mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-06 03:52:10 -04:00
Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3a8419e6e9 | ||
|
|
da0c54f280 | ||
|
|
9920aba6eb | ||
|
|
0446127e11 | ||
|
|
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 | ||
|
|
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 |
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
|
||||
@@ -23,5 +23,50 @@ jobs:
|
||||
run: dotnet test OpenNest.Tests/OpenNest.Tests.csproj
|
||||
- name: Run OpenNest.Engine.Tests
|
||||
run: dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
|
||||
- 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
|
||||
- name: Run OpenNest.IO.Tests
|
||||
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
||||
- name: Run OpenNest.Server.Tests
|
||||
run: dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj
|
||||
|
||||
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: 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_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
|
||||
@@ -48,12 +50,15 @@ jobs:
|
||||
dotnet build OpenNest.sln -c Release
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||
- 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.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
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 +90,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
|
||||
@@ -18,9 +18,11 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
|
||||
dotnet build OpenNest.sln
|
||||
|
||||
# Cross-platform suites: run independently on Linux/macOS/Windows
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
|
||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
|
||||
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
||||
# 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
|
||||
|
||||
@@ -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,122 @@
|
||||
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.");
|
||||
}
|
||||
|
||||
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,243 @@
|
||||
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>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);
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
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,22 @@ 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);
|
||||
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);
|
||||
}
|
||||
result.Mode = Mode.Incremental;
|
||||
return result;
|
||||
}
|
||||
|
||||
private void EmitRawContour(Program program, Shape shape)
|
||||
{
|
||||
var startPoint = GetShapeStartPoint(shape);
|
||||
@@ -300,12 +316,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 +412,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;
|
||||
|
||||
@@ -376,17 +439,31 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var tabbed = Parameters.TabsEnabled
|
||||
&& Parameters.TabConfig != null
|
||||
&& contourType == ContourType.External;
|
||||
if (tabbed)
|
||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||
|
||||
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
|
||||
var leadOutPoint = point;
|
||||
var leadOutNormal = normal;
|
||||
if (!tabbed)
|
||||
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, leadOutNormal, winding));
|
||||
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
|
||||
}
|
||||
|
||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -32,6 +32,20 @@ public sealed class OverlapGeometryStamp
|
||||
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;
|
||||
|
||||
@@ -43,7 +43,7 @@ internal sealed class OverlapSource
|
||||
|
||||
internal OverlapSource(Program program, List<Entity> entities, string error)
|
||||
{
|
||||
codeCount = program.Codes.Count;
|
||||
codeCount = CodeCount(program);
|
||||
rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
|
||||
Entities = entities;
|
||||
Error = error;
|
||||
@@ -54,7 +54,10 @@ internal sealed class OverlapSource
|
||||
internal string Error { get; }
|
||||
|
||||
internal bool Matches(Program program) =>
|
||||
program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
@@ -9,12 +11,16 @@ public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Fail
|
||||
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
|
||||
@@ -36,7 +42,8 @@ public sealed class OverlapReportState
|
||||
/// </summary>
|
||||
public long Begin(Plate plate, bool automatic = false)
|
||||
{
|
||||
Clear(OverlapCheckStatus.Checking);
|
||||
RefreshDisplayPairs(plate);
|
||||
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
|
||||
stamp = OverlapGeometryStamp.Capture(plate);
|
||||
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
|
||||
DisplayMode = OverlapDisplayMode.Areas;
|
||||
@@ -48,6 +55,9 @@ public sealed class OverlapReportState
|
||||
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;
|
||||
@@ -66,20 +76,52 @@ public sealed class OverlapReportState
|
||||
|
||||
public bool EnsureFresh(Plate plate)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
if (stamp == null)
|
||||
return false;
|
||||
if (stamp.Matches(plate))
|
||||
return true;
|
||||
Invalidate();
|
||||
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)
|
||||
@@ -88,8 +130,10 @@ public sealed class OverlapReportState
|
||||
|
||||
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
||||
|
||||
private void Clear(OverlapCheckStatus status)
|
||||
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
|
||||
{
|
||||
if (!preserveDisplay)
|
||||
ClearDisplayPairs();
|
||||
Generation++;
|
||||
Report = null;
|
||||
uncheckedPartCount = 0;
|
||||
|
||||
@@ -71,7 +71,7 @@ public static class PlateOverlapAnalyzer
|
||||
{
|
||||
try
|
||||
{
|
||||
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
|
||||
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)
|
||||
@@ -267,14 +267,32 @@ public static class PlateOverlapAnalyzer
|
||||
internal static bool IsGeometryFailure(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
private static void ValidateProgram(Program program, HashSet<Program> visiting)
|
||||
// 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.");
|
||||
@@ -282,7 +300,7 @@ public static class PlateOverlapAnalyzer
|
||||
{
|
||||
if (!OverlapMaterial.IsFinite(call.Offset) || !double.IsFinite(call.Rotation))
|
||||
throw new ArgumentException("Subprogram pose must be finite.");
|
||||
ValidateProgram(call.Program, visiting);
|
||||
ValidateProgram(call.Program, visiting, true);
|
||||
}
|
||||
}
|
||||
visiting.Remove(program);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
@@ -27,9 +28,14 @@ public static class PostVerificationAnalyzer
|
||||
return new PostVerificationReport(findings);
|
||||
}
|
||||
|
||||
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default)
|
||||
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)
|
||||
@@ -50,8 +56,8 @@ public static class PostVerificationAnalyzer
|
||||
}
|
||||
var materialParts = new List<Part>();
|
||||
var indices = new List<int>();
|
||||
var obstacles = new List<Obstacle>();
|
||||
Vector? position = Vector.Zero;
|
||||
var obstacles = new ReleasedContourState();
|
||||
Vector? position = startPoint;
|
||||
for (var index = 0; index < plate.Parts.Count; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -65,7 +71,7 @@ public static class PostVerificationAnalyzer
|
||||
{
|
||||
try
|
||||
{
|
||||
var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken);
|
||||
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)
|
||||
@@ -83,12 +89,12 @@ public static class PostVerificationAnalyzer
|
||||
{
|
||||
if (part == null || !double.IsFinite(part.Rotation))
|
||||
throw new ArgumentException("Missing part or invalid rotation.");
|
||||
var moves = Read(part.Program, part.Location, position, cancellationToken);
|
||||
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.");
|
||||
AnalyzeMoves(moves, cutoff, obstacles, findings, plateNumber, partNumber, cancellationToken);
|
||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
|
||||
position = moves[^1].End;
|
||||
}
|
||||
catch (Exception exception) when (IsInvalid(exception))
|
||||
@@ -119,172 +125,4 @@ public static class PostVerificationAnalyzer
|
||||
private static bool IsInvalid(Exception exception) => exception is
|
||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||
|
||||
private static List<Move> Read(Program program, Vector origin, Vector? previous,
|
||||
CancellationToken token)
|
||||
{
|
||||
var moves = new List<Move>();
|
||||
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var budget = 1000000;
|
||||
Walk(program, origin, previous);
|
||||
return moves;
|
||||
|
||||
Vector Walk(Program current, Vector frame, Vector? arrival)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PostVerificationGeometry.Validate(frame);
|
||||
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
|
||||
throw new ArgumentException("Missing, recursive or excessively nested program.");
|
||||
var pos = frame;
|
||||
var first = true;
|
||||
var countBefore = moves.Count;
|
||||
foreach (var code in current.Codes)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Program expansion exceeds the verification limit.");
|
||||
if (code == null)
|
||||
throw new ArgumentException("Program contains a missing instruction.");
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
if (!double.IsFinite(call.Rotation))
|
||||
throw new ArgumentException("Subprogram rotation is not finite.");
|
||||
// Call rotation is baked into the shared program by its setter. Do not
|
||||
// rotate again; offsets are frame-relative even in incremental mode.
|
||||
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
if (code is not Motion motion)
|
||||
{
|
||||
if (code is not (Comment or Feedrate or Kerf))
|
||||
throw new NotSupportedException("Unsupported program instruction.");
|
||||
continue;
|
||||
}
|
||||
// Posts disagree about incremental position after suppressed instructions.
|
||||
// Never silently certify a trajectory whose semantics are ambiguous.
|
||||
if (motion.Suppressed)
|
||||
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
|
||||
if (motion is not (RapidMove or LinearMove or ArcMove))
|
||||
throw new NotSupportedException("Unsupported motion.");
|
||||
var reference = current.Mode == Mode.Incremental ? pos : frame;
|
||||
var end = reference + motion.EndPoint;
|
||||
PostVerificationGeometry.Validate(end);
|
||||
var rapid = motion is RapidMove;
|
||||
if (first && !rapid)
|
||||
moves.Add(new(arrival, frame, true, LayerType.Display, null));
|
||||
var start = first && rapid ? arrival : pos;
|
||||
var layer = motion switch
|
||||
{
|
||||
LinearMove line => line.Layer,
|
||||
ArcMove arc => arc.Layer,
|
||||
_ => LayerType.Display
|
||||
};
|
||||
if (!Enum.IsDefined(layer))
|
||||
throw new NotSupportedException("Unsupported motion layer.");
|
||||
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
|
||||
throw new NotSupportedException("Unsupported arc direction.");
|
||||
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
|
||||
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
|
||||
motion is ArcMove { Rotation: RotationType.CW });
|
||||
moves.Add(new(start, end, rapid, layer, curve));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
visiting.Remove(current);
|
||||
if (moves.Count == countBefore)
|
||||
throw new ArgumentException("Program has no motions.");
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeMoves(List<Move> moves, bool cutoff, List<Obstacle> obstacles,
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
|
||||
{
|
||||
var contour = new List<PostVerificationGeometry.Curve>();
|
||||
var unfinished = new List<PostVerificationGeometry.Curve[]>();
|
||||
var hasLead = false;
|
||||
var contourNumber = 0;
|
||||
foreach (var move in moves)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (move.Rapid)
|
||||
{
|
||||
Finish();
|
||||
hasLead = false;
|
||||
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
|
||||
continue;
|
||||
foreach (var obstacle in obstacles)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||
$"of part {obstacle.Part}."));
|
||||
}
|
||||
}
|
||||
else if (move.Layer == LayerType.Leadin)
|
||||
{
|
||||
Finish();
|
||||
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
|
||||
}
|
||||
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
|
||||
{
|
||||
if (move.Layer == LayerType.Leadout && contour.Count > 0
|
||||
&& !PostVerificationGeometry.Closed(contour)
|
||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
|
||||
"Rapid safety for that contour requires manual review."));
|
||||
Finish();
|
||||
hasLead = false;
|
||||
}
|
||||
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
|
||||
{
|
||||
if (contour.Count == 0)
|
||||
{
|
||||
contourNumber++;
|
||||
if (!cutoff && !hasLead)
|
||||
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
|
||||
hasLead = false;
|
||||
// A rapid can pause/reposition without leaving any material gap.
|
||||
// Retain already-cut fragments, but do not turn them into obstacles
|
||||
// until an actually continuous chain closes.
|
||||
var previous = unfinished.FindIndex(chain =>
|
||||
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
|
||||
if (previous >= 0)
|
||||
{
|
||||
contour.AddRange(unfinished[previous]);
|
||||
unfinished.RemoveAt(previous);
|
||||
}
|
||||
}
|
||||
contour.Add(move.Curve);
|
||||
// A completed contour becomes an obstacle immediately, not at part end.
|
||||
if (PostVerificationGeometry.Closed(contour))
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
Finish();
|
||||
if (unfinished.Count > 1)
|
||||
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
|
||||
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
||||
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
|
||||
|
||||
void Finish()
|
||||
{
|
||||
if (contour.Count == 0)
|
||||
return;
|
||||
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
|
||||
// no flag or tab configuration is used as evidence of retention.
|
||||
if (!cutoff && PostVerificationGeometry.Closed(contour))
|
||||
obstacles.Add(new(part, contourNumber, contour.ToArray()));
|
||||
else if (!cutoff)
|
||||
unfinished.Add(contour.ToArray());
|
||||
contour.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer,
|
||||
PostVerificationGeometry.Curve Curve);
|
||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
|
||||
}
|
||||
@@ -11,6 +11,46 @@ 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)
|
||||
@@ -75,6 +115,134 @@ internal static class PostVerificationGeometry
|
||||
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))
|
||||
{
|
||||
var direction = (End - Start) * (1 / Length);
|
||||
if ((other.ContactAfterStart(Start, direction, Length)
|
||||
|| other.ContactAfterStart(End, direction * -1, Length)) && nativeContactCount == 0)
|
||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||
}
|
||||
if (other.Center is null && Center.HasValue)
|
||||
{
|
||||
var direction = (other.End - other.Start) * (1 / other.Length);
|
||||
if ((ContactAfterStart(other.Start, direction, other.Length)
|
||||
|| ContactAfterStart(other.End, direction * -1, other.Length)) && nativeContactCount == 0)
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
|
||||
{
|
||||
if (center is not { } c)
|
||||
@@ -97,6 +265,13 @@ internal static class PostVerificationGeometry
|
||||
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|
||||
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
|
||||
|
||||
// How far past r^2 a squared distance from the centre still counts as touching the arc.
|
||||
// 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)
|
||||
@@ -107,7 +282,7 @@ internal static class PostVerificationGeometry
|
||||
var projection = Dot(relative, direction);
|
||||
var perpendicular = Cross(relative, direction);
|
||||
var square = Radius * Radius - perpendicular * perpendicular;
|
||||
if (square < -Epsilon * System.Math.Max(1, Radius * 2))
|
||||
if (square < -ContactSlack)
|
||||
return false;
|
||||
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||
return Hit(projection - offset) || Hit(projection + offset);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||
<InternalsVisibleTo Include="OpenNest.Engine" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Clipper2" Version="2.0.0" />
|
||||
|
||||
@@ -135,6 +135,39 @@ namespace OpenNest
|
||||
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;
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
|
||||
@@ -7,9 +7,15 @@ namespace OpenNest.Data;
|
||||
/// </summary>
|
||||
public interface INestRepository
|
||||
{
|
||||
/// <summary>All stored nests, newest saved first.</summary>
|
||||
/// <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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,26 @@ public sealed class NestDefaults
|
||||
/// 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)
|
||||
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;
|
||||
@@ -88,13 +105,10 @@ public sealed class NestDefaults
|
||||
var json = File.ReadAllText(path);
|
||||
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
status = NestDefaultsStatus.Invalid;
|
||||
return defaults;
|
||||
}
|
||||
catch (IOException)
|
||||
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;
|
||||
}
|
||||
@@ -107,10 +121,7 @@ public sealed class NestDefaults
|
||||
|
||||
status = NestDefaultsStatus.Ok;
|
||||
|
||||
if (
|
||||
dto.Units is not null
|
||||
&& Enum.TryParse<Units>(dto.Units, ignoreCase: true, out var units)
|
||||
)
|
||||
if (TryParseUnits(dto.Units, out var units))
|
||||
defaults.Units = units;
|
||||
|
||||
if (
|
||||
@@ -195,7 +206,7 @@ public sealed class NestDefaults
|
||||
var dto = new NestDefaultsDto
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
Units = Units.ToString().ToLowerInvariant(),
|
||||
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
|
||||
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
|
||||
Quadrant = Quadrant,
|
||||
PartSpacing = PartSpacing,
|
||||
@@ -228,6 +239,38 @@ public sealed class NestDefaults
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
@@ -247,7 +290,9 @@ public sealed class NestDefaults
|
||||
private sealed record NestDefaultsDto
|
||||
{
|
||||
public int? Version { get; init; } = CurrentVersion;
|
||||
public string? Units { get; init; }
|
||||
// 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; }
|
||||
|
||||
@@ -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,108 @@
|
||||
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. Call from one thread (the UI thread).
|
||||
/// </summary>
|
||||
public sealed class NestDetailsSession : IDisposable
|
||||
{
|
||||
private readonly Func<Guid, CancellationToken, Task<NestDetails>> _load;
|
||||
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
|
||||
{
|
||||
var details = await _load(id, cancellation.Token);
|
||||
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>Forgets the current details and discards any load in flight (no nest highlighted).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_generation++;
|
||||
var pending = _pending;
|
||||
_pending = null;
|
||||
NestId = null;
|
||||
Details = null;
|
||||
IsLoading = false;
|
||||
pending?.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,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,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -58,6 +59,40 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
||||
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);
|
||||
|
||||
@@ -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,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,96 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Cutouts;
|
||||
using OpenNest.Engine.Tests.NestingEngines;
|
||||
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||
|
||||
public class CutoutLatticeFillTests
|
||||
{
|
||||
private const double Spacing = 0.25;
|
||||
|
||||
// A 20" ring around a 10" round cutout. At 0.25" spacing the usable circle is 9.5" across;
|
||||
// the largest square inside it is 6.72", which holds a 5 x 5 grid of 1" squares.
|
||||
private const int InscribedRectangleCount = 25;
|
||||
|
||||
private static NestJobPart Ring() => JobBuilder.Part("ring", TestShapes.Ring(20, 10), 1);
|
||||
|
||||
private static NestJobPart Squares(double side, int quantity) =>
|
||||
JobBuilder.Part("square", TestShapes.Rectangle(side, side), quantity);
|
||||
|
||||
[Fact]
|
||||
public void ShiftedLatticeFillsMoreOfARoundCutoutThanItsInscribedRectangle()
|
||||
{
|
||||
var poses = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing);
|
||||
|
||||
Assert.True(poses.Count > InscribedRectangleCount, $"placed {poses.Count}");
|
||||
AssertValidLayout(Ring(), Squares(1, poses.Count), poses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShiftingTheLatticeKeepsMoreCopiesThanTheUnshiftedLattice()
|
||||
{
|
||||
var shifted = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing);
|
||||
var unshifted = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing, 0, default);
|
||||
|
||||
Assert.True(shifted.Count > unshifted.Count, $"shifted {shifted.Count}, unshifted {unshifted.Count}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(7.0)] // diagonal 9.90: wider than the cutout less its wall spacing
|
||||
[InlineData(6.75)] // corners 4.773 from the centre; the usable radius is 4.75
|
||||
public void InsertThatCannotClearTheCutoutWallIsNeverPlaced(double side)
|
||||
{
|
||||
Assert.Empty(CutoutLatticeFill.Fill(Ring(), 0, Squares(side, 4), 4, Spacing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuantityCapsTheCopiesReturned()
|
||||
{
|
||||
var poses = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 5), 5, Spacing);
|
||||
|
||||
Assert.Equal(5, poses.Count);
|
||||
AssertValidLayout(Ring(), Squares(1, 5), poses);
|
||||
}
|
||||
|
||||
// Both cases fail intermittently; unskip when Project Memory opennest issue c98c21bd is fixed.
|
||||
private const string FillNondeterminism = "Open defect (Project Memory opennest c98c21bd): Default Fill can "
|
||||
+ "return different, equally scored lattices between identical calls.";
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.0, 1.0, Skip = FillNondeterminism)]
|
||||
[InlineData(1.5, 0.75, Skip = FillNondeterminism)]
|
||||
public void RepeatedCallsReturnIdenticalPoses(double length, double width)
|
||||
{
|
||||
var part = JobBuilder.Part("part", TestShapes.Rectangle(length, width), 100);
|
||||
|
||||
var first = CutoutLatticeFill.Fill(Ring(), 0, part, 100, Spacing);
|
||||
var second = CutoutLatticeFill.Fill(Ring(), 0, part, 100, Spacing);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
/// <summary>Places the ring 1" in from the sheet corner, moves the copies with it and runs
|
||||
/// the production layout check over the whole sheet.</summary>
|
||||
private static void AssertValidLayout(NestJobPart frame, NestJobPart insert, IReadOnlyList<NestJobPlacement> poses)
|
||||
{
|
||||
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
|
||||
var job = JobBuilder.Job(new[] { frame, insert }, new[] { stock });
|
||||
var bounds = JobPartGeometry.Read(frame.Geometry).Bounds;
|
||||
var x = 1 - bounds.Left;
|
||||
var y = 1 - bounds.Bottom;
|
||||
var placements = new[] { new NestJobPlacement(frame.Id, 0, x, y, 0) }
|
||||
.Concat(poses.Select((p, i) => new NestJobPlacement(insert.Id, i, p.X + x, p.Y + y, p.Rotation)))
|
||||
.ToArray();
|
||||
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, stock, placements) },
|
||||
new[]
|
||||
{
|
||||
new PartFulfillment(frame.Id, 1, 1, 0),
|
||||
new PartFulfillment(insert.Id, insert.Quantity, poses.Count, insert.Quantity - poses.Count),
|
||||
},
|
||||
new[] { new StockUsage(stock.Id, 1, null) });
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public class IrregularBlockTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
|
||||
new Dictionary<int, IReadOnlyList<PairPose>>();
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void SmallDemandNeverPreparesAFill(int quantity)
|
||||
{
|
||||
var job = Job(new[] { Part("ell", LShape(9, 7, 3), quantity, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
|
||||
var types = PartCatalog.Build(job);
|
||||
using var catalog = new BlockCatalog(0.25, types, NoPairs);
|
||||
Assert.Empty(catalog.Get(types[0], quantity, new Box(0, 0, 60, 40), CancellationToken.None));
|
||||
Assert.Equal(0, catalog.PreparationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillProposalIsTrimmedAndCertifiedWithoutChangingSingleRotations()
|
||||
{
|
||||
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 7, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
|
||||
var types = PartCatalog.Build(job);
|
||||
var original = types[0].Orientations.ToArray();
|
||||
using var catalog = new BlockCatalog(0.25, types, NoPairs);
|
||||
var block = catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None);
|
||||
Assert.Equal(7, block.Count);
|
||||
Assert.Equal(original, types[0].Orientations);
|
||||
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
|
||||
for (var i = 0; i < block.Count; i++)
|
||||
for (var j = i + 1; j < block.Count; j++)
|
||||
Assert.True(NestLayoutCheck.Clears(geometry,
|
||||
new NestJobPlacement("ell", i, block[i].X, block[i].Y, block[i].Orientation.Rotation), geometry,
|
||||
new NestJobPlacement("ell", j, block[j].X, block[j].Y, block[j].Orientation.Rotation), 0.25));
|
||||
Assert.Same(block, catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2.1)]
|
||||
public void InvalidInternalSpacingRejectsTheWholeProposal(double offset)
|
||||
{
|
||||
var program = Shapes.Rectangle(2, 2);
|
||||
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
|
||||
var type = PartCatalog.Build(job)[0];
|
||||
var drawing = new Drawing("box", program);
|
||||
var members = Enumerable.Range(0, 3).Select(i => new OpenNest.Part(drawing)
|
||||
{ Location = new Vector(i * offset, 0) }).ToArray();
|
||||
var orientations = type.Orientations.ToList();
|
||||
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
|
||||
Assert.Equal(type.Orientations, orientations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicalFreeRectanglesExcludeOccupiedMaterialAndClearance()
|
||||
{
|
||||
var job = Job(new[] { Rectangle("box", 4, 10, 1) }, new[] { Stock("sheet", 10, 20) });
|
||||
var type = PartCatalog.Build(job)[0];
|
||||
var part = new Placed(type.Orientations[0], 8, 0);
|
||||
var rectangles = BlockCatalog.Rectangles(new Box(0, 0, 20, 10), new[] { part }, 0.25);
|
||||
Assert.NotEmpty(rectangles);
|
||||
Assert.All(rectangles, box => Assert.True(box.Right <= 7.75 || box.Left >= 12.25));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedEllBlockCompetesWithPairsOnAnOpenSheet()
|
||||
{
|
||||
var stock = Stock("sheet", 30, 40, spacing: 0.25);
|
||||
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 44, RotationPolicy.Automatic) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
var pairs = PairCatalog.Build(types, 0.25, 40, 30, CancellationToken.None);
|
||||
using var blocks = new BlockCatalog(0.25, types, pairs);
|
||||
var before = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
|
||||
PackAxis.X, 1, new WorkCounter()).Fill(new[] { 44 }, CancellationToken.None);
|
||||
var after = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
|
||||
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 44 }, CancellationToken.None);
|
||||
Assert.True(after.Parts.Count > before.Parts.Count,
|
||||
$"Before {before.Parts.Count}; after {after.Parts.Count}");
|
||||
var result = new NestJobResultBuilder(job);
|
||||
result.AddSheet(stock, after.Parts.Select(p => ("ell", p.X, p.Y, p.Orientation.Rotation)));
|
||||
LayoutAssert.Valid(job, result.Build(NestJobStopReason.StockExhausted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UndersizedRectangleKeepsSinglesFallback()
|
||||
{
|
||||
var stock = Stock("sheet", 3, 3, spacing: 0.25);
|
||||
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
using var blocks = new BlockCatalog(0.25, types, NoPairs);
|
||||
Assert.Empty(blocks.Get(types[0], 3, new Box(0, 0, 3, 3), CancellationToken.None));
|
||||
Assert.Equal(0, blocks.PreparationCount);
|
||||
var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock,
|
||||
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 3 }, CancellationToken.None);
|
||||
Assert.Single(fill.Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenRotationsAreRejectedBeforeAddingGroupOrientations()
|
||||
{
|
||||
var program = LShape(9, 7, 3);
|
||||
var job = Job(new[] { Part("ell", program, 3, RotationPolicy.Fixed(0)) },
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
|
||||
var type = PartCatalog.Build(job)[0];
|
||||
var drawing = new Drawing("ell", program);
|
||||
var members = Enumerable.Range(0, 3).Select(i =>
|
||||
{
|
||||
var part = OpenNest.Part.CreateAtOrigin(drawing, System.Math.PI / 2);
|
||||
part.Offset(new Vector(i * 12, 0));
|
||||
return part;
|
||||
}).ToArray();
|
||||
var orientations = type.Orientations.ToList();
|
||||
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
|
||||
Assert.Equal(type.Orientations, orientations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationDuringCertificationPropagates()
|
||||
{
|
||||
var program = Shapes.Rectangle(2, 2);
|
||||
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
|
||||
var type = PartCatalog.Build(job)[0];
|
||||
var drawing = new Drawing("box", program);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var members = new CancellingMembers(Enumerable.Range(0, 3)
|
||||
.Select(i => new OpenNest.Part(drawing) { Location = new Vector(i * 3, 0) }).ToArray(), cancellation);
|
||||
Assert.Throws<OperationCanceledException>(() => BlockCatalog.Resolve(type, members,
|
||||
type.Orientations.ToList(), 0.25, cancellation.Token));
|
||||
}
|
||||
|
||||
private sealed class CancellingMembers(OpenNest.Part[] parts, CancellationTokenSource cancellation)
|
||||
: IReadOnlyList<OpenNest.Part>
|
||||
{
|
||||
public int Count => parts.Length;
|
||||
public OpenNest.Part this[int index] => parts[index];
|
||||
public IEnumerator<OpenNest.Part> GetEnumerator()
|
||||
{
|
||||
foreach (var part in parts)
|
||||
yield return part;
|
||||
cancellation.Cancel();
|
||||
}
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationIsNotConvertedIntoAnEmptyProposal()
|
||||
{
|
||||
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { Stock("sheet", 10, 20) });
|
||||
var types = PartCatalog.Build(job);
|
||||
using var catalog = new BlockCatalog(0.25, types, NoPairs);
|
||||
Assert.Throws<OperationCanceledException>(() =>
|
||||
catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
/// <summary>
|
||||
/// A part whose material exactly fills the work area in one allowed rotation. The engine must
|
||||
/// place it: preparation padding for arc flattening may not shrink a line-only part's room.
|
||||
/// </summary>
|
||||
public class IrregularExactContactTests
|
||||
{
|
||||
private const double PartLength = 8;
|
||||
private const double PartWidth = 3;
|
||||
private const double Edge = 0.25;
|
||||
private const double Spacing = 0.25;
|
||||
|
||||
/// <summary>Gap shortfall the independent oracle tolerates (float noise only).</summary>
|
||||
private const double OracleNoise = 1e-9;
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void ExactContactRotatedRectangleIsPlacedInEveryQuadrant(int quadrant)
|
||||
{
|
||||
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Automatic);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
AssertPlacedSafely(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(8.501, 3.501)]
|
||||
[InlineData(8.5, 3.501)]
|
||||
[InlineData(8.501, 3.5)]
|
||||
[InlineData(8.52, 3.52)] // Generator smoke stock: a looser control, not exact-contact acceptance.
|
||||
public void LargerNeighbouringStockStillPlacesThePart(double width, double length)
|
||||
{
|
||||
var job = RectangleJob(width, length, 1, RotationPolicy.Automatic);
|
||||
|
||||
AssertPlacedSafely(job, new IrregularNestingEngine().Solve(job));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(8.499, 3.5, 1)]
|
||||
[InlineData(8.5, 3.499, 1)]
|
||||
[InlineData(8.499, 3.499, 3)]
|
||||
public void StockOneThousandthTooSmallLeavesThePartUnplaced(double width, double length, int quadrant)
|
||||
{
|
||||
var job = RectangleJob(width, length, quadrant, RotationPolicy.Automatic);
|
||||
|
||||
AssertUnplaced(job, new IrregularNestingEngine().Solve(job));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
public void ZeroRotationOnlyCannotFitAndStaysUnplaced(int quadrant)
|
||||
{
|
||||
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Fixed(0));
|
||||
|
||||
AssertUnplaced(job, new IrregularNestingEngine().Solve(job));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void HandPoseAtExactContactPassesAndOneThousandthOverEdgeFails(int quadrant)
|
||||
{
|
||||
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Automatic);
|
||||
var stock = job.Plates[0];
|
||||
var work = stock.WorkArea;
|
||||
// +90 degrees maps the source rectangle to X in [-3, 0], Y in [0, 8].
|
||||
var exact = new NestJobPlacement("rect", 0, work.Left + PartWidth, work.Bottom, System.Math.PI / 2);
|
||||
Assert.True(OracleGaps(stock, exact).Min() >= Edge - OracleNoise);
|
||||
Assert.Empty(Violations(job, exact));
|
||||
Validate(job, exact);
|
||||
|
||||
foreach (var (dx, dy) in new[] { (0.001, 0.0), (-0.001, 0.0), (0.0, 0.001), (0.0, -0.001) })
|
||||
{
|
||||
var over = exact with { X = exact.X + dx, Y = exact.Y + dy };
|
||||
Assert.True(OracleGaps(stock, over).Min() < Edge - 0.0009);
|
||||
Assert.NotEmpty(Violations(job, over));
|
||||
Assert.Throws<InvalidOperationException>(() => Validate(job, over));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineOnlyBoundsAreExactWhileFlatteningToleranceIsKept()
|
||||
{
|
||||
var job = RectangleJob(8.5, 3.5, 1, RotationPolicy.Automatic);
|
||||
var type = PartCatalog.Build(job).Single();
|
||||
var rotated = type.Orientations.Single(o => System.Math.Abs(o.Rotation - System.Math.PI / 2) < 1e-9);
|
||||
|
||||
// Chord tolerance still drives tessellation and NFP footprints.
|
||||
Assert.Equal(PartCatalog.ChordTolerance, rotated.Tolerance);
|
||||
Assert.Equal(-PartWidth, rotated.MinX, 12);
|
||||
Assert.Equal(0, rotated.MinY, 12);
|
||||
Assert.Equal(0, rotated.MaxX, 12);
|
||||
Assert.Equal(PartLength, rotated.MaxY, 12);
|
||||
Assert.True(job.Plates[0].Fits(rotated.Width, rotated.Height));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)]
|
||||
[InlineData(0.3)]
|
||||
[InlineData(1.2)]
|
||||
public void ArcBoundsStillContainTheTruePerimeterAndTheFlattenedOutline(double angle)
|
||||
{
|
||||
var job = Job(new[] { Part("obround", Shapes.Obround(9, 4), 1, RotationPolicy.Fixed(angle)) },
|
||||
new[] { Stock("sheet", 40, 40, spacing: Spacing) });
|
||||
var type = PartCatalog.Build(job).Single();
|
||||
var o = type.Orientations.Single();
|
||||
Assert.True(o.Tolerance >= PartCatalog.ChordTolerance);
|
||||
|
||||
// Independent oracle: sample the stadium analytically (straight sides plus semicircles).
|
||||
const double length = 9, width = 4, r = width / 2;
|
||||
var points = new List<(double X, double Y)>();
|
||||
for (var i = 0; i <= 2000; i++)
|
||||
{
|
||||
var t = System.Math.PI * i / 2000;
|
||||
points.Add((1 + length - r + r * System.Math.Sin(t), 1 + r - r * System.Math.Cos(t)));
|
||||
points.Add((1 + r - r * System.Math.Sin(t), 1 + r + r * System.Math.Cos(t)));
|
||||
}
|
||||
var (c, s) = (System.Math.Cos(angle), System.Math.Sin(angle));
|
||||
foreach (var (x, y) in points)
|
||||
{
|
||||
var rx = x * c - y * s;
|
||||
var ry = x * s + y * c;
|
||||
Assert.InRange(rx, o.MinX - 1e-9, o.MaxX + 1e-9);
|
||||
Assert.InRange(ry, o.MinY - 1e-9, o.MaxY + 1e-9);
|
||||
}
|
||||
foreach (var p in o.Outline)
|
||||
{
|
||||
Assert.InRange(p.x, o.MinX, o.MaxX);
|
||||
Assert.InRange(p.y, o.MinY, o.MaxY);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, false)]
|
||||
[InlineData(System.Math.PI / 2, false)]
|
||||
[InlineData(System.Math.PI, false)]
|
||||
[InlineData(3 * System.Math.PI / 2, false)]
|
||||
[InlineData(0.0, true)]
|
||||
[InlineData(System.Math.PI / 2, true)]
|
||||
[InlineData(System.Math.PI, true)]
|
||||
[InlineData(3 * System.Math.PI / 2, true)]
|
||||
public void ShortEdgeChainBoundsKeepEveryAnalyticEndpoint(double angle, bool extraOrientation)
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Shapes.Polyline(ShortEdgeChainVertices()), 1,
|
||||
RotationPolicy.Fixed(extraOrientation ? 0 : angle)) }, new[] { Stock("sheet", 20, 20) });
|
||||
var type = PartCatalog.Build(job).Single();
|
||||
var orientation = extraOrientation
|
||||
? PartCatalog.CreateOrientation(type, 100, angle)!
|
||||
: type.Orientations.Single();
|
||||
Assert.NotNull(orientation);
|
||||
|
||||
var (c, s) = (System.Math.Cos(angle), System.Math.Sin(angle));
|
||||
var endpoints = ShortEdgeChainVertices()
|
||||
.Select(p => (X: p.X * c - p.Y * s, Y: p.X * s + p.Y * c)).ToArray();
|
||||
Assert.Equal(endpoints.Min(p => p.X), orientation.MinX, 12);
|
||||
Assert.Equal(endpoints.Min(p => p.Y), orientation.MinY, 12);
|
||||
Assert.Equal(endpoints.Max(p => p.X), orientation.MaxX, 12);
|
||||
Assert.Equal(endpoints.Max(p => p.Y), orientation.MaxY, 12);
|
||||
Assert.Equal(PartCatalog.ChordTolerance, orientation.Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortEdgeChainCannotEscapeAnExactWorkArea()
|
||||
{
|
||||
var job = ShortEdgeChainJob(8.5);
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
Assert.Empty(NestLayoutCheck.Violations(job, result));
|
||||
AssertUnplaced(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortEdgeChainFitsWhenStockContainsItsTrueExtent()
|
||||
{
|
||||
var job = ShortEdgeChainJob(8.500024);
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal((1, 1, 0), (result.Fulfillment.Single().Requested,
|
||||
result.Fulfillment.Single().Placed, result.Fulfillment.Single().Unplaced));
|
||||
var sheet = Assert.Single(result.Plates);
|
||||
var pose = Assert.Single(sheet.Placements);
|
||||
Assert.True(OracleGaps(sheet.Stock, pose, ShortEdgeChainVertices()).Min() >= Edge - OracleNoise);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Empty(NestLayoutCheck.Violations(job, result));
|
||||
Validate(job, pose);
|
||||
}
|
||||
|
||||
// Polygon cleanup can collapse these individually short edges, but their accumulated
|
||||
// outward extent is real material and exceeds the unchanged work-area slack.
|
||||
private static (double X, double Y)[] ShortEdgeChainVertices() =>
|
||||
[(0, 0), (0, 3), (8, 3), (8.000008, 2.999992),
|
||||
(8.000016, 2.999984), (8.000024, 2.999976), (8, 0)];
|
||||
|
||||
private static NestJob ShortEdgeChainJob(double length) =>
|
||||
Job(new[] { Part("rect", Shapes.Polyline(ShortEdgeChainVertices()), 1, RotationPolicy.Fixed(0)) },
|
||||
new[] { Stock("sheet", 3.5, length, spacing: Spacing, edge: new Spacing(Edge, Edge, Edge, Edge)) });
|
||||
|
||||
private static NestJob RectangleJob(double width, double length, int quadrant, RotationPolicy rotation) =>
|
||||
Job(new[] { Rectangle("rect", PartLength, PartWidth, 1, rotation) },
|
||||
new[] { Stock("sheet", width, length, spacing: Spacing, edge: new Spacing(Edge, Edge, Edge, Edge), quadrant: quadrant) });
|
||||
|
||||
private static void AssertPlacedSafely(NestJob job, NestJobResult result)
|
||||
{
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
var fulfillment = Assert.Single(result.Fulfillment);
|
||||
Assert.Equal((1, 1, 0), (fulfillment.Requested, fulfillment.Placed, fulfillment.Unplaced));
|
||||
var sheet = Assert.Single(result.Plates);
|
||||
Assert.Equal(1, Assert.Single(result.StockUsage).Used);
|
||||
var pose = Assert.Single(sheet.Placements);
|
||||
|
||||
Assert.True(OracleGaps(sheet.Stock, pose).Min() >= Edge - OracleNoise,
|
||||
$"pose ({pose.X:R}, {pose.Y:R}, {pose.Rotation:R}) gaps {string.Join(", ", OracleGaps(sheet.Stock, pose))}");
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Empty(NestLayoutCheck.Violations(job, result));
|
||||
Validate(job, pose);
|
||||
}
|
||||
|
||||
private static void AssertUnplaced(NestJob job, NestJobResult result)
|
||||
{
|
||||
Assert.NotEqual(NestJobStatus.Complete, result.Status);
|
||||
Assert.Empty(result.Plates);
|
||||
var fulfillment = Assert.Single(result.Fulfillment);
|
||||
Assert.Equal((1, 0, 1), (fulfillment.Requested, fulfillment.Placed, fulfillment.Unplaced));
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
/// <summary>Clearance from the rotated nominal rectangle to each physical sheet edge
|
||||
/// (left, bottom, right, top), computed from corners and the quadrant's sheet origin.</summary>
|
||||
private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose) =>
|
||||
OracleGaps(stock, pose, [(0, 0), (PartLength, 0), (PartLength, PartWidth), (0, PartWidth)]);
|
||||
|
||||
private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose,
|
||||
(double X, double Y)[] vertices)
|
||||
{
|
||||
var (c, s) = (System.Math.Cos(pose.Rotation), System.Math.Sin(pose.Rotation));
|
||||
var corners = vertices
|
||||
.Select(p => (X: p.X * c - p.Y * s + pose.X, Y: p.X * s + p.Y * c + pose.Y))
|
||||
.ToArray();
|
||||
var sheetLeft = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
|
||||
var sheetBottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
|
||||
return new[]
|
||||
{
|
||||
corners.Min(p => p.X) - sheetLeft,
|
||||
corners.Min(p => p.Y) - sheetBottom,
|
||||
sheetLeft + stock.Size.Length - corners.Max(p => p.X),
|
||||
sheetBottom + stock.Size.Width - corners.Max(p => p.Y),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> Violations(NestJob job, NestJobPlacement pose)
|
||||
{
|
||||
var stock = job.Plates[0];
|
||||
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, stock, new[] { pose }) },
|
||||
new[] { new PartFulfillment("rect", 1, 1, 0) }, new[] { new StockUsage(stock.Id, 1, null) });
|
||||
return NestLayoutCheck.Violations(job, result);
|
||||
}
|
||||
|
||||
private static void Validate(NestJob job, NestJobPlacement pose) =>
|
||||
NestJobValidator.ValidateCandidate(new PlateCandidate(new[] { pose }), job.Plates[0],
|
||||
new Dictionary<string, int> { ["rect"] = 1 }, job.Parts.ToDictionary(p => p.Id));
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public class IrregularPairTests
|
||||
{
|
||||
// A 20-long wedge, 8 wide at one end and 6 at the other. Lying down, two copies need
|
||||
// 8 + 0.25 + 8 = 16.25 across, but nested slant-to-slant they need only about 14.25.
|
||||
// The 30 x 15 sheet holds the pair only when the two copies interlock.
|
||||
private static OpenNest.CNC.Program Wedge() => Polyline((0, 0), (8, 0), (6, 20), (0, 20));
|
||||
|
||||
private static OpenNest.CNC.Program MirroredWedge() => Polyline((0, 0), (8, 0), (8, 20), (2, 20));
|
||||
|
||||
public static TheoryData<string> Wedges => new() { "wedge", "mirrored" };
|
||||
|
||||
private static OpenNest.CNC.Program NativeU()
|
||||
{
|
||||
// Same native semicircular U as the shared curve-contact regression; no DXF dependency.
|
||||
var p = new OpenNest.CNC.Program();
|
||||
p.MoveTo(2.5, 0.625);
|
||||
p.LineTo(2.5, 0);
|
||||
p.LineTo(1.5, 0);
|
||||
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 3, 1.5, 1.5, OpenNest.RotationType.CW));
|
||||
p.LineTo(2.5, 3);
|
||||
p.LineTo(2.5, 2.375);
|
||||
p.LineTo(1.5, 2.375);
|
||||
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 0.625, 1.5, 1.5, OpenNest.RotationType.CCW));
|
||||
p.LineTo(2.5, 0.625);
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeUQuantityTwoOffersAndPlacesAnInterlockedClearPair()
|
||||
{
|
||||
var job = Job(new[] { Part("native-u", NativeU(), 2, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 6, 6, spacing: 0.25) });
|
||||
var types = PartCatalog.Build(job);
|
||||
var pairs = PairCatalog.Build(types, 0.25, 6, 6, CancellationToken.None);
|
||||
Assert.NotEmpty(pairs[0]);
|
||||
var pair = pairs[0][0];
|
||||
// Bounding boxes overlap on both axes: this is an interlock, not adjacent boxes.
|
||||
Assert.True(System.Math.Min(pair.A.MaxX, pair.Dx + pair.B.MaxX)
|
||||
> System.Math.Max(pair.A.MinX, pair.Dx + pair.B.MinX));
|
||||
Assert.True(System.Math.Min(pair.A.MaxY, pair.Dy + pair.B.MaxY)
|
||||
> System.Math.Max(pair.A.MinY, pair.Dy + pair.B.MinY));
|
||||
var tightJob = Job(job.Parts.ToArray(),
|
||||
new[] { Stock("tight", pair.Height + 0.01, pair.Width + 0.01, spacing: 0.25) });
|
||||
var result = new IrregularNestingEngine().Solve(tightJob);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
LayoutAssert.Valid(tightJob, result);
|
||||
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
|
||||
Assert.Empty(geometry.Cutouts);
|
||||
Assert.Equal(2.5, geometry.Bounds.Length, 9);
|
||||
Assert.Equal(3, geometry.Bounds.Width, 9);
|
||||
var outlines = result.Plates[0].Placements.Select(placement =>
|
||||
{
|
||||
var shape = (OpenNest.Geometry.Shape)geometry.Perimeter.Clone();
|
||||
shape.Rotate(placement.Rotation);
|
||||
shape.Offset(placement.X, placement.Y);
|
||||
return shape.ToPolygonWithTolerance(1e-6);
|
||||
}).ToArray();
|
||||
// Raw fine boundaries, independent of BestFit's offsets and the NFP free regions.
|
||||
Assert.True(OpenNest.Geometry.Clearance.Between(outlines[0], outlines[1]).Distance >= 0.25 - 2e-6);
|
||||
Assert.True(outlines[0].BoundingBox.Right > outlines[1].BoundingBox.Left
|
||||
&& outlines[1].BoundingBox.Right > outlines[0].BoundingBox.Left);
|
||||
Assert.True(outlines[0].BoundingBox.Top > outlines[1].BoundingBox.Bottom
|
||||
&& outlines[1].BoundingBox.Top > outlines[0].BoundingBox.Bottom);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)] // Coincident material.
|
||||
[InlineData(3.1)] // Disjoint outlines, but only 0.1 clearance rather than 0.25.
|
||||
public void PairResolutionRejectsOverlapAndInsufficientSpacing(double offset)
|
||||
{
|
||||
var program = NativeU();
|
||||
var job = Job(new[] { Part("native-u", program, 2, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 10, 10, spacing: 0.25) });
|
||||
var type = Assert.Single(PartCatalog.Build(job));
|
||||
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
|
||||
var drawing = new Drawing("native-u", program);
|
||||
var members = new List<OpenNest.Part>
|
||||
{
|
||||
new(drawing),
|
||||
new(drawing) { Location = new OpenNest.Geometry.Vector(0, offset) },
|
||||
};
|
||||
var extra = new List<Orientation>();
|
||||
Assert.Null(PairCatalog.Resolve(type, extra, geometry, members, 0, 0.25));
|
||||
Assert.Empty(extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PairOnlyOrientationRemainsEligibleWhenSampledSinglesDoNotFit()
|
||||
{
|
||||
var wedge = MirroredWedge();
|
||||
wedge.Rotate(System.Math.PI / 4);
|
||||
// A many-type job samples only two single orientations. The pair adds its own
|
||||
// legal rotations; neither sampled single fits this narrow stock.
|
||||
var parts = Enumerable.Range(0, 24).Select(i => Rectangle($"oversized-{i}", 100, 100, 1)).ToList();
|
||||
parts.Insert(0, Part("wedge", wedge, 2, RotationPolicy.Automatic));
|
||||
var stock = Stock("sheet", 15, 30, spacing: 0.25);
|
||||
var job = Job(parts.ToArray(), new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
Assert.DoesNotContain(types[0].Orientations, o => stock.Fits(o.Width, o.Height));
|
||||
var pairs = PairCatalog.Build(types, 0.25, 30, 15, CancellationToken.None);
|
||||
Assert.Contains(pairs[0], p => stock.Fits(p.Width, p.Height));
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Wedges))]
|
||||
public void QuantityTwoInterlocksWhenOnlyAPairFits(string shape)
|
||||
{
|
||||
var program = shape == "wedge" ? Wedge() : MirroredWedge();
|
||||
var job = Job(
|
||||
new[] { Part("wedge", program, 2, RotationPolicy.Fixed(System.Math.PI / 2, allow180Equivalent: true)) },
|
||||
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Wedges))]
|
||||
public void AutomaticRotationAlsoFindsThePair(string shape)
|
||||
{
|
||||
var program = shape == "wedge" ? Wedge() : MirroredWedge();
|
||||
var job = Job(
|
||||
new[] { Part("wedge", program, 2, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PairsNeverUseARotationThePolicyForbids()
|
||||
{
|
||||
// Fixed at 0 with no 180-degree twin: every best-fit pair turns its second copy,
|
||||
// so no pair is allowed and the parts must be placed as singles.
|
||||
var job = Job(
|
||||
new[] { Part("wedge", Wedge(), 2, RotationPolicy.Fixed(0)) },
|
||||
new[] { Stock("sheet", 25, 30, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.All(result.Plates.SelectMany(p => p.Placements), p => Assert.Equal(0, p.Rotation, 9));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OddDemandPlacesTheLastCopyAsASingleWithoutOverdrawing()
|
||||
{
|
||||
var job = Job(new[] { Part("wedge", MirroredWedge(), 3, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 15, 30, spacing: 0.25) });
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(new[] { 1, 2 }, result.Plates.Select(p => p.Placements.Count).OrderBy(n => n));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PairMembersLeaveTheirEnclosedGapAvailableForLaterParts()
|
||||
{
|
||||
var stock = Stock("sheet", 6.1, 6.1, spacing: 0.1);
|
||||
var job = Job(new[] { Rectangle("bar", 2, 6, 2), Rectangle("insert", 1, 1, 1) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
var bar = Assert.Single(types[0].Orientations);
|
||||
var pair = new PairPose { A = bar, B = bar, Dx = 4, Dy = 0 };
|
||||
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
|
||||
// Along Y the pair buys twice the area for the same advance, so it wins first.
|
||||
var fill = new FrontierPacker(types, new NoFitCache(0.1), pairs, stock,
|
||||
PackAxis.Y, 1, new WorkCounter()).Fill(new[] { 2, 1 }, CancellationToken.None);
|
||||
Assert.Equal(3, fill.Parts.Count);
|
||||
Assert.Equal(0, fill.Parts[0].Orientation.TypeIndex);
|
||||
Assert.Equal(0, fill.Parts[1].Orientation.TypeIndex);
|
||||
var insert = fill.Parts[2];
|
||||
Assert.Equal(1, insert.Orientation.TypeIndex);
|
||||
Assert.True(insert.Left > fill.Parts[0].Right);
|
||||
Assert.True(insert.Right < fill.Parts[1].Left);
|
||||
var geometries = job.Parts.Select(p => JobPartGeometry.Read(p.Geometry)).ToArray();
|
||||
for (var i = 0; i < fill.Parts.Count; i++)
|
||||
for (var j = i + 1; j < fill.Parts.Count; j++)
|
||||
{
|
||||
var a = fill.Parts[i];
|
||||
var b = fill.Parts[j];
|
||||
Assert.True(NestLayoutCheck.Clears(geometries[a.Orientation.TypeIndex],
|
||||
new NestJobPlacement(job.Parts[a.Orientation.TypeIndex].Id, i, a.X, a.Y, a.Orientation.Rotation),
|
||||
geometries[b.Orientation.TypeIndex],
|
||||
new NestJobPlacement(job.Parts[b.Orientation.TypeIndex].Id, j, b.X, b.Y, b.Orientation.Rotation), 0.1));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PairedJobsAreDeterministic()
|
||||
{
|
||||
// Best-fit evaluates in parallel; equal-area pairs must still be chosen the same way.
|
||||
NestJob Build() =>
|
||||
Job(
|
||||
new[]
|
||||
{
|
||||
Part("wedge", Wedge(), 6, RotationPolicy.Automatic),
|
||||
Part("ell", LShape(9, 7, 3), 4, RotationPolicy.Automatic),
|
||||
},
|
||||
new[] { Stock("sheet", 30, 40, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var first = new IrregularNestingEngine().Solve(Build());
|
||||
var second = new IrregularNestingEngine().Solve(Build());
|
||||
|
||||
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Whole-part cutting prerequisites for one captured plate. A cutoff precedes every part its
|
||||
/// nominal span crosses (an orphaned cutoff, whose definition is missing, precedes every part),
|
||||
/// and a part proven to lie inside a cutout of another part precedes that host. Built on the
|
||||
/// caller thread from owned values; ambiguous containment refuses instead of guessing from bounds.
|
||||
/// </summary>
|
||||
internal sealed class CuttingDependencyGraph
|
||||
{
|
||||
private const int ContactBudget = 1000000;
|
||||
private readonly int[][] prerequisites;
|
||||
|
||||
private CuttingDependencyGraph(int[][] prerequisites) => this.prerequisites = prerequisites;
|
||||
|
||||
internal static CuttingDependencyGraph Empty(int count) =>
|
||||
new(Enumerable.Range(0, count).Select(_ => Array.Empty<int>()).ToArray());
|
||||
|
||||
/// <summary>Exact prerequisites, for tests and callers that already proved them.</summary>
|
||||
internal static CuttingDependencyGraph FromPrerequisites(IReadOnlyList<int[]> prerequisites)
|
||||
{
|
||||
var copy = prerequisites.Select(p => p.Distinct().Order().ToArray()).ToArray();
|
||||
if (copy.SelectMany(p => p).Any(i => i < 0 || i >= copy.Length))
|
||||
throw new ArgumentException("Prerequisite ordinal out of range.");
|
||||
return new(copy);
|
||||
}
|
||||
|
||||
internal int Count => prerequisites.Length;
|
||||
|
||||
internal IReadOnlyList<int> PrerequisitesOf(int ordinal) => prerequisites[ordinal];
|
||||
|
||||
internal bool IsReady(int ordinal, IEnumerable<int> done)
|
||||
{
|
||||
if (prerequisites[ordinal].Length == 0)
|
||||
return true;
|
||||
var finished = done as ICollection<int> ?? done.ToHashSet();
|
||||
return prerequisites[ordinal].All(finished.Contains);
|
||||
}
|
||||
|
||||
/// <summary>First (part, missing prerequisite) in a proposed complete order, or null.</summary>
|
||||
internal (int Part, int Prerequisite)? FirstViolation(IReadOnlyList<int> order)
|
||||
{
|
||||
var position = Enumerable.Repeat(-1, prerequisites.Length).ToArray();
|
||||
for (var i = 0; i < order.Count; i++)
|
||||
position[order[i]] = i;
|
||||
foreach (var part in order)
|
||||
foreach (var prerequisite in prerequisites[part])
|
||||
if (position[prerequisite] < 0 || position[prerequisite] > position[part])
|
||||
return (part, prerequisite);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>An ordinal on a prerequisite cycle, or null when the graph is acyclic.</summary>
|
||||
internal int? FindCycle()
|
||||
{
|
||||
var remaining = prerequisites.Select(p => p.Length).ToArray();
|
||||
var dependents = Enumerable.Range(0, prerequisites.Length).Select(_ => new List<int>()).ToArray();
|
||||
for (var part = 0; part < prerequisites.Length; part++)
|
||||
foreach (var prerequisite in prerequisites[part])
|
||||
dependents[prerequisite].Add(part);
|
||||
var ready = new Queue<int>(Enumerable.Range(0, prerequisites.Length).Where(i => remaining[i] == 0));
|
||||
while (ready.Count != 0)
|
||||
foreach (var dependent in dependents[ready.Dequeue()])
|
||||
if (--remaining[dependent] == 0)
|
||||
ready.Enqueue(dependent);
|
||||
var cyclic = Array.FindIndex(remaining, r => r != 0);
|
||||
return cyclic < 0 ? null : cyclic;
|
||||
}
|
||||
|
||||
internal static CuttingDependencyGraph Build(IReadOnlyList<DependencyNode> nodes, Box plate,
|
||||
CancellationToken token)
|
||||
{
|
||||
var edges = nodes.Select(_ => new SortedSet<int>()).ToArray();
|
||||
for (var cut = 0; cut < nodes.Count; cut++)
|
||||
{
|
||||
if (!nodes[cut].IsCutOff)
|
||||
continue;
|
||||
var definition = nodes[cut].CutOff;
|
||||
for (var part = 0; part < nodes.Count; part++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!nodes[part].IsCutOff && (definition == null || CutOffCrosses(definition.Axis,
|
||||
definition.Position, definition.StartLimit, definition.EndLimit, nodes[part].PlacedBounds, plate)))
|
||||
edges[part].Add(cut);
|
||||
}
|
||||
}
|
||||
for (var inner = 0; inner < nodes.Count; inner++)
|
||||
for (var host = 0; host < nodes.Count; host++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Bounds only select candidates; containment itself is proven on native material.
|
||||
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
|
||||
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds)
|
||||
&& InsideCutout(nodes, inner, host, token))
|
||||
edges[host].Add(inner);
|
||||
}
|
||||
var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray());
|
||||
if (graph.FindCycle() is { } cyclic)
|
||||
throw new CuttingDependencyException(CuttingPlanStatus.ConstraintConflict, cyclic, null,
|
||||
$"Part {cyclic + 1} is on a cutting dependency cycle.");
|
||||
return graph;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a cutoff's nominal line crosses a part's bounds within the cutoff's span. Uses the
|
||||
/// nominal line, not its trimmed cutting segments (which deliberately skip the parts). Bounds
|
||||
/// conservatively include edge contacts and concave recesses; limits prevent unrelated
|
||||
/// dependencies beyond the cutoff's span. Negative coordinates need no special case.
|
||||
/// </summary>
|
||||
internal static bool CutOffCrosses(CutOffAxis axis, Vector cutOffPosition, double? startLimit,
|
||||
double? endLimit, Box part, Box plate)
|
||||
{
|
||||
var vertical = axis == CutOffAxis.Vertical;
|
||||
var position = vertical ? cutOffPosition.X : cutOffPosition.Y;
|
||||
var acrossMin = vertical ? part.Left : part.Bottom;
|
||||
var acrossMax = vertical ? part.Right : part.Top;
|
||||
var alongMin = vertical ? part.Bottom : part.Left;
|
||||
var alongMax = vertical ? part.Top : part.Right;
|
||||
var start = startLimit ?? (vertical ? plate.Bottom : plate.Left);
|
||||
var end = endLimit ?? (vertical ? plate.Top : plate.Right);
|
||||
return !(position < acrossMin - Tolerance.Epsilon
|
||||
|| position > acrossMax + Tolerance.Epsilon
|
||||
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|
||||
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
|
||||
}
|
||||
|
||||
// True when the inner part's perimeter lies strictly inside one cutout of the host. Any
|
||||
// boundary contact or crossing between them, or incomplete material, is ambiguous.
|
||||
private static bool InsideCutout(IReadOnlyList<DependencyNode> nodes, int inner, int host,
|
||||
CancellationToken token)
|
||||
{
|
||||
var part = nodes[inner].Material;
|
||||
var enclosing = nodes[host].Material;
|
||||
if (!part.IsComplete || !enclosing.IsComplete)
|
||||
throw Ambiguous(inner, host, part.Reason ?? enclosing.Reason);
|
||||
var budget = ContactBudget;
|
||||
foreach (var curve in part.Rings[0])
|
||||
foreach (var ring in enclosing.Rings)
|
||||
foreach (var boundary in ring)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw Ambiguous(inner, host, "the containment check exceeds the native query limit");
|
||||
if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
|
||||
throw Ambiguous(inner, host, "their boundaries touch or cross");
|
||||
}
|
||||
var probe = part.Rings[0][0].Start;
|
||||
return enclosing.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
|
||||
}
|
||||
|
||||
private static CuttingDependencyException Ambiguous(int inner, int host, string reason) =>
|
||||
new(CuttingPlanStatus.UnsupportedGeometry, inner, host,
|
||||
$"Containment of part {inner + 1} relative to part {host + 1} cannot be established: {reason}.");
|
||||
|
||||
private static bool Within(Box inner, Box outer) =>
|
||||
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
|
||||
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
/// <summary>Owned copy of a cutoff definition's nominal line.</summary>
|
||||
internal sealed record CutOffDefinition(CutOffAxis Axis, Vector Position, double? StartLimit, double? EndLimit);
|
||||
|
||||
/// <summary>Owned per-placement dependency input; material is captured only when a pair needs it.</summary>
|
||||
internal sealed class DependencyNode
|
||||
{
|
||||
private readonly Func<LeadMaterialSnapshot> materialFactory;
|
||||
private LeadMaterialSnapshot material;
|
||||
|
||||
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
|
||||
Func<LeadMaterialSnapshot> materialFactory)
|
||||
{
|
||||
IsCutOff = isCutOff;
|
||||
CutOff = cutOff;
|
||||
PlacedBounds = Copy(placedBounds);
|
||||
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
|
||||
HostBounds = cleanBounds == null ? PlacedBounds : Union(PlacedBounds, CleanBounds);
|
||||
this.materialFactory = materialFactory;
|
||||
}
|
||||
|
||||
internal bool IsCutOff { get; }
|
||||
internal CutOffDefinition CutOff { get; }
|
||||
internal Box PlacedBounds { get; }
|
||||
internal Box CleanBounds { get; }
|
||||
internal Box HostBounds { get; }
|
||||
internal LeadMaterialSnapshot Material => material ??= materialFactory();
|
||||
|
||||
private static Box Copy(Box box) => new(box.X, box.Y, box.Length, box.Width);
|
||||
|
||||
private static Box Union(Box a, Box b)
|
||||
{
|
||||
var left = System.Math.Min(a.Left, b.Left);
|
||||
var bottom = System.Math.Min(a.Bottom, b.Bottom);
|
||||
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A dependency refusal naming the part and, when relevant, the other part.</summary>
|
||||
internal sealed class CuttingDependencyException(CuttingPlanStatus status, int ordinal, int? other, string message)
|
||||
: Exception(message)
|
||||
{
|
||||
internal CuttingPlanStatus Status { get; } = status;
|
||||
internal int Ordinal { get; } = ordinal;
|
||||
internal int? Other { get; } = other;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Proposes a whole-part visiting order as an open travelling-salesman path from the start point
|
||||
/// over part centres: nearest neighbour, then 2-opt and Or-opt improvement, never placing a part
|
||||
/// before one of its prerequisites. It is only a proposal; the forward search still checks every
|
||||
/// rapid and lead along it.
|
||||
/// </summary>
|
||||
internal static class CuttingPartOrder
|
||||
{
|
||||
private const double Epsilon = 1e-9;
|
||||
|
||||
/// <summary>Upper bound on improvement passes, so a large plate cannot loop for long.</summary>
|
||||
internal const int MaxPasses = 50;
|
||||
|
||||
/// <param name="parts">The part ordinals to order; prerequisites outside this set count as done.</param>
|
||||
/// <param name="centres">One representative point per part ordinal.</param>
|
||||
/// <param name="start">The modeled tool position before the first of these parts.</param>
|
||||
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
|
||||
internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
|
||||
{
|
||||
var local = new Dictionary<int, int>();
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
local.Add(parts[i], i);
|
||||
var localCentres = parts.Select(p => centres[p]).ToArray();
|
||||
var localPrerequisites = parts
|
||||
.Select(p => (IReadOnlyCollection<int>)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray())
|
||||
.ToArray();
|
||||
return Plan(localCentres, start, localPrerequisites, token).Select(i => parts[i]).ToArray();
|
||||
}
|
||||
|
||||
/// <param name="centres">One representative point per part ordinal.</param>
|
||||
/// <param name="start">The modeled tool position before the first part.</param>
|
||||
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
|
||||
internal static int[] Plan(IReadOnlyList<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
|
||||
{
|
||||
var count = centres.Count;
|
||||
var order = NearestNeighbour(centres, start, prerequisites, token);
|
||||
var position = new int[count];
|
||||
for (var pass = 0; pass < MaxPasses; pass++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var improved = TwoOpt(order, centres, start, prerequisites, position, token);
|
||||
improved |= OrOpt(order, centres, start, prerequisites, position, token);
|
||||
if (!improved)
|
||||
break;
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
private static int[] NearestNeighbour(IReadOnlyList<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
|
||||
{
|
||||
var count = centres.Count;
|
||||
var placed = new bool[count];
|
||||
var order = new int[count];
|
||||
var current = start;
|
||||
for (var step = 0; step < count; step++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var best = -1;
|
||||
var bestDistance = double.MaxValue;
|
||||
for (var part = 0; part < count; part++)
|
||||
{
|
||||
if (placed[part] || prerequisites[part].Any(p => !placed[p]))
|
||||
continue;
|
||||
var distance = current.DistanceTo(centres[part]);
|
||||
if (distance < bestDistance - Epsilon)
|
||||
{
|
||||
best = part;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
if (best < 0)
|
||||
throw new InvalidOperationException("Part prerequisites form a cycle.");
|
||||
placed[best] = true;
|
||||
order[step] = best;
|
||||
current = centres[best];
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// Reverses order[i..j] when that shortens the open path and keeps every prerequisite earlier.
|
||||
private static bool TwoOpt(int[] order, IReadOnlyList<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
|
||||
{
|
||||
var improved = false;
|
||||
var count = order.Length;
|
||||
for (var i = 0; i < count - 1; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
for (var j = i + 1; j < count; j++)
|
||||
{
|
||||
var before = Point(i - 1).DistanceTo(centres[order[i]])
|
||||
+ (j + 1 < count ? centres[order[j]].DistanceTo(centres[order[j + 1]]) : 0);
|
||||
var after = Point(i - 1).DistanceTo(centres[order[j]])
|
||||
+ (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : 0);
|
||||
if (after >= before - Epsilon || !CanReverse(order, i, j, prerequisites, position))
|
||||
continue;
|
||||
Array.Reverse(order, i, j - i + 1);
|
||||
improved = true;
|
||||
}
|
||||
}
|
||||
return improved;
|
||||
|
||||
Vector Point(int index) => index < 0 ? start : centres[order[index]];
|
||||
}
|
||||
|
||||
// A reversal breaks a prerequisite only when both parts lie inside the reversed span.
|
||||
private static bool CanReverse(int[] order, int i, int j, IReadOnlyList<IReadOnlyCollection<int>> prerequisites,
|
||||
int[] position)
|
||||
{
|
||||
for (var k = 0; k < order.Length; k++)
|
||||
position[order[k]] = k;
|
||||
for (var k = i; k <= j; k++)
|
||||
foreach (var prerequisite in prerequisites[order[k]])
|
||||
if (position[prerequisite] >= i && position[prerequisite] <= j)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Moves a run of one to three parts to a later or earlier gap when that shortens the path.
|
||||
private static bool OrOpt(int[] order, IReadOnlyList<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
|
||||
{
|
||||
var improved = false;
|
||||
var count = order.Length;
|
||||
for (var length = 1; length <= 3; length++)
|
||||
for (var i = 0; i + length <= count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var last = i + length - 1;
|
||||
var removal = Gap(i - 1, i) + Gap(last, last + 1) - Gap(i - 1, last + 1);
|
||||
// Insert between order[gap - 1] and order[gap], outside the run.
|
||||
for (var gap = 0; gap <= count; gap++)
|
||||
{
|
||||
if (gap >= i && gap <= last + 1)
|
||||
continue;
|
||||
var insertion = Gap(gap - 1, i) + Gap(last, gap) - Gap(gap - 1, gap);
|
||||
if (insertion >= removal - Epsilon)
|
||||
continue;
|
||||
var candidate = Move(order, i, length, gap);
|
||||
if (!Valid(candidate, prerequisites, position))
|
||||
continue;
|
||||
Array.Copy(candidate, order, count);
|
||||
improved = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return improved;
|
||||
|
||||
// Path length between order[a] and order[b] (a == -1 is the start; b == count is the open end).
|
||||
double Gap(int a, int b)
|
||||
{
|
||||
if (b >= count || b < 0)
|
||||
return 0;
|
||||
var from = a < 0 ? start : centres[order[a]];
|
||||
return from.DistanceTo(centres[order[b]]);
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] Move(int[] order, int start, int length, int gap)
|
||||
{
|
||||
var run = order.Skip(start).Take(length).ToArray();
|
||||
var rest = order.Take(start).Concat(order.Skip(start + length)).ToList();
|
||||
var insertAt = gap > start ? gap - length : gap;
|
||||
rest.InsertRange(insertAt, run);
|
||||
return rest.ToArray();
|
||||
}
|
||||
|
||||
internal static bool Valid(IReadOnlyList<int> order, IReadOnlyList<IReadOnlyCollection<int>> prerequisites,
|
||||
int[] position)
|
||||
{
|
||||
for (var k = 0; k < order.Count; k++)
|
||||
position[order[k]] = k;
|
||||
for (var k = 0; k < order.Count; k++)
|
||||
foreach (var prerequisite in prerequisites[order[k]])
|
||||
if (position[prerequisite] > k)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>What a batch is doing for one plate, reported from the worker.</summary>
|
||||
public enum CuttingPlanPhase
|
||||
{
|
||||
/// <summary>Searching for a new whole-part order.</summary>
|
||||
Reordering,
|
||||
|
||||
/// <summary>Planning with the plate's current part order.</summary>
|
||||
KeepingOrder,
|
||||
|
||||
/// <summary>Checking the plate's clean part material for overlaps.</summary>
|
||||
CheckingOverlap,
|
||||
}
|
||||
|
||||
/// <summary>Worker progress: zero-based position in the batch, the plate's display number and phase.</summary>
|
||||
public sealed record CuttingPlanProgress(int PlateIndex, int PlateCount, int PlateNumber, CuttingPlanPhase Phase);
|
||||
|
||||
/// <summary>
|
||||
/// One desktop planning attempt over one or more plates with caller-confirmed cutting parameters.
|
||||
/// Capture on the thread that owns the plates, <see cref="Plan"/> on a worker, then apply the
|
||||
/// returned proposal back on the owner thread. Every plate goes through
|
||||
/// <see cref="CuttingPlanService"/>; nothing here installs programs or reorders parts itself.
|
||||
/// </summary>
|
||||
public sealed class CuttingPlanBatch
|
||||
{
|
||||
/// <summary>The smallest budget a plate gets, the service default.</summary>
|
||||
public const int MinimumExpansionBudget = 20000;
|
||||
|
||||
/// <summary>
|
||||
/// Expansions allowed per part, whether or not the order is kept: both still choose contour
|
||||
/// order and entries for every part. Dense grids measured near 260 per part with a new order
|
||||
/// and 210 when kept; the margin keeps 100-150 part plates inside the budget.
|
||||
/// </summary>
|
||||
public const int ExpansionsPerPart = 400;
|
||||
|
||||
private readonly Entry[] entries;
|
||||
private readonly CuttingParameters ownedParameters;
|
||||
|
||||
private CuttingPlanBatch(Entry[] entries, CuttingParameters ownedParameters)
|
||||
{
|
||||
this.entries = entries;
|
||||
this.ownedParameters = ownedParameters;
|
||||
}
|
||||
|
||||
public int PlateCount => entries.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Captures every plate's exact state with an owned copy of <paramref name="confirmedParameters"/>,
|
||||
/// and its clean part material for the overlap check. When the order may change, the
|
||||
/// current-order request is captured too, so a free search that runs out of budget can be
|
||||
/// retried on the worker without reading live plates again.
|
||||
/// </summary>
|
||||
public static CuttingPlanBatch Capture(IReadOnlyList<Plate> plates, CuttingParameters confirmedParameters,
|
||||
bool preservePartOrder, IReadOnlyList<int> plateNumbers = null, CancellationToken token = default) =>
|
||||
Capture(plates, confirmedParameters, preservePartOrder, plateNumbers, null, token);
|
||||
|
||||
/// <param name="reorderBudget">Overrides the new-order budget (tests); null gives <see cref="PlateBudget"/>.</param>
|
||||
internal static CuttingPlanBatch Capture(IReadOnlyList<Plate> plates, CuttingParameters confirmedParameters,
|
||||
bool preservePartOrder, IReadOnlyList<int> plateNumbers, int? reorderBudget, CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
ArgumentNullException.ThrowIfNull(confirmedParameters);
|
||||
if (plates.Count == 0)
|
||||
throw new ArgumentException("At least one plate is required.", nameof(plates));
|
||||
if (plates.Any(plate => plate == null)
|
||||
|| plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
|
||||
throw new ArgumentException("Plates must be distinct and not null.", nameof(plates));
|
||||
if (plateNumbers != null && plateNumbers.Count != plates.Count)
|
||||
throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
|
||||
|
||||
CuttingParameters owned;
|
||||
try
|
||||
{
|
||||
owned = OwnedCuttingParameters.Copy(confirmedParameters);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
owned = null; // Each plate's capture reports the unsupported settings itself.
|
||||
}
|
||||
|
||||
var entries = new Entry[plates.Count];
|
||||
for (var index = 0; index < plates.Count; index++)
|
||||
{
|
||||
var plate = plates[index];
|
||||
var budget = PlateBudget(plate.Parts.Count);
|
||||
var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
expansionBudget: budget, confirmedParameters: confirmedParameters,
|
||||
preservePartOrder: true), token);
|
||||
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
|
||||
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
|
||||
entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap);
|
||||
}
|
||||
return new(entries, owned);
|
||||
}
|
||||
|
||||
internal static int PlateBudget(int partCount) =>
|
||||
(int)System.Math.Min(int.MaxValue,
|
||||
System.Math.Max((long)MinimumExpansionBudget, (long)partCount * ExpansionsPerPart));
|
||||
|
||||
/// <summary>
|
||||
/// Checks and plans every plate from its captured snapshots. Safe on a worker: live plates are
|
||||
/// not read. Overlapping material blocks a plate whatever its route. A free search that ends
|
||||
/// without a complete plan within its budget is retried with the current order; the proposal
|
||||
/// reports that it kept the order.
|
||||
/// </summary>
|
||||
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
var plans = new CuttingPlanPlateResult[entries.Length];
|
||||
for (var index = 0; index < entries.Length; index++)
|
||||
{
|
||||
var entry = entries[index];
|
||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.CheckingOverlap));
|
||||
PlateOverlapReport overlap;
|
||||
try
|
||||
{
|
||||
overlap = PlateOverlapAnalyzer.Analyze(entry.Overlap, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
plans[index] = new(entry.Plate, entry.Number, new(CuttingPlanStatus.Cancelled), null, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
CuttingPlanResult reorder = null;
|
||||
if (entry.Reorder != null)
|
||||
{
|
||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
||||
reorder = CuttingPlanService.Plan(entry.Reorder, token);
|
||||
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
||||
{
|
||||
plans[index] = new(entry.Plate, entry.Number, reorder, null, overlap);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
|
||||
plans[index] = new(entry.Plate, entry.Number, CuttingPlanService.Plan(entry.KeepOrder, token), reorder,
|
||||
overlap);
|
||||
}
|
||||
return new(plans, ownedParameters);
|
||||
}
|
||||
|
||||
private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder,
|
||||
PlateOverlapSnapshot Overlap);
|
||||
}
|
||||
|
||||
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
|
||||
public sealed class CuttingPlanPlateResult
|
||||
{
|
||||
internal CuttingPlanPlateResult(Plate plate, int plateNumber, CuttingPlanResult result,
|
||||
CuttingPlanResult reorderAttempt, PlateOverlapReport overlap)
|
||||
{
|
||||
Plate = plate;
|
||||
PlateNumber = plateNumber;
|
||||
Result = result;
|
||||
ReorderAttempt = reorderAttempt;
|
||||
Overlap = overlap;
|
||||
}
|
||||
|
||||
/// <summary>The live plate. Read it only on the thread that owns it.</summary>
|
||||
public Plate Plate { get; }
|
||||
public int PlateNumber { get; }
|
||||
|
||||
/// <summary>The result that would be applied.</summary>
|
||||
public CuttingPlanResult Result { get; }
|
||||
|
||||
/// <summary>The free-order attempt that ran out of budget before the current order was kept; else null.</summary>
|
||||
public CuttingPlanResult ReorderAttempt { get; }
|
||||
|
||||
/// <summary>The clean-material overlap check (part ids are current part positions); null if cancelled first.</summary>
|
||||
public PlateOverlapReport Overlap { get; }
|
||||
|
||||
public bool KeptCurrentOrder => ReorderAttempt != null;
|
||||
|
||||
/// <summary>The route was replayed and the overlap check completed without overlaps.</summary>
|
||||
public bool IsRouteReady => Result.Status == CuttingPlanStatus.Ready && Result.IndependentlyReplayed;
|
||||
|
||||
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
|
||||
public bool IsReady => IsRouteReady && IsOverlapClear;
|
||||
public int PartCount => Result.ProposedOrder.Count;
|
||||
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
|
||||
|
||||
public bool OrderChanged =>
|
||||
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a batch. Apply is all-or-nothing and is offered only when every plate is ready.
|
||||
/// </summary>
|
||||
public sealed class CuttingPlanProposal
|
||||
{
|
||||
private const int FindingsPerPlate = 5;
|
||||
private readonly CuttingParameters ownedParameters;
|
||||
|
||||
internal CuttingPlanProposal(IReadOnlyList<CuttingPlanPlateResult> plates, CuttingParameters ownedParameters)
|
||||
{
|
||||
Plates = plates;
|
||||
this.ownedParameters = ownedParameters;
|
||||
}
|
||||
|
||||
public IReadOnlyList<CuttingPlanPlateResult> Plates { get; }
|
||||
|
||||
public bool IsCancelled => Plates.Any(p => p.Result.Status == CuttingPlanStatus.Cancelled);
|
||||
|
||||
public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
||||
&& Plates.All(p => p.IsReady);
|
||||
|
||||
/// <summary>
|
||||
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
|
||||
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
|
||||
/// of the confirmed parameters as its cutting settings; any other status changes nothing.
|
||||
/// </summary>
|
||||
public CuttingCommitResult Apply(CancellationToken token = default)
|
||||
{
|
||||
if (!CanApply)
|
||||
return new(CuttingCommitStatus.InvalidInput,
|
||||
"Every plate must have a ready plan before anything is applied.");
|
||||
var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token);
|
||||
if (commit.Status == CuttingCommitStatus.Applied)
|
||||
foreach (var plate in Plates)
|
||||
plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
|
||||
return commit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A detached copy of a ready plate for display, in the proposed order with the proposed
|
||||
/// programs, or null. A refused plate is not previewed: its program graphs may be unsafe to
|
||||
/// copy. Neither is a plate that changed after planning, which would draw replayed programs at
|
||||
/// poses that were never checked. The copy has quantity zero, so drawing quantities stay put.
|
||||
/// Call it on the thread that owns the plates.
|
||||
/// </summary>
|
||||
public Plate BuildPreview(int index)
|
||||
{
|
||||
var planned = Plates[index];
|
||||
if (!planned.IsReady || planned.Result.Snapshot?.PlateState?.IsCurrent() != true)
|
||||
return null;
|
||||
// Unchanged since capture: live poses, plate and programs are the validated, replayed ones.
|
||||
var source = planned.Plate;
|
||||
var preview = new Plate(source.Size)
|
||||
{
|
||||
Quantity = 0,
|
||||
Quadrant = source.Quadrant,
|
||||
PartSpacing = source.PartSpacing,
|
||||
EdgeSpacing = source.EdgeSpacing,
|
||||
};
|
||||
foreach (var proposal in planned.Result.ProposedOrder)
|
||||
{
|
||||
var part = (Part)proposal.SourcePart.Clone();
|
||||
part.RestoreLeadInProgram(proposal.CopyProgram(), proposal.LeadInsLocked);
|
||||
preview.Parts.Add(part);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
/// <summary>Operator-facing summary: one overall line, then each plate and its findings.</summary>
|
||||
public IReadOnlyList<string> Describe(string unit)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var ready = Plates.Count(p => p.IsReady);
|
||||
if (IsCancelled)
|
||||
lines.Add("Planning was cancelled. Nothing has changed.");
|
||||
else if (CanApply)
|
||||
lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
|
||||
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
|
||||
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
|
||||
else
|
||||
lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not "
|
||||
+ "be planned. No plate changes until every plate is ready.");
|
||||
|
||||
foreach (var plate in Plates)
|
||||
{
|
||||
lines.Add(string.Empty);
|
||||
lines.AddRange(DescribePlate(plate, unit));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> DescribePlate(CuttingPlanPlateResult plate, string unit)
|
||||
{
|
||||
var heading = $"Plate {plate.PlateNumber}: ";
|
||||
var result = plate.Result;
|
||||
if (plate.IsReady && plate.PartCount == 0)
|
||||
{
|
||||
yield return heading + "no parts.";
|
||||
yield break;
|
||||
}
|
||||
if (plate.IsReady)
|
||||
{
|
||||
var kept = plate.PartCount - plate.RegeneratedCount;
|
||||
var order = plate.KeptCurrentOrder
|
||||
? "No new part order was found within the search limit, so the current order is kept."
|
||||
: plate.OrderChanged ? "The part order changes." : "The part order is unchanged.";
|
||||
yield return heading + $"ready. {Count(plate.PartCount, "part")}, {plate.RegeneratedCount} "
|
||||
+ $"regenerated, {kept} kept as is; rapid travel "
|
||||
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}";
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return heading + (plate.IsRouteReady
|
||||
? "blocked: parts overlap or could not be checked for overlap."
|
||||
: StatusText(result.Status));
|
||||
foreach (var line in Limit(DescribeOverlap(plate.Overlap)))
|
||||
yield return line;
|
||||
if (plate.IsRouteReady)
|
||||
yield break;
|
||||
if (plate.KeptCurrentOrder)
|
||||
yield return " No new part order was found within the search limit, and planning with the "
|
||||
+ "current order was refused:";
|
||||
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
|
||||
yield return line;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Limit(IEnumerable<string> lines)
|
||||
{
|
||||
var all = lines.ToList();
|
||||
foreach (var line in all.Take(FindingsPerPlate))
|
||||
yield return " - " + line;
|
||||
if (all.Count > FindingsPerPlate)
|
||||
yield return $" ... and {all.Count - FindingsPerPlate} more.";
|
||||
}
|
||||
|
||||
private static IEnumerable<string> DescribeOverlap(PlateOverlapReport overlap)
|
||||
{
|
||||
if (overlap == null)
|
||||
yield break;
|
||||
foreach (var pair in overlap.Pairs)
|
||||
yield return $"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
|
||||
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}.";
|
||||
foreach (var issue in overlap.Issues)
|
||||
yield return $"Overlap check incomplete for part {issue.PartAId + 1}"
|
||||
+ (issue.PartBId is int other ? $" and part {other + 1}" : string.Empty) + $": {issue.Message}";
|
||||
}
|
||||
|
||||
private static string StatusText(CuttingPlanStatus status) => status switch
|
||||
{
|
||||
CuttingPlanStatus.Ready => "ready, but the plan was not independently replayed.",
|
||||
CuttingPlanStatus.ConstraintConflict => "blocked: the fixed programs or required cut order conflict.",
|
||||
CuttingPlanStatus.UnsupportedGeometry => "blocked: unsupported geometry or an incomplete check.",
|
||||
CuttingPlanStatus.InvalidInput => "blocked: invalid input.",
|
||||
CuttingPlanStatus.NoSolutionWithinBudget => "no complete plan was found within the search limit.",
|
||||
CuttingPlanStatus.Cancelled => "cancelled.",
|
||||
_ => status.ToString(),
|
||||
};
|
||||
|
||||
private static string DescribeFinding(CuttingPlanFinding finding)
|
||||
{
|
||||
var text = finding.SourceOrdinal is int ordinal
|
||||
? $"Part {ordinal + 1}{Name(finding.SourcePart)}: {finding.Message}"
|
||||
: finding.Message;
|
||||
if (finding.OtherSourceOrdinal is int other)
|
||||
text += $" (with part {other + 1}{Name(finding.OtherSourcePart)})";
|
||||
return text;
|
||||
}
|
||||
|
||||
private static string Name(Part part) => Name(part?.BaseDrawing?.Name);
|
||||
|
||||
private static string Name(string name) => string.IsNullOrWhiteSpace(name) ? string.Empty : $" ({name})";
|
||||
|
||||
private static string Count(int count, string noun) => $"{count} {noun}{(count == 1 ? string.Empty : "s")}";
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Caller-side direct-XY input. Regeneration requires explicit confirmed parameters.
|
||||
/// Keep sources/settings stable during Capture. Only plate-scoped requests (<see cref="ForPlate"/>)
|
||||
/// record the state that CuttingPlanService.Apply checks; a detached part list cannot be applied.
|
||||
/// </summary>
|
||||
public sealed class CuttingPlanRequest
|
||||
{
|
||||
public CuttingPlanRequest(IEnumerable<Part> parts, Vector startPoint = default, int expansionBudget = 20000,
|
||||
CuttingParameters confirmedParameters = null, IEnumerable<Part> eligibleParts = null,
|
||||
bool preservePartOrder = false, int maxEntries = 16)
|
||||
{
|
||||
Parts = parts == null ? null : Array.AsReadOnly(parts.ToArray());
|
||||
StartPoint = startPoint;
|
||||
ExpansionBudget = expansionBudget;
|
||||
ConfirmedParameters = confirmedParameters;
|
||||
EligibleParts = eligibleParts == null ? null : Array.AsReadOnly(eligibleParts.ToArray());
|
||||
PreservePartOrder = preservePartOrder;
|
||||
MaxEntries = maxEntries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plate scope: plans the plate's current parts and captures its exact state, so a Ready
|
||||
/// result can later be applied through CuttingPlanService.Apply. A factory rather than a
|
||||
/// constructor overload, so <c>new CuttingPlanRequest(null)</c> stays unambiguous.
|
||||
/// </summary>
|
||||
public static CuttingPlanRequest ForPlate(Plate plate, Vector startPoint = default, int expansionBudget = 20000,
|
||||
CuttingParameters confirmedParameters = null, IEnumerable<Part> eligibleParts = null,
|
||||
bool preservePartOrder = false, int maxEntries = 16) =>
|
||||
new(plate?.Parts, startPoint, expansionBudget, confirmedParameters, eligibleParts,
|
||||
preservePartOrder, maxEntries)
|
||||
{ Plate = plate };
|
||||
|
||||
/// <summary>The plate scope, or null for a detached part list that cannot be applied.</summary>
|
||||
public Plate Plate { get; private init; }
|
||||
public CuttingParameters ConfirmedParameters { get; }
|
||||
public IReadOnlyList<Part> EligibleParts { get; }
|
||||
public bool PreservePartOrder { get; }
|
||||
public int MaxEntries { get; }
|
||||
internal Action<int> ExpansionObserver { get; init; }
|
||||
public IReadOnlyList<Part> Parts { get; }
|
||||
public Vector StartPoint { get; }
|
||||
public int ExpansionBudget { get; }
|
||||
}
|
||||
|
||||
/// <summary>Owned worker input. SourcePart references are identity handles only, never worker data.</summary>
|
||||
public sealed class CuttingPlanSnapshot
|
||||
{
|
||||
internal CuttingPlanSnapshot(IEnumerable<FixedProgramPlacement> placements, Vector startPoint,
|
||||
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
|
||||
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
|
||||
Action<int> expansionObserver = null, PlateCuttingState plateState = null,
|
||||
CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null)
|
||||
{
|
||||
PlateState = plateState;
|
||||
OwnedParameters = ownedParameters;
|
||||
Placements = Array.AsReadOnly(placements.ToArray());
|
||||
Dependencies = dependencies ?? CuttingDependencyGraph.Empty(Placements.Count);
|
||||
StartPoint = startPoint;
|
||||
ExpansionBudget = expansionBudget;
|
||||
Failure = failure;
|
||||
Findings = Array.AsReadOnly((findings ?? []).ToArray());
|
||||
Regeneration = regeneration;
|
||||
PreservePartOrder = preservePartOrder;
|
||||
MaxEntries = maxEntries;
|
||||
ExpansionObserver = expansionObserver;
|
||||
}
|
||||
|
||||
/// <summary>Whole-part prerequisites by source ordinal (cutoffs, inner parts before hosts).</summary>
|
||||
internal CuttingDependencyGraph Dependencies { get; }
|
||||
/// <summary>Exact captured plate state for plate-scoped requests; null for detached part lists.</summary>
|
||||
internal PlateCuttingState PlateState { get; }
|
||||
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
|
||||
internal CuttingParameters OwnedParameters { get; }
|
||||
internal bool Regeneration { get; }
|
||||
internal bool PreservePartOrder { get; }
|
||||
internal int MaxEntries { get; }
|
||||
internal Action<int> ExpansionObserver { get; }
|
||||
public IReadOnlyList<FixedProgramPlacement> Placements { get; }
|
||||
public Vector StartPoint { get; }
|
||||
public int ExpansionBudget { get; }
|
||||
internal CuttingPlanStatus? Failure { get; }
|
||||
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
||||
}
|
||||
|
||||
public sealed class FixedProgramPlacement
|
||||
{
|
||||
internal FixedProgramPlacement(Part sourcePart, int sourceOrdinal, Vector location,
|
||||
double rotation, bool leadInsLocked, OwnedExecution execution, Program program = null,
|
||||
PreparedContours prepared = null, LeadMaterialSnapshot material = null,
|
||||
IReadOnlyList<ContourChoice> choices = null)
|
||||
{
|
||||
SourcePart = sourcePart;
|
||||
SourceOrdinal = sourceOrdinal;
|
||||
Location = location;
|
||||
Rotation = rotation;
|
||||
LeadInsLocked = leadInsLocked;
|
||||
Execution = execution;
|
||||
this.program = program;
|
||||
Prepared = prepared;
|
||||
Material = material;
|
||||
ContourChoices = Array.AsReadOnly((choices ?? []).ToArray());
|
||||
}
|
||||
|
||||
private readonly Program program;
|
||||
internal PreparedContours Prepared { get; }
|
||||
internal LeadMaterialSnapshot Material { get; }
|
||||
public IReadOnlyList<ContourChoice> ContourChoices { get; }
|
||||
public bool IsRegenerated => ContourChoices.Count != 0;
|
||||
/// <summary>Returns an independent deep copy; never an alias to captured/proposed code.</summary>
|
||||
public Program CopyProgram() => program == null ? null : OwnedProgramCopy.Copy(program);
|
||||
internal SelectedContourProgram SelectedProgram { get; private init; }
|
||||
|
||||
internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList<ContourChoice> choices,
|
||||
System.Threading.CancellationToken token = default)
|
||||
{
|
||||
SelectedContourProgram selected = null;
|
||||
try
|
||||
{
|
||||
// The expected program is constructed from owned choices/settings, NEVER
|
||||
// from proposed or its cached execution. Counterfeit payloads remain subject
|
||||
// to independent final checks, including callers of this internal test seam.
|
||||
selected = Prepared?.CaptureSelectedProgram(choices, Location, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException or ArithmeticException)
|
||||
{
|
||||
// Invalid metadata is a refused proposal, not an exception from final replay.
|
||||
}
|
||||
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
|
||||
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
|
||||
{ SelectedProgram = selected, IsCutOff = IsCutOff };
|
||||
}
|
||||
|
||||
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
|
||||
public bool IsCutOff { get; internal init; }
|
||||
|
||||
public Part SourcePart { get; }
|
||||
public int SourceOrdinal { get; }
|
||||
public Vector Location { get; }
|
||||
public double Rotation { get; }
|
||||
public bool LeadInsLocked { get; }
|
||||
public OwnedExecution Execution { get; }
|
||||
}
|
||||
|
||||
public enum CuttingPlanStatus
|
||||
{
|
||||
Ready,
|
||||
ConstraintConflict,
|
||||
UnsupportedGeometry,
|
||||
InvalidInput,
|
||||
NoSolutionWithinBudget,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
|
||||
public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
|
||||
int? OtherSourceOrdinal, Part OtherSourcePart, PostVerificationKind? Kind, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
|
||||
/// captured cutoff/containment prerequisites. Not physical safety or posting consent. Apply
|
||||
/// installs it only for a plate-scoped request whose plate is unchanged. Failures contain no proposals.
|
||||
/// </summary>
|
||||
public sealed class CuttingPlanResult
|
||||
{
|
||||
internal CuttingPlanResult(CuttingPlanStatus status, IEnumerable<FixedProgramPlacement> order = null,
|
||||
IEnumerable<CuttingPlanFinding> findings = null, int expansions = 0,
|
||||
double rapidDistance = 0, bool independentlyReplayed = false)
|
||||
{
|
||||
Status = status;
|
||||
ProposedOrder = Array.AsReadOnly((order ?? []).ToArray());
|
||||
Findings = Array.AsReadOnly((findings ?? []).ToArray());
|
||||
Expansions = expansions;
|
||||
RapidDistance = rapidDistance;
|
||||
IndependentlyReplayed = independentlyReplayed;
|
||||
}
|
||||
|
||||
public CuttingPlanStatus Status { get; }
|
||||
public IReadOnlyList<FixedProgramPlacement> ProposedOrder { get; }
|
||||
public IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
||||
public int Expansions { get; }
|
||||
public double RapidDistance { get; }
|
||||
public bool IndependentlyReplayed { get; }
|
||||
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
|
||||
internal CuttingPlanSnapshot Snapshot { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
public static class CuttingPlanService
|
||||
{
|
||||
/// <summary>
|
||||
/// Read stable caller-owned sources once into privately owned programs and geometry.
|
||||
/// No private Plates, settings aliases, quantity updates or source subcall rebinding.
|
||||
/// </summary>
|
||||
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default)
|
||||
{
|
||||
var placements = new List<FixedProgramPlacement>();
|
||||
Part source = null;
|
||||
int? ordinal = null;
|
||||
try
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PlateCuttingState plateState = null;
|
||||
if (request?.Plate != null)
|
||||
{
|
||||
// Exact freshness record first, on the caller thread; the planned list must be it.
|
||||
plateState = PlateCuttingState.Capture(request.Plate, token);
|
||||
if (request.Parts == null || !request.Parts.SequenceEqual(plateState.Order, ReferenceEqualityComparer.Instance))
|
||||
throw new ArgumentException("The plate's parts changed after the request was created.");
|
||||
if (request.Parts.Count == 0 && request.ExpansionBudget > 0 && request.MaxEntries > 0)
|
||||
return new([], request.StartPoint, request.ExpansionBudget, plateState: plateState);
|
||||
}
|
||||
if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0 || request.MaxEntries <= 0)
|
||||
throw new ArgumentException("A nonempty source list and positive expansion budget are required.");
|
||||
var eligible = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
||||
if (request.EligibleParts != null)
|
||||
{
|
||||
if (request.ConfirmedParameters == null)
|
||||
throw new ArgumentException("Eligibility requires confirmed cutting parameters.");
|
||||
foreach (var part in request.EligibleParts)
|
||||
if (part == null || !request.Parts.Any(p => ReferenceEquals(p, part)) || !eligible.Add(part))
|
||||
throw new ArgumentException("Foreign or duplicate eligible placement.");
|
||||
}
|
||||
// Cutoff definitions by drawing reference, as automatic sequencing matches them.
|
||||
var definitions = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var cutOff in request.Plate?.CutOffs ?? [])
|
||||
if (cutOff?.Drawing != null)
|
||||
definitions[cutOff.Drawing] = cutOff;
|
||||
var nodes = new List<DependencyNode>();
|
||||
// Reuse the reader's coordinate validation without publishing its native kernel.
|
||||
var identities = new HashSet<Part>(ReferenceEqualityComparer.Instance);
|
||||
for (var index = 0; index < request.Parts.Count; index++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
source = request.Parts[index];
|
||||
ordinal = index;
|
||||
if (source?.BaseDrawing == null || source.Program == null || !double.IsFinite(source.Rotation)
|
||||
|| !identities.Add(source))
|
||||
throw new ArgumentException("Missing/duplicate source placement or invalid pose.");
|
||||
// Validate both original graphs before Clone or any virtual transform can
|
||||
// erase unsupported runtime semantics, including fixed/ineligible targets.
|
||||
OwnedProgramCopy.Validate(source.BaseDrawing.Program, token);
|
||||
OwnedProgramCopy.Validate(source.Program, token);
|
||||
if (source.BaseDrawing.IsCutOff)
|
||||
{
|
||||
// A cutoff is a fixed open cut: never material, never regenerated.
|
||||
if (request.Plate == null)
|
||||
throw new NotSupportedException("Cutoff dependencies require a plate-scoped request.");
|
||||
if (eligible.Contains(source))
|
||||
throw new ArgumentException("A cutoff cannot be eligible for regeneration.");
|
||||
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
|
||||
if (!cut.HasCuttingContour)
|
||||
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
|
||||
nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition)
|
||||
? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
|
||||
source.BoundingBox, null, null));
|
||||
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
|
||||
cut, OwnedProgramCopy.Copy(source.Program, token))
|
||||
{ IsCutOff = true });
|
||||
continue;
|
||||
}
|
||||
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
|
||||
if (!clean.HasCuttingContour)
|
||||
throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice.");
|
||||
var execution = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
|
||||
if (!execution.HasCuttingContour)
|
||||
throw new ArgumentException("Placed program has no nonzero cutting contour motions.");
|
||||
var ownedProgram = OwnedProgramCopy.Copy(source.Program, token);
|
||||
PreparedContours prepared = null;
|
||||
LeadMaterialSnapshot material = null;
|
||||
// Geometry-only transform: original graphs were strictly validated and
|
||||
// clone expansion bounded above. Legacy Rotate visits per parent, so
|
||||
// retain Clone's per-parent sharing here instead of restoring diamonds.
|
||||
// Exact placed/proposed payloads still use lossless OwnedProgramCopy.
|
||||
var ownedClean = (Program)source.BaseDrawing.Program.Clone();
|
||||
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
|
||||
// Material extent only: remote rapids and scribe marks must not widen it, or a
|
||||
// genuine insert could fail the containment prefilter.
|
||||
var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions
|
||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
|
||||
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
|
||||
var location = source.Location;
|
||||
if (request.ConfirmedParameters != null)
|
||||
{
|
||||
material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
|
||||
if (!material.IsComplete)
|
||||
throw new NotSupportedException(material.Reason);
|
||||
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
|
||||
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
|
||||
}
|
||||
var captured = material;
|
||||
// Fixed-route material is captured only for a pair whose bounds admit containment.
|
||||
nodes.Add(new(false, null, source.BoundingBox, cleanBounds,
|
||||
() => captured ?? LeadMaterialSnapshot.Capture(ownedClean, location, token)));
|
||||
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
|
||||
execution, ownedProgram, prepared, material));
|
||||
}
|
||||
// The start can be invalid even when the first rapid has no cutting geometry.
|
||||
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
|
||||
source = null;
|
||||
ordinal = null;
|
||||
var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token);
|
||||
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
|
||||
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
|
||||
expansionObserver: request.ExpansionObserver, plateState: plateState,
|
||||
ownedParameters: request.ConfirmedParameters == null ? null
|
||||
: OwnedCuttingParameters.Copy(request.ConfirmedParameters),
|
||||
dependencies: dependencies);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Failure(CuttingPlanStatus.Cancelled, "Capture cancelled.");
|
||||
}
|
||||
catch (CuttingDependencyException exception)
|
||||
{
|
||||
var other = exception.Other is { } index ? request.Parts[index] : null;
|
||||
return new(placements, request.StartPoint, request.ExpansionBudget, exception.Status,
|
||||
[new(exception.Ordinal, request.Parts[exception.Ordinal], exception.Other, other, null, exception.Message)]);
|
||||
}
|
||||
catch (NotSupportedException exception)
|
||||
{
|
||||
return Failure(CuttingPlanStatus.UnsupportedGeometry, exception.Message);
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or ArithmeticException)
|
||||
{
|
||||
return Failure(CuttingPlanStatus.InvalidInput, exception.Message);
|
||||
}
|
||||
|
||||
CuttingPlanSnapshot Failure(CuttingPlanStatus status, string message) =>
|
||||
new(placements, request?.StartPoint ?? Vector.Zero, request?.ExpansionBudget ?? 0, status,
|
||||
[new(ordinal, source, null, null, null, message)]);
|
||||
}
|
||||
|
||||
public static CuttingPlanResult Plan(CuttingPlanRequest request, CancellationToken token = default) =>
|
||||
Plan(Capture(request, token), token);
|
||||
|
||||
/// <summary>Worker-only planning uses owned values; live identities are never dereferenced.</summary>
|
||||
public static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token = default)
|
||||
{
|
||||
var result = PlanCaptured(snapshot, token);
|
||||
result.Snapshot = snapshot;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs exactly the replayed proposals of Ready plate-scoped results, all or nothing.
|
||||
/// Each plate must still match the state captured with its request; otherwise Stale and
|
||||
/// nothing changes. Run on the thread that owns the plates. Never replans.
|
||||
/// </summary>
|
||||
public static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token = default) =>
|
||||
Apply(results, token, null);
|
||||
|
||||
// beforeInstall is the commit's install-boundary test seam.
|
||||
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
|
||||
Action<Plate, Part> beforeInstall)
|
||||
{
|
||||
var plans = new List<PlateCuttingPlan>();
|
||||
foreach (var result in results ?? [])
|
||||
{
|
||||
var snapshot = result?.Snapshot;
|
||||
if (result?.Status != CuttingPlanStatus.Ready || !result.IndependentlyReplayed
|
||||
|| snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
|
||||
return new(CuttingCommitStatus.InvalidInput,
|
||||
"Only Ready, independently replayed plate-scoped proposals can be applied.");
|
||||
var programs = new List<PlannedPartProgram>();
|
||||
foreach (var proposal in result.ProposedOrder)
|
||||
{
|
||||
if (!proposal.IsRegenerated)
|
||||
continue;
|
||||
if (snapshot.OwnedParameters == null)
|
||||
return new(CuttingCommitStatus.InvalidInput, "A regenerated proposal has no captured settings.",
|
||||
snapshot.PlateState.Plate);
|
||||
// Fresh owned copies: a result can be applied at most once per captured state,
|
||||
// and nothing installed aliases the proposal or another part's settings.
|
||||
programs.Add(new(proposal.SourcePart, proposal.CopyProgram(),
|
||||
OwnedCuttingParameters.Copy(snapshot.OwnedParameters)));
|
||||
}
|
||||
plans.Add(new(snapshot.PlateState, result.ProposedOrder.Select(p => p.SourcePart), programs));
|
||||
}
|
||||
return CuttingPlanCommit.Apply(plans, token, beforeInstall);
|
||||
}
|
||||
|
||||
private static CuttingPlanResult PlanCaptured(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
return new(CuttingPlanStatus.Cancelled);
|
||||
if (snapshot == null)
|
||||
return new(CuttingPlanStatus.InvalidInput);
|
||||
if (snapshot.Failure is { } failure)
|
||||
return new(failure, findings: snapshot.Findings);
|
||||
if (snapshot.Placements.Count == 0)
|
||||
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
|
||||
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
|
||||
if (snapshot.PreservePartOrder
|
||||
&& snapshot.Dependencies.FirstViolation(Enumerable.Range(0, snapshot.Placements.Count).ToArray()) is { } manual)
|
||||
return new(CuttingPlanStatus.ConstraintConflict,
|
||||
findings: [DependencyFinding(snapshot, manual, "The preserved part order")]);
|
||||
try
|
||||
{
|
||||
var fixedFindings = new List<CuttingPlanFinding>();
|
||||
foreach (var placement in snapshot.Placements)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (placement.Prepared != null)
|
||||
continue; // An eligible old crossing is precisely what regeneration may repair.
|
||||
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
|
||||
var findings = new ReleasedContourState().Check(placement.Execution, null,
|
||||
placement.SourceOrdinal + 1, placement.IsCutOff, token);
|
||||
fixedFindings.AddRange(Map(snapshot, findings));
|
||||
}
|
||||
if (fixedFindings.Count != 0)
|
||||
return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
|
||||
findings: fixedFindings);
|
||||
|
||||
if (snapshot.Regeneration)
|
||||
{
|
||||
var joint = JointCuttingPlanSearch.Run(snapshot, token);
|
||||
if (joint.Status != CuttingPlanStatus.Ready)
|
||||
return new(joint.Status, findings: joint.Findings, expansions: joint.Expansions);
|
||||
return ReplayPrograms(snapshot, joint.Order, joint.Expansions, token);
|
||||
}
|
||||
var search = FixedProgramSearch.Run(snapshot, token);
|
||||
if (search.Status != CuttingPlanStatus.Ready)
|
||||
return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions);
|
||||
return Replay(snapshot, search.Order, search.Expansions, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(CuttingPlanStatus.Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh checker and full sequence replay: no cached branch state is accepted as evidence.
|
||||
internal static CuttingPlanResult Replay(CuttingPlanSnapshot snapshot, IReadOnlyList<int> order,
|
||||
int expansions, CancellationToken token)
|
||||
{
|
||||
if (order.Count != snapshot.Placements.Count || order.Distinct().Count() != order.Count
|
||||
|| order.Any(index => index < 0 || index >= snapshot.Placements.Count))
|
||||
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
||||
// Dependencies are rechecked from the captured graph, not trusted from the search.
|
||||
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
|
||||
return new(CuttingPlanStatus.InvalidInput,
|
||||
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
||||
var checker = new ReleasedContourState();
|
||||
var position = snapshot.StartPoint;
|
||||
var distance = 0.0;
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
foreach (var index in order)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var placement = snapshot.Placements[index];
|
||||
findings.AddRange(checker.Check(placement.Execution, position, placement.SourceOrdinal + 1,
|
||||
placement.IsCutOff, token));
|
||||
distance += placement.Execution.RapidDistanceFrom(position);
|
||||
position = placement.Execution.DeparturePoint;
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (findings.Count != 0)
|
||||
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
|
||||
findings: Map(snapshot, findings), expansions: expansions);
|
||||
return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]),
|
||||
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
|
||||
}
|
||||
|
||||
// Re-read EXACT selected programs with a fresh checker and native lead validation.
|
||||
// No emission/regeneration or cached branch verdict is used here.
|
||||
internal static CuttingPlanResult ReplayPrograms(CuttingPlanSnapshot snapshot,
|
||||
IReadOnlyList<FixedProgramPlacement> order, int expansions, CancellationToken token)
|
||||
{
|
||||
if (order == null || order.Count != snapshot.Placements.Count
|
||||
|| order.Any(p => p == null || p.SourceOrdinal < 0 || p.SourceOrdinal >= snapshot.Placements.Count)
|
||||
|| order.Select(p => p.SourceOrdinal).Distinct().Count() != order.Count)
|
||||
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
||||
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
|
||||
return new(CuttingPlanStatus.InvalidInput,
|
||||
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
||||
var checker = new ReleasedContourState();
|
||||
var position = snapshot.StartPoint;
|
||||
var distance = 0.0;
|
||||
var findings = new List<CuttingPlanFinding>();
|
||||
var materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
|
||||
foreach (var proposal in order)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var source = snapshot.Placements[proposal.SourceOrdinal];
|
||||
if (!ReferenceEquals(source.SourcePart, proposal.SourcePart)
|
||||
|| !SameBits(source.Location.X, proposal.Location.X) || !SameBits(source.Location.Y, proposal.Location.Y)
|
||||
|| !SameBits(source.Rotation, proposal.Rotation) || source.LeadInsLocked != proposal.LeadInsLocked
|
||||
|| !ReferenceEquals(source.Prepared, proposal.Prepared) || !ReferenceEquals(source.Material, proposal.Material)
|
||||
|| source.IsCutOff != proposal.IsCutOff
|
||||
|| snapshot.PreservePartOrder && proposal.SourceOrdinal != order.TakeWhile(p => !ReferenceEquals(p, proposal)).Count())
|
||||
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
||||
if (source.Prepared == null)
|
||||
{
|
||||
if (!ReferenceEquals(source, proposal))
|
||||
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
||||
}
|
||||
else if (proposal.SelectedProgram == null
|
||||
|| !proposal.SelectedProgram.Matches(source.Prepared, proposal.ContourChoices))
|
||||
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
||||
OwnedExecution execution;
|
||||
bool complete;
|
||||
try
|
||||
{
|
||||
execution = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, position, token);
|
||||
// A cutoff is an open cut with no material; it is retained exactly (checked above).
|
||||
complete = source.IsCutOff
|
||||
|| ContourProgramVerifier.Verify(execution, source.Material, proposal.SelectedProgram, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
return new(CuttingPlanStatus.UnsupportedGeometry,
|
||||
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
|
||||
}
|
||||
findings.AddRange(JointCuttingPlanSearch.Map(snapshot,
|
||||
checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, token)));
|
||||
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
|
||||
: LeadPathValidator.Check(execution, source.Material, materials, token);
|
||||
if (!lead.IsComplete || !lead.IsClear)
|
||||
findings.Add(JointCuttingPlanSearch.Finding(source,
|
||||
lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
|
||||
if (!complete && findings.Count == 0)
|
||||
return new(CuttingPlanStatus.InvalidInput,
|
||||
findings: [JointCuttingPlanSearch.Finding(source, null,
|
||||
"Selected program does not preserve complete directed contours, certified tabs or selected entry/style geometry.")],
|
||||
expansions: expansions);
|
||||
distance += execution.RapidDistanceFrom(position);
|
||||
position = execution.DeparturePoint;
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (findings.Count != 0)
|
||||
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
|
||||
findings: findings, expansions: expansions);
|
||||
return new(CuttingPlanStatus.Ready, order, expansions: expansions,
|
||||
rapidDistance: distance, independentlyReplayed: true);
|
||||
}
|
||||
|
||||
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
|
||||
(int Part, int Prerequisite) violation, string subject)
|
||||
{
|
||||
var part = snapshot.Placements[violation.Part];
|
||||
var prerequisite = snapshot.Placements[violation.Prerequisite];
|
||||
var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
|
||||
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
|
||||
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
|
||||
}
|
||||
|
||||
private static bool SameBits(double source, double proposed) =>
|
||||
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
|
||||
|
||||
private static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot,
|
||||
IEnumerable<PostVerificationFinding> findings) => findings.Select(finding =>
|
||||
{
|
||||
var source = finding.PartNumber is { } part ? snapshot.Placements[part - 1] : null;
|
||||
var other = finding.OtherPartNumber is { } otherPart ? snapshot.Placements[otherPart - 1] : null;
|
||||
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
|
||||
other?.SourceOrdinal, other?.SourcePart, finding.Kind, finding.Message);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>Bounded forward depth-first search, nearest feasible branch then source ordinal.</summary>
|
||||
internal static class FixedProgramSearch
|
||||
{
|
||||
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<int> Order,
|
||||
IReadOnlyList<PostVerificationFinding> Findings, int Expansions);
|
||||
|
||||
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
var stack = new Stack<Frame>();
|
||||
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
|
||||
var rejected = new HashSet<PostVerificationFinding>();
|
||||
var expansions = 0;
|
||||
while (stack.Count != 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var frame = stack.Peek();
|
||||
if (frame.Order.Length == snapshot.Placements.Count)
|
||||
return new(CuttingPlanStatus.Ready, frame.Order, [], expansions);
|
||||
if (frame.Next == frame.Candidates.Length)
|
||||
{
|
||||
stack.Pop();
|
||||
continue;
|
||||
}
|
||||
if (expansions == snapshot.ExpansionBudget)
|
||||
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.ToArray(), expansions);
|
||||
var candidate = frame.Candidates[frame.Next++];
|
||||
expansions++;
|
||||
var placement = snapshot.Placements[candidate];
|
||||
var checker = frame.Checker.Copy();
|
||||
var findings = checker.Check(placement.Execution, frame.Position, placement.SourceOrdinal + 1,
|
||||
placement.IsCutOff, token);
|
||||
if (findings.Count != 0)
|
||||
{
|
||||
rejected.UnionWith(findings);
|
||||
continue;
|
||||
}
|
||||
stack.Push(Create([.. frame.Order, candidate], placement.Execution.DeparturePoint, checker));
|
||||
}
|
||||
return new(CuttingPlanStatus.ConstraintConflict, [], rejected.ToArray(), expansions);
|
||||
|
||||
Frame Create(int[] order, Vector position, ReleasedContourState checker) => new(order, position, checker,
|
||||
Enumerable.Range(0, snapshot.Placements.Count).Where(index => !order.Contains(index)
|
||||
&& snapshot.Dependencies.IsReady(index, order)
|
||||
&& (!snapshot.PreservePartOrder || index == order.Length))
|
||||
.OrderBy(index => snapshot.Placements[index].Execution.RapidDistanceFrom(position))
|
||||
.ThenBy(index => snapshot.Placements[index].SourceOrdinal).ToArray());
|
||||
}
|
||||
|
||||
private sealed class Frame(int[] order, Vector position, ReleasedContourState checker, int[] candidates)
|
||||
{
|
||||
internal int[] Order { get; } = order;
|
||||
internal Vector Position { get; } = position;
|
||||
internal ReleasedContourState Checker { get; } = checker;
|
||||
internal int[] Candidates { get; } = candidates;
|
||||
internal int Next { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// Plans whole parts and their emitted contour prefixes along a part order. A preserved order is
|
||||
/// followed with full backtracking. Otherwise the order comes from <see cref="CuttingPartOrder"/>;
|
||||
/// when a part on it cannot be reached without crossing parts already cut, the search learns
|
||||
/// "cut this part before those", keeps the parts cut before them and re-plans the rest. Once
|
||||
/// nothing new can be learned, the remaining budget goes to a full search over every ready part.
|
||||
/// </summary>
|
||||
internal static class JointCuttingPlanSearch
|
||||
{
|
||||
/// <summary>
|
||||
/// Expansions per entry and contour that a reordering attempt may spend without getting further
|
||||
/// along its order before it gives up and learns from the part that blocked it.
|
||||
/// </summary>
|
||||
internal const int StallExpansionsPerEntry = 8;
|
||||
|
||||
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
|
||||
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions);
|
||||
|
||||
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
var walk = new Walk(snapshot, token);
|
||||
var count = snapshot.Placements.Count;
|
||||
try
|
||||
{
|
||||
if (snapshot.PreservePartOrder)
|
||||
{
|
||||
var kept = walk.Follow(Enumerable.Range(0, count).ToArray(), null, null);
|
||||
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
|
||||
}
|
||||
|
||||
var centres = snapshot.Placements.Select(Centre).ToArray();
|
||||
var prerequisites = Enumerable.Range(0, count)
|
||||
.Select(i => new HashSet<int>(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
|
||||
var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
|
||||
var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
|
||||
var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres,
|
||||
snapshot.StartPoint, prerequisites, token);
|
||||
Node resume = null;
|
||||
while (true)
|
||||
{
|
||||
var attempt = walk.Follow(sequence, stall, resume);
|
||||
if (attempt.Order != null)
|
||||
return walk.Ready(attempt.Order);
|
||||
if (!Learn(attempt, prerequisites))
|
||||
{
|
||||
// "Cut before" rules are a heuristic: a part blocked straight after another can
|
||||
// still be reachable via a third. Search every order with what budget remains.
|
||||
var full = walk.Follow(null, null, null);
|
||||
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
|
||||
}
|
||||
// Back up to just before the earliest part the blocked approach crossed, keep the
|
||||
// parts cut before it, and re-plan the rest from where the tool is at that point.
|
||||
var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part));
|
||||
resume = attempt.BoundaryAt(back);
|
||||
sequence = [.. sequence.Take(back), .. CuttingPartOrder.Plan(sequence[back..], centres,
|
||||
resume.Position, prerequisites, token)];
|
||||
}
|
||||
}
|
||||
catch (BudgetExceededException)
|
||||
{
|
||||
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions);
|
||||
}
|
||||
}
|
||||
|
||||
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
|
||||
// would contradict an order already required. False when nothing new was learned.
|
||||
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
|
||||
{
|
||||
if (attempt.Blocked is not int blocked)
|
||||
return false;
|
||||
var learned = false;
|
||||
foreach (var crossed in attempt.Crossed.Order())
|
||||
{
|
||||
if (crossed == blocked || prerequisites[crossed].Contains(blocked) || Precedes(crossed, blocked, prerequisites))
|
||||
continue;
|
||||
prerequisites[crossed].Add(blocked);
|
||||
learned = true;
|
||||
}
|
||||
return learned;
|
||||
}
|
||||
|
||||
// True when 'first' must already come before 'second' through the prerequisite chain.
|
||||
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
var pending = new Stack<int>(prerequisites[second]);
|
||||
while (pending.Count != 0)
|
||||
{
|
||||
var part = pending.Pop();
|
||||
if (part == first)
|
||||
return true;
|
||||
if (seen.Add(part))
|
||||
foreach (var prerequisite in prerequisites[part])
|
||||
pending.Push(prerequisite);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The centre of the part's placed cut material, for ordering only.
|
||||
private static Vector Centre(FixedProgramPlacement placement)
|
||||
{
|
||||
var cuts = placement.Execution.Motions
|
||||
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
|
||||
.Select(m => m.Curve.ToEntity().BoundingBox).ToList();
|
||||
return cuts.Count == 0 ? placement.Execution.DeparturePoint : cuts.GetBoundingBox().Center;
|
||||
}
|
||||
|
||||
internal static CuttingPlanFinding Finding(FixedProgramPlacement source, PostVerificationKind? kind, string message) =>
|
||||
new(source.SourceOrdinal, source.SourcePart, null, null, kind, message);
|
||||
|
||||
internal static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot, IEnumerable<PostVerificationFinding> findings) =>
|
||||
findings.Select(f =>
|
||||
{
|
||||
var source = f.PartNumber is { } p ? snapshot.Placements[p - 1] : null;
|
||||
var other = f.OtherPartNumber is { } o ? snapshot.Placements[o - 1] : null;
|
||||
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
|
||||
other?.SourceOrdinal, other?.SourcePart, f.Kind, f.Message);
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// The forward DFS over the next part on a given order and its emitted contour prefixes.
|
||||
/// Attempts share one expansion budget and one list of rejected findings.
|
||||
/// </summary>
|
||||
private sealed class Walk(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
private readonly List<CuttingPlanFinding> rejected = [];
|
||||
private readonly LeadMaterialSnapshot[] materials =
|
||||
snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
|
||||
|
||||
internal int Expansions { get; private set; }
|
||||
|
||||
internal IReadOnlyList<CuttingPlanFinding> Rejected => rejected.Distinct().ToArray();
|
||||
|
||||
internal Outcome Ready(IReadOnlyList<FixedProgramPlacement> order) =>
|
||||
new(CuttingPlanStatus.Ready, order, [], Expansions);
|
||||
|
||||
// Entries are capped; exhaustion is not a proof over all possible entries.
|
||||
internal Outcome Exhausted()
|
||||
{
|
||||
var status = snapshot.Placements.Any(p => p.Prepared != null)
|
||||
? CuttingPlanStatus.NoSolutionWithinBudget
|
||||
: rejected.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict;
|
||||
return new(status, [], Rejected, Expansions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follows <paramref name="sequence"/> from <paramref name="resume"/> (or the start point);
|
||||
/// a null sequence tries every dependency-ready part, nearest first.
|
||||
/// With a stall limit the attempt ends once that many expansions pass without getting
|
||||
/// further along the order; it never backtracks behind its starting node.
|
||||
/// </summary>
|
||||
internal Attempt Follow(int[] sequence, int? stall, Node resume)
|
||||
{
|
||||
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null);
|
||||
var attempt = new Attempt(sequence);
|
||||
var progressExpansions = Expansions;
|
||||
var stack = new Stack<Frame>();
|
||||
stack.Push(new(root));
|
||||
while (stack.Count != 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var frame = stack.Peek();
|
||||
var node = frame.Node;
|
||||
if (node.Order.Length == snapshot.Placements.Count)
|
||||
{
|
||||
attempt.Order = node.Order;
|
||||
return attempt;
|
||||
}
|
||||
if (attempt.Advance(node))
|
||||
progressExpansions = Expansions;
|
||||
else if (stall is int limit && Expansions - progressExpansions > limit)
|
||||
return attempt;
|
||||
frame.Children ??= Expand(node, sequence, attempt).OrderBy(c => c.Distance)
|
||||
.ThenBy(c => c.Ordinal).ThenBy(c => c.Contour).ThenBy(c => c.Entry).ToArray();
|
||||
if (frame.Next == frame.Children.Length)
|
||||
{
|
||||
stack.Pop();
|
||||
continue;
|
||||
}
|
||||
stack.Push(new(frame.Children[frame.Next++].Node));
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt)
|
||||
{
|
||||
IEnumerable<FixedProgramPlacement> sources;
|
||||
if (node.Active is { } active)
|
||||
sources = [active.Source];
|
||||
else
|
||||
{
|
||||
var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
|
||||
sources = sequence == null
|
||||
? snapshot.Placements.Where(p => !finished.Contains(p.SourceOrdinal)
|
||||
&& snapshot.Dependencies.IsReady(p.SourceOrdinal, finished))
|
||||
: snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished)
|
||||
? [snapshot.Placements[sequence[node.Order.Length]]] : [];
|
||||
}
|
||||
foreach (var source in sources)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (source.Prepared == null)
|
||||
{
|
||||
CountExpansion(source);
|
||||
var checker = node.Checker.Copy();
|
||||
if (Check(source, source.Execution, node.Position, checker, attempt))
|
||||
yield return new(new([.. node.Order, source], source.Execution.DeparturePoint, checker, null, node),
|
||||
source.Execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, -1);
|
||||
continue;
|
||||
}
|
||||
var prepared = source.Prepared;
|
||||
var choices = node.Active?.Choices ?? [];
|
||||
var arrival = node.Active?.Arrival ?? node.Position;
|
||||
var before = node.Active?.Before ?? node.Checker;
|
||||
var boundary = node.Active?.Boundary ?? node;
|
||||
var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
|
||||
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
|
||||
foreach (var contour in contours)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token);
|
||||
for (var entry = 0; entry < entries.Count; entry++)
|
||||
{
|
||||
CountExpansion(source); // Before emission/native queries, including rejected candidates.
|
||||
var prefix = choices.Append(entries[entry]).ToArray();
|
||||
Program program;
|
||||
OwnedExecution execution;
|
||||
try
|
||||
{
|
||||
program = prefix.Length == prepared.Count ? prepared.Emit(prefix) : prepared.EmitPrefix(prefix);
|
||||
execution = ExecutionMotionReader.Read(program, source.Location, arrival, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
|
||||
{
|
||||
rejected.Add(Finding(source, PostVerificationKind.Incomplete,
|
||||
$"Contour {contour}, entry {entry} emission refused: {ex.Message}"));
|
||||
continue; // Retain emitter semantics, never repair/shorten invalid lead styles.
|
||||
}
|
||||
// Prefix includes all earlier cuts and scribes. Replay from BEFORE the whole part.
|
||||
var checker = before.Copy();
|
||||
if (!Check(source, execution, arrival, checker, attempt)) continue;
|
||||
var distance = execution.RapidDistanceFrom(arrival);
|
||||
var next = prefix.Length == prepared.Count
|
||||
? new Node([.. node.Order, source.Propose(program, execution, prefix, token)],
|
||||
execution.DeparturePoint, checker, null, boundary)
|
||||
: new Node(node.Order, execution.DeparturePoint, checker,
|
||||
new(source, prefix, arrival, before, distance, boundary), null);
|
||||
yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CountExpansion(FixedProgramPlacement source)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (Expansions == snapshot.ExpansionBudget)
|
||||
{
|
||||
rejected.Add(Finding(source, null, $"Expansion budget {snapshot.ExpansionBudget} reached before the next candidate."));
|
||||
throw new BudgetExceededException();
|
||||
}
|
||||
Expansions++;
|
||||
snapshot.ExpansionObserver?.Invoke(Expansions);
|
||||
token.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
private bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival,
|
||||
ReleasedContourState checker, Attempt attempt)
|
||||
{
|
||||
var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, source.IsCutOff, token);
|
||||
rejected.AddRange(Map(snapshot, findings));
|
||||
attempt.NoteCrossings(source.SourceOrdinal, findings);
|
||||
// A fixed cutoff has no material or leads to certify; its rapids are still checked.
|
||||
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
|
||||
: LeadPathValidator.Check(execution, source.Material, materials, token);
|
||||
if (!lead.IsComplete || !lead.IsClear)
|
||||
rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
|
||||
return findings.Count == 0 && lead.IsComplete && lead.IsClear;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One pass along an order: how far it got, which part stopped it and what that part crossed.</summary>
|
||||
private sealed class Attempt(int[] sequence)
|
||||
{
|
||||
private long progress = -1;
|
||||
|
||||
internal IReadOnlyList<FixedProgramPlacement> Order { get; set; }
|
||||
|
||||
/// <summary>The deepest whole-part boundary reached; its Previous chain leads back to the root.</summary>
|
||||
private Node Deepest { get; set; }
|
||||
|
||||
/// <summary>The ordinal of the part the attempt could not get past, or null.</summary>
|
||||
internal int? Blocked => sequence == null || Deepest == null || Deepest.Order.Length >= sequence.Length ? null
|
||||
: sequence[Deepest.Order.Length];
|
||||
|
||||
/// <summary>Parts whose completed contours the blocked part's motions crossed (it can be among them).</summary>
|
||||
internal HashSet<int> Crossed { get; } = [];
|
||||
|
||||
// Records a node that gets further along the order than any before. True when it does.
|
||||
internal bool Advance(Node node)
|
||||
{
|
||||
var depth = (long)node.Order.Length * (int.MaxValue + 1L) + (node.Active?.Choices.Length ?? 0);
|
||||
if (depth <= progress)
|
||||
return false;
|
||||
progress = depth;
|
||||
if (node.Active == null)
|
||||
{
|
||||
Deepest = node;
|
||||
Crossed.Clear();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings)
|
||||
{
|
||||
if (Blocked != ordinal)
|
||||
return;
|
||||
foreach (var finding in findings)
|
||||
if (finding.Kind == PostVerificationKind.RapidCrossing && finding.OtherPartNumber is int other)
|
||||
Crossed.Add(other - 1);
|
||||
}
|
||||
|
||||
/// <summary>The part boundary at <paramref name="depth"/> on the way to the deepest one.</summary>
|
||||
internal Node BoundaryAt(int depth)
|
||||
{
|
||||
var node = Deepest;
|
||||
while (node.Order.Length > depth)
|
||||
node = node.Previous;
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BudgetExceededException : Exception;
|
||||
private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival,
|
||||
ReleasedContourState Before, double Distance, Node Boundary);
|
||||
/// <summary>A search state; Previous links a whole-part boundary to the boundary before it.</summary>
|
||||
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
|
||||
ActivePart Active, Node Previous);
|
||||
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry);
|
||||
private sealed class Frame(Node node)
|
||||
{
|
||||
internal Node Node { get; } = node;
|
||||
internal Edge[] Children { get; set; }
|
||||
internal int Next { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,13 @@ namespace OpenNest.Engine.Fill
|
||||
/// </summary>
|
||||
public string Label { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When positive, grid fills stop adding perpendicular rows once they hold at least
|
||||
/// this many parts; a first row that already holds them is returned on its own. The
|
||||
/// first row is always complete. Zero fills the whole work area.
|
||||
/// </summary>
|
||||
public int MaxParts { get; init; }
|
||||
|
||||
private static Vector MakeOffset(NestDirection direction, double distance)
|
||||
{
|
||||
return direction == NestDirection.Horizontal
|
||||
@@ -195,7 +202,12 @@ namespace OpenNest.Engine.Fill
|
||||
/// patterns, also adds individual parts from the next incomplete copy
|
||||
/// that still fit within the work area.
|
||||
/// </summary>
|
||||
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, OffsetPerimeterCache cache)
|
||||
private List<Part> TilePattern(
|
||||
Pattern basePattern,
|
||||
NestDirection direction,
|
||||
OffsetPerimeterCache cache,
|
||||
int maxParts = 0
|
||||
)
|
||||
{
|
||||
var copyDistance = FindPatternCopyDistance(basePattern, direction, cache);
|
||||
|
||||
@@ -211,7 +223,10 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
var count = 1;
|
||||
|
||||
while (true)
|
||||
// maxParts counts the base pattern too: stop once the tiling holds that many.
|
||||
bool Full() => maxParts > 0 && basePattern.Parts.Count + result.Count >= maxParts;
|
||||
|
||||
while (!Full())
|
||||
{
|
||||
var nextPos = start + copyDistance * count;
|
||||
|
||||
@@ -230,7 +245,7 @@ namespace OpenNest.Engine.Fill
|
||||
// next copy that didn't fit as a whole. This handles cases where
|
||||
// e.g. a 2-part pair only partially fits — one part may still be
|
||||
// within the work area even though the full pattern exceeds it.
|
||||
if (basePattern.Parts.Count > 1)
|
||||
if (basePattern.Parts.Count > 1 && !Full())
|
||||
{
|
||||
var offset = MakeOffset(direction, copyDistance * count);
|
||||
|
||||
@@ -383,7 +398,7 @@ namespace OpenNest.Engine.Fill
|
||||
// If primary tiling didn't produce copies, just tile along perpendicular
|
||||
if (row.Count <= pattern.Parts.Count)
|
||||
{
|
||||
row.AddRange(TilePattern(pattern, perpAxis, cache));
|
||||
row.AddRange(TilePattern(pattern, perpAxis, cache, MaxParts));
|
||||
|
||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
|
||||
{
|
||||
@@ -395,13 +410,19 @@ namespace OpenNest.Engine.Fill
|
||||
return row;
|
||||
}
|
||||
|
||||
// A capped fill whose first row already holds enough parts needs no more rows.
|
||||
// Step 2's check could only switch perpendicular tiling to the bbox fallback; it
|
||||
// never repairs the row itself, so returning the row loses no protection.
|
||||
if (MaxParts > 0 && row.Count >= MaxParts)
|
||||
return row;
|
||||
|
||||
// Step 2: Build row pattern and tile along perpendicular axis
|
||||
var rowPattern = new Pattern();
|
||||
rowPattern.Parts.AddRange(row);
|
||||
rowPattern.UpdateBounds();
|
||||
|
||||
var gridResult = new List<Part>(rowPattern.Parts);
|
||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache));
|
||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache, MaxParts));
|
||||
|
||||
// Only the unchanged row is covered by Step 1's clean verdict: skip Step 2
|
||||
// only when the perpendicular tiling appended zero parts, so gridResult
|
||||
|
||||
@@ -68,14 +68,16 @@ public class StripeFiller
|
||||
sheetSpan,
|
||||
spacing,
|
||||
axis,
|
||||
_context.Token
|
||||
_context.Token,
|
||||
_context.MaxQuantity
|
||||
);
|
||||
var shrinkResult = ConvergeStripeAngleShrink(
|
||||
pairParts,
|
||||
sheetSpan,
|
||||
spacing,
|
||||
axis,
|
||||
_context.Token
|
||||
_context.Token,
|
||||
_context.MaxQuantity
|
||||
);
|
||||
|
||||
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
|
||||
@@ -127,7 +129,11 @@ public class StripeFiller
|
||||
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
|
||||
return null;
|
||||
|
||||
var stripeEngine = new FillLinear(stripeBox, spacing) { Label = "Stripe" };
|
||||
var stripeEngine = new FillLinear(stripeBox, spacing)
|
||||
{
|
||||
Label = "Stripe",
|
||||
MaxParts = _context.MaxQuantity,
|
||||
};
|
||||
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
|
||||
|
||||
if (stripeParts == null || stripeParts.Count == 0)
|
||||
@@ -144,7 +150,11 @@ public class StripeFiller
|
||||
stripePattern.Parts.AddRange(stripeParts);
|
||||
stripePattern.UpdateBounds();
|
||||
|
||||
var gridEngine = new FillLinear(workArea, spacing) { Label = "Stripe-Grid" };
|
||||
var gridEngine = new FillLinear(workArea, spacing)
|
||||
{
|
||||
Label = "Stripe-Grid",
|
||||
MaxParts = _context.MaxQuantity,
|
||||
};
|
||||
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
|
||||
|
||||
if (gridParts == null || gridParts.Count == 0)
|
||||
@@ -179,6 +189,10 @@ public class StripeFiller
|
||||
|
||||
var allParts = new List<Part>(gridParts);
|
||||
|
||||
// A capped fill that already holds the quantity needs no remnant parts.
|
||||
if (_context.MaxQuantity > 0 && gridParts.Count >= _context.MaxQuantity)
|
||||
return allParts;
|
||||
|
||||
var remnantParts = FillRemnant(gridParts, primaryAxis);
|
||||
if (remnantParts != null)
|
||||
{
|
||||
@@ -351,18 +365,20 @@ public class StripeFiller
|
||||
/// <summary>
|
||||
/// Iteratively finds the rotation angle where N copies of the pattern
|
||||
/// span the given dimension with minimal waste by expanding pair width.
|
||||
/// Returns (angle, waste, pairCount).
|
||||
/// Returns (angle, waste, pairCount). A positive <paramref name="maxParts"/> caps
|
||||
/// the estimate rows like <see cref="FillLinear.MaxParts"/>.
|
||||
/// </summary>
|
||||
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
|
||||
List<Part> patternParts,
|
||||
double sheetSpan,
|
||||
double spacing,
|
||||
NestDirection axis,
|
||||
CancellationToken token = default
|
||||
CancellationToken token = default,
|
||||
int maxParts = 0
|
||||
)
|
||||
{
|
||||
var startAngle = OrientShortSideAlong(patternParts, axis);
|
||||
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
|
||||
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -374,7 +390,8 @@ public class StripeFiller
|
||||
double sheetSpan,
|
||||
double spacing,
|
||||
NestDirection axis,
|
||||
CancellationToken token = default
|
||||
CancellationToken token = default,
|
||||
int maxParts = 0
|
||||
)
|
||||
{
|
||||
var baseAngle = OrientShortSideAlong(patternParts, axis);
|
||||
@@ -392,7 +409,7 @@ public class StripeFiller
|
||||
return (0, double.MaxValue, 0);
|
||||
|
||||
var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
|
||||
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
|
||||
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
|
||||
}
|
||||
|
||||
private static (double Angle, double Waste, int Count) ConvergeFromAngle(
|
||||
@@ -401,7 +418,8 @@ public class StripeFiller
|
||||
double sheetSpan,
|
||||
double spacing,
|
||||
NestDirection axis,
|
||||
CancellationToken token
|
||||
CancellationToken token,
|
||||
int maxParts
|
||||
)
|
||||
{
|
||||
var bestWaste = double.MaxValue;
|
||||
@@ -428,7 +446,11 @@ public class StripeFiller
|
||||
axis == NestDirection.Horizontal
|
||||
? new Box(0, 0, sheetSpan, perpDim)
|
||||
: new Box(0, 0, perpDim, sheetSpan);
|
||||
var engine = new FillLinear(stripeBox, spacing) { Label = "Stripe-EstimateRow" };
|
||||
var engine = new FillLinear(stripeBox, spacing)
|
||||
{
|
||||
Label = "Stripe-EstimateRow",
|
||||
MaxParts = maxParts,
|
||||
};
|
||||
var filled = engine.Fill(rotated, axis);
|
||||
var n = filled?.Count ?? 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>
|
||||
/// Fills one closed cutout of a frame part with copies of one insert part. A Fill lattice is
|
||||
/// built over the cutout's bounds plus one part step on every side and shifted across a grid of
|
||||
/// offsets of up to half a step each way. At each offset the copies whose spacing-grown outline
|
||||
/// lies inside the cutout (a point test against the insert's inner-fit region of the cutout)
|
||||
/// are counted; the offset keeping the most copies wins. Every kept pose is then certified with
|
||||
/// <see cref="NestLayoutCheck.Clears"/> against the frame and the other copies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
|
||||
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into
|
||||
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order.
|
||||
/// </remarks>
|
||||
internal static class CutoutLatticeFill
|
||||
{
|
||||
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
|
||||
internal const int DefaultShiftSteps = 8;
|
||||
|
||||
private const double FlattenTolerance = 0.001;
|
||||
|
||||
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
|
||||
private const double Margin = FlattenTolerance + 0.001;
|
||||
|
||||
private const int Precision = NestTolerances.ClipperPrecision;
|
||||
|
||||
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
|
||||
/// or none when no copy fits.</summary>
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
NestJobPart insert, int maxQuantity, double spacing, CancellationToken token = default) =>
|
||||
Fill(frame, cutoutIndex, insert, maxQuantity, spacing, DefaultShiftSteps, token);
|
||||
|
||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
ArgumentNullException.ThrowIfNull(insert);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
|
||||
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
|
||||
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
|
||||
if (frameGeometry == null || insertGeometry == null)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||
var cutout = frameGeometry.Cutouts[cutoutIndex];
|
||||
if (!cutout.IsClosed())
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
|
||||
// Inscribed: the flattened cutout never extends past the real one.
|
||||
var hole = Positive(ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, FlattenTolerance, circumscribe: false),
|
||||
new Vector()));
|
||||
if (hole.Count < 3)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
var bounds = cutout.BoundingBox;
|
||||
var insertBounds = insertGeometry.Bounds;
|
||||
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
|
||||
|
||||
var lattice = Lattice(insert, bounds, step, spacing, token);
|
||||
if (lattice.Count == 0)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
var regions = new Dictionary<double, PathsD>();
|
||||
foreach (var rotation in lattice.Select(p => p.Rotation).Distinct())
|
||||
regions[rotation] = InnerFit(insertGeometry, rotation, hole, spacing);
|
||||
|
||||
var best = (Count: 0, I: 0, J: 0);
|
||||
for (var i = -shiftSteps; i <= shiftSteps; i++)
|
||||
for (var j = -shiftSteps; j <= shiftSteps; j++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var (dx, dy) = Shift(step, shiftSteps, i, j);
|
||||
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy));
|
||||
if (Better(count, i, j, best))
|
||||
best = (count, i, j);
|
||||
}
|
||||
if (best.Count == 0)
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
|
||||
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
|
||||
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy))
|
||||
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
|
||||
System.Math.Round(p.Y + sy, 8), p.Rotation))
|
||||
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
|
||||
.ToList();
|
||||
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
|
||||
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Default Fill over the cutout's bounds grown by one step on every side, in frame
|
||||
/// coordinates. Fill places whole parts only, so the margin keeps every offset covered.</summary>
|
||||
private static List<NestJobPlacement> Lattice(NestJobPart insert, Box bounds, double step, double spacing,
|
||||
CancellationToken token)
|
||||
{
|
||||
var drawing = DrawingJobMapper.CreateDrawing(insert);
|
||||
try
|
||||
{
|
||||
var parts = PrivatePlateFill.Run(drawing, insert.Rotation, spacing,
|
||||
bounds.Length + 2 * step, bounds.Width + 2 * step, 0, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var left = bounds.Left - step;
|
||||
var bottom = bounds.Bottom - step;
|
||||
return parts.Select(p => new NestJobPlacement(insert.Id, 0, p.Location.X + left, p.Location.Y + bottom,
|
||||
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
|
||||
.Where(p => double.IsFinite(p.X) && double.IsFinite(p.Y) && insert.Rotation.Allows(p.Rotation))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
|
||||
or NotSupportedException or ArithmeticException)
|
||||
{
|
||||
return new List<NestJobPlacement>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
BestFitCache.Invalidate(drawing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference points t at which the insert, rotated and grown by the spacing, lies inside the
|
||||
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
|
||||
/// never meets the cutout boundary (t outside boundary + reflected outline).
|
||||
/// </summary>
|
||||
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
||||
{
|
||||
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
|
||||
foreach (var entity in entities)
|
||||
entity.Rotate(rotation);
|
||||
var perimeter = new ShapeProfile(entities).Perimeter;
|
||||
var outline = Positive(ClipperBridge.ToPath(ClipperBridge.Flatten(perimeter, FlattenTolerance, circumscribe: true),
|
||||
new Vector()));
|
||||
var grown = Clipper.InflatePaths(new PathsD { outline }, spacing + Margin, JoinType.Round, EndType.Polygon,
|
||||
2.0, Precision, FlattenTolerance)
|
||||
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
|
||||
if (grown == null || grown.Count < 3)
|
||||
return new PathsD();
|
||||
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
|
||||
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
|
||||
var forbidden = Clipper.Union(Minkowski.Sum(reflected, hole, true, Precision),
|
||||
new PathsD { Clipper.TranslatePath(reflected, hole[0].x, hole[0].y) }, FillRule.NonZero, Precision);
|
||||
var anchor = grown[0];
|
||||
return Clipper.Difference(new PathsD { Clipper.TranslatePath(hole, -anchor.x, -anchor.y) }, forbidden,
|
||||
FillRule.NonZero, Precision);
|
||||
}
|
||||
|
||||
/// <summary>Drops copies that fail the production clearance check against the frame; any
|
||||
/// failing pair of copies rejects the whole fill, since the lattice itself is then unsound.</summary>
|
||||
private static IReadOnlyList<NestJobPlacement> Certify(string frameId, JobPartGeometry frame,
|
||||
JobPartGeometry insertGeometry, List<NestJobPlacement> poses, double spacing, CancellationToken token)
|
||||
{
|
||||
var framePose = new NestJobPlacement(frameId, 0, 0, 0, 0);
|
||||
var clear = new List<NestJobPlacement>();
|
||||
foreach (var pose in poses)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (NestLayoutCheck.Clears(frame, framePose, insertGeometry, pose, spacing))
|
||||
clear.Add(pose);
|
||||
}
|
||||
// Rotation is about the reference point, so every copy's material lies within this
|
||||
// distance of its pose whatever its rotation.
|
||||
var b = insertGeometry.Bounds;
|
||||
var reach = new[] { (b.Left, b.Bottom), (b.Right, b.Bottom), (b.Right, b.Top), (b.Left, b.Top) }
|
||||
.Max(c => System.Math.Sqrt(c.Item1 * c.Item1 + c.Item2 * c.Item2));
|
||||
var apart = 2 * reach + spacing;
|
||||
for (var i = 0; i < clear.Count; i++)
|
||||
for (var j = i + 1; j < clear.Count; j++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (System.Math.Abs(clear[i].X - clear[j].X) > apart || System.Math.Abs(clear[i].Y - clear[j].Y) > apart)
|
||||
continue;
|
||||
if (!NestLayoutCheck.Clears(insertGeometry, clear[i], insertGeometry, clear[j], spacing))
|
||||
return Array.Empty<NestJobPlacement>();
|
||||
}
|
||||
return clear;
|
||||
}
|
||||
|
||||
private static (double Dx, double Dy) Shift(double step, int steps, int i, int j) =>
|
||||
steps == 0 ? (0, 0) : (step * i / (2.0 * steps), step * j / (2.0 * steps));
|
||||
|
||||
/// <summary>More copies win; ties keep the smaller offset, then the lower i, then j.</summary>
|
||||
private static bool Better(int count, int i, int j, (int Count, int I, int J) best)
|
||||
{
|
||||
if (count != best.Count)
|
||||
return count > best.Count;
|
||||
var distance = System.Math.Abs(i) + System.Math.Abs(j);
|
||||
var bestDistance = System.Math.Abs(best.I) + System.Math.Abs(best.J);
|
||||
if (distance != bestDistance)
|
||||
return distance < bestDistance;
|
||||
return i != best.I ? i < best.I : j < best.J;
|
||||
}
|
||||
|
||||
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
|
||||
private static bool Inside(PathsD region, double x, double y)
|
||||
{
|
||||
var point = new PointD(x, y);
|
||||
var inside = false;
|
||||
foreach (var path in region)
|
||||
{
|
||||
var result = Clipper.PointInPolygon(point, path, Precision);
|
||||
if (result == PointInPolygonResult.IsOn)
|
||||
return false;
|
||||
if (result == PointInPolygonResult.IsInside)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private static PathD Positive(PathD path)
|
||||
{
|
||||
if (path.Count >= 3 && !Clipper.IsPositive(path))
|
||||
path.Reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ internal class DefaultPlateFiller : PlateFillerBase
|
||||
set => angleBuilder.ForceFullSweep = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, a positive item quantity lets supporting strategies stop adding rows once a
|
||||
/// candidate holds the quantity. Other strategies still fill the work area; the winner is
|
||||
/// trimmed to the quantity. Off by default, so ordinary fills are unchanged.
|
||||
/// </summary>
|
||||
internal bool StopAtQuantity { get; init; }
|
||||
|
||||
public override List<double> BuildAngles(
|
||||
NestItem item,
|
||||
ClassificationResult classification,
|
||||
@@ -338,7 +345,7 @@ internal class DefaultPlateFiller : PlateFillerBase
|
||||
Token = token,
|
||||
Progress = progress,
|
||||
Policy = BuildPolicy(),
|
||||
MaxQuantity = item.Quantity,
|
||||
MaxQuantity = StopAtQuantity ? System.Math.Max(0, item.Quantity) : 0,
|
||||
};
|
||||
RunPipeline(context);
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement.Fillers;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Placement;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the Default Fill for one private drawing on a private plate, for callers that turn the
|
||||
/// result into job poses. The caller owns the drawing and must call
|
||||
/// <see cref="BestFitCache.Invalidate"/> for it when finished.
|
||||
/// </summary>
|
||||
internal static class PrivatePlateFill
|
||||
{
|
||||
/// <param name="length">X extent of the plate and work area.</param>
|
||||
/// <param name="width">Y extent of the plate and work area.</param>
|
||||
/// <param name="quantity">Copies wanted; zero fills the whole area.</param>
|
||||
internal static List<Part> Run(Drawing drawing, RotationPolicy rotation, double spacing,
|
||||
double length, double width, int quantity, CancellationToken token)
|
||||
{
|
||||
var plate = new Plate(new Size(width, length)) { PartSpacing = spacing };
|
||||
// The drawing is private: stabilize this cache entry before Fill's candidate pruning.
|
||||
var fits = BestFitCache.GetOrCompute(drawing, plate.Size.Length, plate.Size.Width, spacing);
|
||||
var sorted = fits.OrderBy(f => f.RotatedArea).ThenBy(f => f.Candidate.StrategyIndex)
|
||||
.ThenBy(f => f.Candidate.Part2Rotation).ThenBy(f => f.Candidate.Part2Offset.X)
|
||||
.ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray();
|
||||
fits.Clear();
|
||||
fits.AddRange(sorted);
|
||||
// A positive quantity lets supporting strategies avoid surplus rows. Zero still
|
||||
// fills the whole area (the cutout lattice caller requires an uncapped lattice).
|
||||
var filler = new DefaultPlateFiller(plate) { StopAtQuantity = true };
|
||||
return filler.Fill(new NestItem
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = quantity,
|
||||
RotationStart = rotation.Start,
|
||||
RotationEnd = rotation.End,
|
||||
StepAngle = DrawingJobMapper.LegacyStep(rotation),
|
||||
}, new Box(0, 0, length, width), null!, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>Per-solve, per-spacing Fill proposals. Only the members occupy material.</summary>
|
||||
internal sealed class BlockCatalog : IDisposable
|
||||
{
|
||||
private readonly double spacing;
|
||||
private readonly Dictionary<int, Drawing> drawings = new();
|
||||
private readonly Dictionary<int, List<Orientation>> orientations = new();
|
||||
private readonly Dictionary<int, int> attempts = new();
|
||||
internal int PreparationCount => attempts.Values.Sum();
|
||||
private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList<Placed>> cache = new();
|
||||
|
||||
public BlockCatalog(double spacing, IReadOnlyList<PartType> types,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs)
|
||||
{
|
||||
this.spacing = spacing;
|
||||
foreach (var type in types)
|
||||
{
|
||||
var poses = type.Orientations.ToList();
|
||||
if (pairs.TryGetValue(type.Index, out var found))
|
||||
poses.AddRange(found.SelectMany(p => new[] { p.A, p.B }));
|
||||
orientations[type.Index] = poses.Distinct().ToList();
|
||||
}
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<Box> Rectangles(Box work, IReadOnlyList<Placed> placed, double spacing)
|
||||
{
|
||||
var free = new PathsD { new PathD
|
||||
{
|
||||
new(work.Left, work.Bottom), new(work.Right, work.Bottom),
|
||||
new(work.Right, work.Top), new(work.Left, work.Top),
|
||||
} };
|
||||
foreach (var part in placed)
|
||||
{
|
||||
// Physical occupied material, not a reference-point region for any moving part.
|
||||
// The catalog currently prepares solid outlines; future profile preparation owns holes.
|
||||
var blocked = Clipper.InflatePaths(new PathsD { part.Orientation.Outline },
|
||||
spacing + part.Orientation.Tolerance + 0.001, JoinType.Miter, EndType.Polygon,
|
||||
2, NoFitCache.Precision);
|
||||
free = Clipper.Difference(free, Clipper.TranslatePaths(blocked, part.X, part.Y),
|
||||
FillRule.NonZero, NoFitCache.Precision);
|
||||
}
|
||||
return MaximalRectangles.InRegion(free).OrderByDescending(b => b.Area())
|
||||
.ThenBy(b => b.Left).ThenBy(b => b.Bottom).ThenBy(b => b.Length).Take(2).ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<Placed> Get(PartType type, int quantity, Box rectangle, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (quantity <= 2 || type.Orientations.Count == 0 || rectangle.Length <= 0 || rectangle.Width <= 0)
|
||||
return Array.Empty<Placed>();
|
||||
var key = (type.Index, quantity, rectangle.Length, rectangle.Width);
|
||||
if (cache.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8)
|
||||
return Array.Empty<Placed>();
|
||||
attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1;
|
||||
var result = Build(type, quantity, rectangle, token);
|
||||
cache[key] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
private IReadOnlyList<Placed> Build(PartType type, int quantity, Box rectangle, CancellationToken token)
|
||||
{
|
||||
if (!drawings.TryGetValue(type.Index, out var drawing))
|
||||
drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part);
|
||||
try
|
||||
{
|
||||
var members = PrivatePlateFill.Run(drawing, type.Part.Rotation, spacing,
|
||||
rectangle.Length, rectangle.Width, quantity, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
return Resolve(type, members.Take(quantity).ToArray(), orientations[type.Index], spacing, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
|
||||
or NotSupportedException or ArithmeticException)
|
||||
{
|
||||
return Array.Empty<Placed>();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var drawing in drawings.Values)
|
||||
BestFitCache.Invalidate(drawing);
|
||||
drawings.Clear();
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<Placed> Resolve(PartType type, IReadOnlyList<Part> members,
|
||||
List<Orientation> orientations, double spacing, CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (members.Count <= 2)
|
||||
return Array.Empty<Placed>();
|
||||
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
|
||||
if (geometry == null)
|
||||
return Array.Empty<Placed>();
|
||||
// Canonical rebinding is already performed by FillItem. Quantization removes sub-grid
|
||||
// arithmetic differences from equivalent Fill proposals; certify the resulting poses.
|
||||
var poses = members.Select(p => new NestJobPlacement(type.Part.Id, 0,
|
||||
System.Math.Round(p.Location.X, 8), System.Math.Round(p.Location.Y, 8),
|
||||
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
|
||||
.OrderBy(p => p.X).ThenBy(p => p.Y).ThenBy(p => p.Rotation).ToArray();
|
||||
if (poses.Any(p => !double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Rotation)
|
||||
|| !type.Part.Rotation.Allows(p.Rotation)))
|
||||
return Array.Empty<Placed>();
|
||||
for (var i = 0; i < poses.Length; i++)
|
||||
for (var j = i + 1; j < poses.Length; j++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing))
|
||||
return Array.Empty<Placed>();
|
||||
}
|
||||
var result = new List<Placed>();
|
||||
foreach (var pose in poses)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var orientation = orientations.FirstOrDefault(o => o.Rotation == pose.Rotation);
|
||||
if (orientation == null)
|
||||
{
|
||||
orientation = PartCatalog.CreateOrientation(type, orientations.Max(o => o.Index) + 1, pose.Rotation);
|
||||
if (orientation == null)
|
||||
return Array.Empty<Placed>();
|
||||
orientations.Add(orientation);
|
||||
}
|
||||
result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -55,17 +55,30 @@ internal sealed class FrontierPacker
|
||||
|
||||
private readonly IReadOnlyList<PartType> types;
|
||||
private readonly NoFitCache nfps;
|
||||
private readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs;
|
||||
private readonly NestPlateStock stock;
|
||||
private readonly PackAxis axis;
|
||||
private readonly double beta;
|
||||
private readonly Box work;
|
||||
private readonly WorkCounter counter;
|
||||
private readonly BlockCatalog? blocks;
|
||||
|
||||
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
|
||||
public FrontierPacker(
|
||||
IReadOnlyList<PartType> types,
|
||||
NoFitCache nfps,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs,
|
||||
NestPlateStock stock,
|
||||
PackAxis axis,
|
||||
double beta,
|
||||
WorkCounter counter,
|
||||
BlockCatalog? blocks = null
|
||||
)
|
||||
{
|
||||
this.blocks = blocks;
|
||||
this.counter = counter;
|
||||
this.types = types;
|
||||
this.nfps = nfps;
|
||||
this.pairs = pairs;
|
||||
this.stock = stock;
|
||||
this.axis = axis;
|
||||
this.beta = beta;
|
||||
@@ -76,39 +89,102 @@ internal sealed class FrontierPacker
|
||||
{
|
||||
var left = remaining.ToArray();
|
||||
var states = new List<Region>();
|
||||
var byOrientation = new Dictionary<Orientation, Region>(ReferenceEqualityComparer.Instance);
|
||||
bool Track(Orientation o, bool single)
|
||||
{
|
||||
if (byOrientation.ContainsKey(o))
|
||||
return true;
|
||||
if (!stock.Fits(o.Width, o.Height))
|
||||
return false;
|
||||
var region = new Region(o, work, single);
|
||||
states.Add(region);
|
||||
byOrientation[o] = region;
|
||||
return true;
|
||||
}
|
||||
var offered = new List<PairState>();
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (left[type.Index] <= 0)
|
||||
continue;
|
||||
foreach (var o in type.Orientations)
|
||||
if (stock.Fits(o.Width, o.Height))
|
||||
states.Add(new Region(o, work));
|
||||
Track(o, single: true);
|
||||
if (left[type.Index] < 2 || !pairs.TryGetValue(type.Index, out var typePairs))
|
||||
continue;
|
||||
foreach (var pair in typePairs)
|
||||
{
|
||||
// Members missing from the catalog get regions too, but only for pair placement.
|
||||
if (stock.Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
|
||||
offered.Add(new PairState(pair, byOrientation[pair.A], byOrientation[pair.B]));
|
||||
}
|
||||
}
|
||||
|
||||
var placed = new List<Placed>();
|
||||
var blockStates = new List<BlockState>();
|
||||
var preparations = 0;
|
||||
void PrepareBlocks()
|
||||
{
|
||||
if (blocks == null || preparations++ >= 2)
|
||||
return;
|
||||
var rectangles = BlockCatalog.Rectangles(work, placed, stock.PartSpacing);
|
||||
foreach (var type in types.Where(t => left[t.Index] > 2)
|
||||
.OrderByDescending(t => t.Area * left[t.Index]).ThenBy(t => t.Index).Take(4))
|
||||
foreach (var rectangle in rectangles)
|
||||
{
|
||||
var members = blocks.Get(type, left[type.Index], rectangle, token);
|
||||
if (members.Count <= 2)
|
||||
continue;
|
||||
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
|
||||
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
|
||||
if (!stock.Fits(width, height))
|
||||
continue;
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (byOrientation.ContainsKey(member.Orientation))
|
||||
continue;
|
||||
if (!Track(member.Orientation, single: false))
|
||||
break;
|
||||
var region = byOrientation[member.Orientation];
|
||||
foreach (var existing in placed)
|
||||
region.Subtract(nfps.Get(existing.Orientation, member.Orientation), existing.X, existing.Y);
|
||||
}
|
||||
if (members.All(p => byOrientation.ContainsKey(p.Orientation)))
|
||||
blockStates.Add(new BlockState(members, members.Select(p => byOrientation[p.Orientation]).ToArray()));
|
||||
}
|
||||
}
|
||||
PrepareBlocks();
|
||||
var partArea = 0.0;
|
||||
var front = axis == PackAxis.X ? work.Left : work.Bottom;
|
||||
|
||||
while (states.Count > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var choice = Choose(states, front);
|
||||
var choice = Choose(states, offered, blockStates, front);
|
||||
if (choice == null)
|
||||
break;
|
||||
|
||||
var (region, point) = choice.Value;
|
||||
var part = new Placed(region.Orientation, point.x, point.y);
|
||||
var typeIndex = choice[0].Orientation.TypeIndex;
|
||||
foreach (var part in choice)
|
||||
{
|
||||
placed.Add(part);
|
||||
var typeIndex = region.Orientation.TypeIndex;
|
||||
partArea += types[typeIndex].Area;
|
||||
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
|
||||
}
|
||||
|
||||
if (--left[typeIndex] == 0)
|
||||
left[typeIndex] -= choice.Count;
|
||||
blockStates.RemoveAll(b => b.Members.Count > left[b.Members[0].Orientation.TypeIndex]);
|
||||
if (left[typeIndex] == 0)
|
||||
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
|
||||
if (left[typeIndex] < 2)
|
||||
{
|
||||
offered.RemoveAll(p => p.Pose.TypeIndex == typeIndex);
|
||||
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex && !s.Single);
|
||||
}
|
||||
|
||||
// Each surviving region loses the positions the new part now blocks. Regions are
|
||||
// Each surviving region loses the positions the new parts now block. Regions are
|
||||
// independent, so they update in parallel without affecting determinism.
|
||||
var snapshot = states.ToArray();
|
||||
foreach (var part in choice)
|
||||
{
|
||||
counter.Add(snapshot.Length);
|
||||
Parallel.For(
|
||||
0,
|
||||
@@ -116,34 +192,42 @@ internal sealed class FrontierPacker
|
||||
new ParallelOptions { CancellationToken = token },
|
||||
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
|
||||
);
|
||||
}
|
||||
states.RemoveAll(s => s.IsEmpty);
|
||||
offered.RemoveAll(p => p.A.IsEmpty || p.B.IsEmpty);
|
||||
if (offered.Count == 0 && blocks == null)
|
||||
states.RemoveAll(s => !s.Single);
|
||||
blockStates.RemoveAll(b => b.Regions.Any(r => r.IsEmpty));
|
||||
PrepareBlocks();
|
||||
}
|
||||
|
||||
return new SheetFill(stock, placed, partArea);
|
||||
}
|
||||
|
||||
private (Region, PointD)? Choose(List<Region> states, double front)
|
||||
/// <summary>
|
||||
/// The next placement: one part, or both members of a pair. Singles and pairs compete
|
||||
/// under the same rule; a pair counts as one piece of twice the part area, and on a tie
|
||||
/// the single (considered first) is kept.
|
||||
/// </summary>
|
||||
private IReadOnlyList<Placed>? Choose(List<Region> states, List<PairState> offered, List<BlockState> blocks, double front)
|
||||
{
|
||||
Region? bestRegion = null;
|
||||
var bestPoint = default(PointD);
|
||||
IReadOnlyList<Placed>? bestParts = null;
|
||||
var bestFills = false;
|
||||
var bestValue = double.PositiveInfinity;
|
||||
var bestSide = double.PositiveInfinity;
|
||||
var bestLead = double.PositiveInfinity;
|
||||
var bestPriority = int.MaxValue;
|
||||
|
||||
foreach (var region in states)
|
||||
void Consider(Func<IReadOnlyList<Placed>> build, int typeIndex, double area, double advance, double side, double lead)
|
||||
{
|
||||
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
|
||||
continue;
|
||||
var area = types[region.Orientation.TypeIndex].Area;
|
||||
var priority = types[region.Orientation.TypeIndex].Part.Priority;
|
||||
if (priority > bestPriority) continue;
|
||||
var priority = types[typeIndex].Part.Priority;
|
||||
if (priority > bestPriority)
|
||||
return;
|
||||
var fills = advance <= Tie;
|
||||
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
|
||||
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
|
||||
|
||||
var better = bestRegion == null
|
||||
var better = bestParts == null
|
||||
|| priority < bestPriority
|
||||
|| (fills && !bestFills)
|
||||
|| (
|
||||
@@ -157,17 +241,83 @@ internal sealed class FrontierPacker
|
||||
)
|
||||
);
|
||||
if (!better)
|
||||
continue;
|
||||
bestRegion = region;
|
||||
return;
|
||||
bestParts = build();
|
||||
bestPriority = priority;
|
||||
bestPoint = point;
|
||||
bestFills = fills;
|
||||
bestValue = value;
|
||||
bestSide = side;
|
||||
bestLead = lead;
|
||||
}
|
||||
|
||||
return bestRegion == null ? null : (bestRegion, bestPoint);
|
||||
foreach (var region in states)
|
||||
{
|
||||
if (!region.Single)
|
||||
continue;
|
||||
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
|
||||
continue;
|
||||
var o = region.Orientation;
|
||||
Consider(() => new[] { new Placed(o, point.x, point.y) }, o.TypeIndex, types[o.TypeIndex].Area, advance, side, lead);
|
||||
}
|
||||
|
||||
foreach (var state in offered)
|
||||
{
|
||||
var pair = state.Pose;
|
||||
if (!state.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
|
||||
continue;
|
||||
Consider(
|
||||
() => new[] { new Placed(pair.A, point.x, point.y), new Placed(pair.B, point.x + pair.Dx, point.y + pair.Dy) },
|
||||
pair.TypeIndex,
|
||||
2 * types[pair.TypeIndex].Area,
|
||||
advance,
|
||||
side,
|
||||
lead
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var block in blocks)
|
||||
{
|
||||
if (!block.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
|
||||
continue;
|
||||
var typeIndex = block.Members[0].Orientation.TypeIndex;
|
||||
Consider(() => block.Members.Select(p => new Placed(p.Orientation, p.X + point.x, p.Y + point.y)).ToArray(),
|
||||
typeIndex, block.Members.Count * types[typeIndex].Area, advance, side, lead);
|
||||
}
|
||||
return bestParts;
|
||||
}
|
||||
|
||||
private sealed class BlockState(IReadOnlyList<Placed> members, Region[] regions)
|
||||
{
|
||||
public IReadOnlyList<Placed> Members { get; } = members;
|
||||
public Region[] Regions { get; } = regions;
|
||||
|
||||
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
|
||||
out PointD point, out double advance, out double side, out double lead)
|
||||
{
|
||||
var free = Clipper.TranslatePaths(Regions[0].Free, -Members[0].X, -Members[0].Y);
|
||||
var minX = double.NegativeInfinity;
|
||||
var minY = double.NegativeInfinity;
|
||||
var maxX = double.PositiveInfinity;
|
||||
var maxY = double.PositiveInfinity;
|
||||
for (var i = 0; i < Members.Count; i++)
|
||||
{
|
||||
var member = Members[i];
|
||||
var region = Regions[i];
|
||||
minX = System.Math.Max(minX, region.MinX - member.X);
|
||||
minY = System.Math.Max(minY, region.MinY - member.Y);
|
||||
maxX = System.Math.Min(maxX, region.MaxX - member.X);
|
||||
maxY = System.Math.Min(maxY, region.MaxY - member.Y);
|
||||
if (i > 0)
|
||||
{
|
||||
counter.Add(1);
|
||||
free = Clipper.Intersect(free, Clipper.TranslatePaths(region.Free, -member.X, -member.Y),
|
||||
FillRule.NonZero, NoFitCache.Precision);
|
||||
}
|
||||
}
|
||||
return BestVertex(free, minX, minY, System.Math.Max(minX, maxX), System.Math.Max(minY, maxY),
|
||||
(Members.Min(p => p.Left), Members.Min(p => p.Bottom), Members.Max(p => p.Right), Members.Max(p => p.Top)),
|
||||
axis, front, out point, out advance, out side, out lead);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Legal reference points for one orientation on this sheet.</summary>
|
||||
@@ -177,9 +327,10 @@ internal sealed class FrontierPacker
|
||||
private PathsD free;
|
||||
private RectD bounds;
|
||||
|
||||
public Region(Orientation orientation, Box work)
|
||||
public Region(Orientation orientation, Box work, bool single)
|
||||
{
|
||||
Orientation = orientation;
|
||||
Single = single;
|
||||
minX = work.Left - orientation.MinX;
|
||||
maxX = work.Right - orientation.MaxX;
|
||||
minY = work.Bottom - orientation.MinY;
|
||||
@@ -203,6 +354,10 @@ internal sealed class FrontierPacker
|
||||
}
|
||||
|
||||
public Orientation Orientation { get; }
|
||||
|
||||
/// <summary>False for a pair-only orientation: it is never placed on its own.</summary>
|
||||
public bool Single { get; }
|
||||
|
||||
public bool IsEmpty => free.Count == 0;
|
||||
|
||||
public void Subtract(Nfp nfp, double dx, double dy)
|
||||
@@ -219,18 +374,54 @@ internal sealed class FrontierPacker
|
||||
// Drop numerical dust; a sliver thinner than the precision grid is no real room.
|
||||
free.RemoveAll(p => p.Count < 3);
|
||||
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
|
||||
Version++;
|
||||
}
|
||||
|
||||
/// <summary>Changes whenever the free region does.</summary>
|
||||
public int Version { get; private set; }
|
||||
|
||||
public PathsD Free => free;
|
||||
public RectD Bounds => bounds;
|
||||
public double MinX => minX;
|
||||
public double MinY => minY;
|
||||
public double MaxX => maxX;
|
||||
public double MaxY => maxY;
|
||||
|
||||
/// <summary>
|
||||
/// Best vertex of the free region: least front advance, then lowest cross-axis position,
|
||||
/// then lowest leading edge. Vertices suffice because every score is linear in position.
|
||||
/// </summary>
|
||||
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
|
||||
{
|
||||
var o = Orientation;
|
||||
return BestVertex(free, minX, minY, maxX, maxY, (o.MinX, o.MinY, o.MaxX, o.MaxY),
|
||||
axis, front, out point, out advance, out side, out lead);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scores the vertices of <paramref name="free"/>, each clamped to the inner-fit box
|
||||
/// [minX, maxX] x [minY, maxY], for a piece occupying <paramref name="box"/> around its
|
||||
/// reference point.
|
||||
/// </summary>
|
||||
private static bool BestVertex(
|
||||
PathsD free,
|
||||
double minX,
|
||||
double minY,
|
||||
double maxX,
|
||||
double maxY,
|
||||
(double MinX, double MinY, double MaxX, double MaxY) box,
|
||||
PackAxis axis,
|
||||
double front,
|
||||
out PointD point,
|
||||
out double advance,
|
||||
out double side,
|
||||
out double lead
|
||||
)
|
||||
{
|
||||
point = default;
|
||||
advance = side = lead = double.PositiveInfinity;
|
||||
var found = false;
|
||||
var o = Orientation;
|
||||
foreach (var path in free)
|
||||
foreach (var raw in path)
|
||||
{
|
||||
@@ -239,15 +430,15 @@ internal sealed class FrontierPacker
|
||||
double reach, across, start;
|
||||
if (axis == PackAxis.X)
|
||||
{
|
||||
reach = x + o.MaxX;
|
||||
across = y + o.MinY;
|
||||
start = x + o.MinX;
|
||||
reach = x + box.MaxX;
|
||||
across = y + box.MinY;
|
||||
start = x + box.MinX;
|
||||
}
|
||||
else
|
||||
{
|
||||
reach = y + o.MaxY;
|
||||
across = x + o.MinX;
|
||||
start = y + o.MinY;
|
||||
reach = y + box.MaxY;
|
||||
across = x + box.MinX;
|
||||
start = y + box.MinY;
|
||||
}
|
||||
var adv = System.Math.Max(0, reach - front);
|
||||
var better = !found
|
||||
@@ -263,5 +454,52 @@ internal sealed class FrontierPacker
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legal reference points for a pair: member A's free region intersected with member B's
|
||||
/// moved back by the pair offset. Recomputed only after either member's region changes.
|
||||
/// </summary>
|
||||
private sealed class PairState(PairPose pose, Region a, Region b)
|
||||
{
|
||||
private int versionA = -1, versionB = -1;
|
||||
private PathsD free = new();
|
||||
|
||||
public PairPose Pose { get; } = pose;
|
||||
public Region A { get; } = a;
|
||||
public Region B { get; } = b;
|
||||
|
||||
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
|
||||
out PointD point, out double advance, out double side, out double lead)
|
||||
{
|
||||
if (versionA != A.Version || versionB != B.Version)
|
||||
{
|
||||
versionA = A.Version;
|
||||
versionB = B.Version;
|
||||
counter.Add(1);
|
||||
free = Intersect();
|
||||
}
|
||||
// Clamp into both members' inner-fit boxes; they overlap whenever the pair fits.
|
||||
var minX = System.Math.Max(A.MinX, B.MinX - Pose.Dx);
|
||||
var maxX = System.Math.Max(minX, System.Math.Min(A.MaxX, B.MaxX - Pose.Dx));
|
||||
var minY = System.Math.Max(A.MinY, B.MinY - Pose.Dy);
|
||||
var maxY = System.Math.Max(minY, System.Math.Min(A.MaxY, B.MaxY - Pose.Dy));
|
||||
return BestVertex(free, minX, minY, maxX, maxY, (Pose.MinX, Pose.MinY, Pose.MaxX, Pose.MaxY),
|
||||
axis, front, out point, out advance, out side, out lead);
|
||||
}
|
||||
|
||||
private PathsD Intersect()
|
||||
{
|
||||
if (A.IsEmpty || B.IsEmpty)
|
||||
return new PathsD();
|
||||
var a = A.Bounds;
|
||||
var b = B.Bounds;
|
||||
if (b.right - Pose.Dx < a.left || b.left - Pose.Dx > a.right
|
||||
|| b.bottom - Pose.Dy < a.top || b.top - Pose.Dy > a.bottom)
|
||||
return new PathsD();
|
||||
var shifted = Clipper.TranslatePaths(B.Free, -Pose.Dx, -Pose.Dy);
|
||||
var result = Clipper.Intersect(A.Free, shifted, FillRule.NonZero, NoFitCache.Precision);
|
||||
result.RemoveAll(p => p.Count < 3);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
@@ -48,9 +48,10 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var types = PartCatalog.Build(job);
|
||||
var solver = new Solver(job, types, progress, token);
|
||||
using var solver = new Solver(job, types, progress, token);
|
||||
|
||||
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
|
||||
// Pair-only orientations may fit stock even when the sampled single poses do not.
|
||||
// Demand that neither a single nor a pair can fit is reported unplaced.
|
||||
var demand = new int[types.Count];
|
||||
foreach (var type in types)
|
||||
{
|
||||
@@ -58,7 +59,8 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
stock.Quantity != 0
|
||||
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
|
||||
);
|
||||
demand[type.Index] = placeable ? type.Part.Quantity : 0;
|
||||
demand[type.Index] = placeable || solver.PairFits(type)
|
||||
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
|
||||
}
|
||||
|
||||
Plan? best = null;
|
||||
@@ -85,10 +87,26 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
IReadOnlyList<PartType> types,
|
||||
IProgress<NestJobProgress>? progress,
|
||||
CancellationToken token
|
||||
)
|
||||
) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var catalog in blocks.Values)
|
||||
catalog.Dispose();
|
||||
}
|
||||
|
||||
private const int MaxTail = 3;
|
||||
private readonly Dictionary<double, NoFitCache> caches = new();
|
||||
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
|
||||
private readonly Dictionary<double, BlockCatalog> blocks = new();
|
||||
|
||||
private BlockCatalog BlocksFor(NestPlateStock stock)
|
||||
{
|
||||
var spacing = System.Math.Max(0, stock.PartSpacing);
|
||||
if (!blocks.TryGetValue(spacing, out var found))
|
||||
blocks[spacing] = found = new BlockCatalog(spacing, types, PairsFor(stock));
|
||||
return found;
|
||||
}
|
||||
|
||||
public WorkCounter Work { get; } = new();
|
||||
|
||||
@@ -157,6 +175,30 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
return cache;
|
||||
}
|
||||
|
||||
public bool PairFits(PartType type)
|
||||
{
|
||||
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
|
||||
return false;
|
||||
return job.Plates.Any(stock => stock.Quantity != 0
|
||||
&& PairsFor(stock).TryGetValue(type.Index, out var candidates)
|
||||
&& candidates.Any(pair => stock.Fits(pair.Width, pair.Height)));
|
||||
}
|
||||
|
||||
/// <summary>Best-fit pairs for this stock's spacing, built once per solve.</summary>
|
||||
private IReadOnlyDictionary<int, IReadOnlyList<PairPose>> PairsFor(NestPlateStock stock)
|
||||
{
|
||||
var clearance = System.Math.Max(0, stock.PartSpacing);
|
||||
if (!pairs.TryGetValue(clearance, out var found))
|
||||
{
|
||||
// The best-fit plate filter only needs the largest sheet at this spacing;
|
||||
// each packer still checks the pair against its own work area.
|
||||
var sheets = job.Plates.Where(p => System.Math.Max(0, p.PartSpacing) == clearance).ToList();
|
||||
pairs[clearance] = found = PairCatalog.Build(types, clearance,
|
||||
sheets.Max(p => p.Size.Length), sheets.Max(p => p.Size.Width), token);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
|
||||
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
|
||||
@@ -199,7 +241,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||
continue;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
|
||||
var fill = packer.Fill(remaining, token);
|
||||
if (fill.Parts.Count > 0)
|
||||
trials.Add((fill, NetArea(job.Options, fill)));
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// Two copies of one part type placed together: member A at the pair's reference point and
|
||||
/// member B at <see cref="Dx"/>, <see cref="Dy"/> from it.
|
||||
/// </summary>
|
||||
internal sealed class PairPose
|
||||
{
|
||||
public required Orientation A { get; init; }
|
||||
public required Orientation B { get; init; }
|
||||
public required double Dx { get; init; }
|
||||
public required double Dy { get; init; }
|
||||
|
||||
public int TypeIndex => A.TypeIndex;
|
||||
|
||||
/// <summary>Box of both members around the pair's reference point (catalog-padded).</summary>
|
||||
public double MinX => System.Math.Min(A.MinX, Dx + B.MinX);
|
||||
public double MinY => System.Math.Min(A.MinY, Dy + B.MinY);
|
||||
public double MaxX => System.Math.Max(A.MaxX, Dx + B.MaxX);
|
||||
public double MaxY => System.Math.Max(A.MaxY, Dy + B.MaxY);
|
||||
public double Width => MaxX - MinX;
|
||||
public double Height => MaxY - MinY;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-fit pairs offered to the packer for part types with two or more copies.
|
||||
///
|
||||
/// A pair is placed through the existing free regions: its reference point must be legal for
|
||||
/// member A and, shifted by the pair offset, for member B (region A intersected with region B
|
||||
/// moved back by the offset). No new no-fit polygons are built. The members' clearance from
|
||||
/// each other comes from the best-fit search and is re-checked here once per pair with the
|
||||
/// layout check at the job spacing; a pair that fails is never offered.
|
||||
/// </summary>
|
||||
internal static class PairCatalog
|
||||
{
|
||||
/// <summary>Best-fit results tried per type, in rank order.</summary>
|
||||
private const int CandidatesPerType = 12;
|
||||
|
||||
/// <summary>Pairs kept per type; each one costs a region intersection per placement.</summary>
|
||||
private const int PairsPerType = 2;
|
||||
|
||||
private const double AngleTolerance = 1e-6;
|
||||
|
||||
/// <summary>
|
||||
/// Pairs for every type with quantity two or more, for one part spacing. A member rotation
|
||||
/// missing from the catalog gets a pair-only orientation (indexed after the catalog's), so
|
||||
/// both members always own cached footprints; such orientations are never offered as singles.
|
||||
/// </summary>
|
||||
/// <param name="sheetLength">Largest sheet X extent offered at this spacing.</param>
|
||||
/// <param name="sheetWidth">Largest sheet Y extent offered at this spacing.</param>
|
||||
public static IReadOnlyDictionary<int, IReadOnlyList<PairPose>> Build(
|
||||
IReadOnlyList<PartType> types,
|
||||
double spacing,
|
||||
double sheetLength,
|
||||
double sheetWidth,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var result = new Dictionary<int, IReadOnlyList<PairPose>>();
|
||||
foreach (var type in types)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
|
||||
continue;
|
||||
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
|
||||
if (geometry == null)
|
||||
continue;
|
||||
var pairs = BuildForType(type, geometry, spacing, sheetLength, sheetWidth, token);
|
||||
if (pairs.Count > 0)
|
||||
result[type.Index] = pairs;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<PairPose> BuildForType(
|
||||
PartType type,
|
||||
JobPartGeometry geometry,
|
||||
double spacing,
|
||||
double sheetLength,
|
||||
double sheetWidth,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var drawing = new Drawing(type.Part.Id, DrawingJobMapper.ToProgram(type.Part.Geometry));
|
||||
List<BestFitResult> fits;
|
||||
try
|
||||
{
|
||||
fits = BestFitCache.GetOrCompute(drawing, sheetLength, sheetWidth, spacing);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
|
||||
or NotSupportedException or ArithmeticException)
|
||||
{
|
||||
return new List<PairPose>();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The drawing exists only for this call; release its cache entry now.
|
||||
BestFitCache.Invalidate(drawing);
|
||||
}
|
||||
|
||||
// Best-fit evaluates in parallel, so equal-area results arrive in any order. Sort on
|
||||
// the candidate itself as well, so the same job always picks the same pairs.
|
||||
var ranked = fits
|
||||
.Where(f => f.Keep)
|
||||
.OrderBy(f => f.RotatedArea)
|
||||
.ThenBy(f => f.Candidate.StrategyIndex)
|
||||
.ThenBy(f => f.Candidate.Part2Rotation)
|
||||
.ThenBy(f => f.Candidate.Part2Offset.X)
|
||||
.ThenBy(f => f.Candidate.Part2Offset.Y)
|
||||
.ThenBy(f => f.OptimalRotation)
|
||||
.Take(CandidatesPerType);
|
||||
|
||||
var pairs = new List<PairPose>();
|
||||
var extra = new List<Orientation>();
|
||||
foreach (var fit in ranked)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
List<Part> members;
|
||||
try
|
||||
{
|
||||
members = fit.BuildSourceParts(drawing);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (members.Count != 2)
|
||||
continue;
|
||||
// The whole pair may also turn a quarter: offer it along each sheet axis.
|
||||
foreach (var turn in new[] { 0.0, System.Math.PI / 2 })
|
||||
{
|
||||
var pair = Resolve(type, extra, geometry, members, turn, spacing);
|
||||
if (pair != null && !pairs.Any(p => SamePair(p, pair)))
|
||||
pairs.Add(pair);
|
||||
if (pairs.Count >= PairsPerType)
|
||||
return pairs;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
internal static PairPose? Resolve(
|
||||
PartType type,
|
||||
List<Orientation> extra,
|
||||
JobPartGeometry geometry,
|
||||
List<Part> members,
|
||||
double turn,
|
||||
double spacing
|
||||
)
|
||||
{
|
||||
// Turning a part about the origin turns its program and its location together,
|
||||
// so the members keep their relative placement.
|
||||
var poses = members
|
||||
.Select(member =>
|
||||
{
|
||||
var location = member.Location.Rotate(turn);
|
||||
return (Rotation: Angle.NormalizeRad(member.Rotation + turn), location.X, location.Y);
|
||||
})
|
||||
.ToArray();
|
||||
if (!type.Part.Rotation.Allows(poses[0].Rotation) || !type.Part.Rotation.Allows(poses[1].Rotation))
|
||||
return null;
|
||||
|
||||
var a = new NestJobPlacement(type.Part.Id, 0, poses[0].X, poses[0].Y, poses[0].Rotation);
|
||||
var b = new NestJobPlacement(type.Part.Id, 1, poses[1].X, poses[1].Y, poses[1].Rotation);
|
||||
if (!NestLayoutCheck.Clears(geometry, a, geometry, b, spacing))
|
||||
return null;
|
||||
|
||||
var oa = Find(type, extra, poses[0].Rotation);
|
||||
var ob = Find(type, extra, poses[1].Rotation);
|
||||
if (oa == null || ob == null)
|
||||
return null;
|
||||
return new PairPose { A = oa, B = ob, Dx = poses[1].X - poses[0].X, Dy = poses[1].Y - poses[0].Y };
|
||||
}
|
||||
|
||||
private static Orientation? Find(PartType type, List<Orientation> extra, double rotation)
|
||||
{
|
||||
foreach (var orientation in type.Orientations.Concat(extra))
|
||||
if (SameAngle(orientation.Rotation, rotation))
|
||||
return orientation;
|
||||
var added = PartCatalog.CreateOrientation(type, type.Orientations.Count + extra.Count, rotation);
|
||||
if (added != null)
|
||||
extra.Add(added);
|
||||
return added;
|
||||
}
|
||||
|
||||
private static bool SamePair(PairPose first, PairPose second) =>
|
||||
ReferenceEquals(first.A, second.A)
|
||||
&& ReferenceEquals(first.B, second.B)
|
||||
&& System.Math.Abs(first.Dx - second.Dx) < 1e-6
|
||||
&& System.Math.Abs(first.Dy - second.Dy) < 1e-6;
|
||||
|
||||
private static bool SameAngle(double first, double second)
|
||||
{
|
||||
var difference = Angle.NormalizeRad(first - second);
|
||||
return difference < AngleTolerance || Angle.TwoPI - difference < AngleTolerance;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,11 @@ internal sealed class Orientation
|
||||
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
|
||||
public required double Tolerance { get; init; }
|
||||
|
||||
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary>
|
||||
/// <summary>
|
||||
/// Outline bounds grown by the outline's actual flattening error, so they contain the true
|
||||
/// perimeter: by <see cref="Tolerance"/> when arcs were flattened, not at all for line-only
|
||||
/// outlines, whose vertices are exact. Footprints and NFPs still use <see cref="Tolerance"/>.
|
||||
/// </summary>
|
||||
public required double MinX { get; init; }
|
||||
public required double MinY { get; init; }
|
||||
public required double MaxX { get; init; }
|
||||
@@ -41,6 +45,9 @@ internal sealed class PartType
|
||||
public required NestJobPart Part { get; init; }
|
||||
public required double Area { get; init; }
|
||||
public required IReadOnlyList<Orientation> Orientations { get; init; }
|
||||
|
||||
/// <summary>Analytic perimeter the orientations were flattened from; null when unreadable.</summary>
|
||||
public Shape? Perimeter { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -93,15 +100,36 @@ internal static class PartCatalog
|
||||
var outline = Polygonize(perimeter, angle, tolerance);
|
||||
if (outline.Count < 3)
|
||||
continue;
|
||||
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
|
||||
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance, perimeter));
|
||||
}
|
||||
|
||||
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
|
||||
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
|
||||
types.Add(new PartType
|
||||
{
|
||||
Index = index,
|
||||
Part = part,
|
||||
Area = area,
|
||||
Orientations = orientations,
|
||||
Perimeter = perimeter,
|
||||
});
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An extra pose of <paramref name="type"/> at <paramref name="angle"/>, flattened like the
|
||||
/// catalog's own. <paramref name="index"/> must be unique within the type, because NFP
|
||||
/// caches key on it.
|
||||
/// </summary>
|
||||
internal static Orientation? CreateOrientation(PartType type, int index, double angle)
|
||||
{
|
||||
if (type.Perimeter == null || type.Orientations.Count == 0)
|
||||
return null;
|
||||
var tolerance = type.Orientations[0].Tolerance;
|
||||
var outline = Polygonize(type.Perimeter, angle, tolerance);
|
||||
return outline.Count < 3 ? null : MakeOrientation(type.Index, index, angle, outline, tolerance, type.Perimeter);
|
||||
}
|
||||
|
||||
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
|
||||
JobPartGeometry.TryRead(geometry)?.Perimeter;
|
||||
|
||||
@@ -139,9 +167,21 @@ internal static class PartCatalog
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance)
|
||||
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline,
|
||||
double tolerance, Shape perimeter)
|
||||
{
|
||||
var bounds = Clipper.GetBounds(outline);
|
||||
// Curves retain flattening-error padding. For lines, use analytic endpoint bounds:
|
||||
// polygon cleanup can discard short-edge chains and shrink the material's true extent.
|
||||
var padding = tolerance;
|
||||
if (perimeter.Entities.All(e => e.Type == EntityType.Line))
|
||||
{
|
||||
var nominal = (Shape)perimeter.Clone();
|
||||
nominal.Rotate(angle);
|
||||
var box = nominal.BoundingBox;
|
||||
bounds = new RectD(box.Left, box.Bottom, box.Right, box.Top);
|
||||
padding = 0;
|
||||
}
|
||||
return new Orientation
|
||||
{
|
||||
TypeIndex = typeIndex,
|
||||
@@ -149,10 +189,10 @@ internal static class PartCatalog
|
||||
Rotation = angle,
|
||||
Outline = outline,
|
||||
Tolerance = tolerance,
|
||||
MinX = bounds.left - tolerance,
|
||||
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
|
||||
MaxX = bounds.right + tolerance,
|
||||
MaxY = bounds.bottom + tolerance,
|
||||
MinX = bounds.left - padding,
|
||||
MinY = bounds.top - padding, // Clipper RectD: top is the minimum Y.
|
||||
MaxX = bounds.right + padding,
|
||||
MaxY = bounds.bottom + padding,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
namespace OpenNest.Engine.Sequencing
|
||||
{
|
||||
@@ -60,7 +59,8 @@ namespace OpenNest.Engine.Sequencing
|
||||
// An orphaned cutoff still must not follow potentially
|
||||
// crossed parts when its nominal span cannot be recovered.
|
||||
if (!definitions.TryGetValue(cut.Part.BaseDrawing, out var definition)
|
||||
|| CrossesBounds(definition, part.BoundingBox, bounds))
|
||||
|| CuttingDependencyGraph.CutOffCrosses(definition.Axis, definition.Position,
|
||||
definition.StartLimit, definition.EndLimit, part.BoundingBox, bounds))
|
||||
{
|
||||
result.Add(cut.Part);
|
||||
emitted[cut.Index] = true;
|
||||
@@ -77,26 +77,5 @@ namespace OpenNest.Engine.Sequencing
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool CrossesBounds(CutOff cutOff, Box part, Box plate)
|
||||
{
|
||||
var vertical = cutOff.Axis == CutOffAxis.Vertical;
|
||||
var position = vertical ? cutOff.Position.X : cutOff.Position.Y;
|
||||
var acrossMin = vertical ? part.Left : part.Bottom;
|
||||
var acrossMax = vertical ? part.Right : part.Top;
|
||||
var alongMin = vertical ? part.Bottom : part.Left;
|
||||
var alongMax = vertical ? part.Top : part.Right;
|
||||
var start = cutOff.StartLimit ?? (vertical ? plate.Bottom : plate.Left);
|
||||
var end = cutOff.EndLimit ?? (vertical ? plate.Top : plate.Right);
|
||||
|
||||
// Use the nominal line, not its trimmed cutting segments (which
|
||||
// deliberately skip the parts). Bounds conservatively include edge
|
||||
// contacts and concave recesses; limits prevent unrelated dependencies
|
||||
// beyond the cutoff's span. Negative coordinates need no special case.
|
||||
return !(position < acrossMin - Tolerance.Epsilon
|
||||
|| position > acrossMax + Tolerance.Epsilon
|
||||
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|
||||
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ namespace OpenNest.Engine.Strategies
|
||||
public CancellationToken Token { get; init; }
|
||||
public IProgress<NestProgress> Progress { get; init; }
|
||||
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
|
||||
/// <summary>
|
||||
/// When positive, strategies may stop placing parts once a candidate holds this
|
||||
/// many (see <see cref="FillLinear.MaxParts"/>). Zero fills the whole work area.
|
||||
/// </summary>
|
||||
public int MaxQuantity { get; init; }
|
||||
public PartType PartType { get; set; }
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine.Fill;
|
||||
@@ -40,24 +39,27 @@ namespace OpenNest.Engine.Strategies
|
||||
IFillComparer comparer = null
|
||||
)
|
||||
{
|
||||
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
||||
// Slots in angle order, V before H within an angle: ties keep the earliest slot
|
||||
// however the workers finish.
|
||||
var results = new (List<Part> Parts, FillScore Score)[angles.Count * 2];
|
||||
|
||||
Parallel.ForEach(
|
||||
angles,
|
||||
angle =>
|
||||
Parallel.For(
|
||||
0,
|
||||
angles.Count,
|
||||
i =>
|
||||
{
|
||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
||||
var pattern = BuildRotatedPattern(groupParts, angles[i]);
|
||||
|
||||
if (pattern.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||
if (h != null && h.Count > 0)
|
||||
results.Add((h, comparer == null ? FillScore.Compute(h, workArea) : default));
|
||||
results[2 * i + 1] = (h, comparer == null ? FillScore.Compute(h, workArea) : default);
|
||||
|
||||
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||
if (v != null && v.Count > 0)
|
||||
results.Add((v, comparer == null ? FillScore.Compute(v, workArea) : default));
|
||||
results[2 * i] = (v, comparer == null ? FillScore.Compute(v, workArea) : default);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -66,6 +68,9 @@ namespace OpenNest.Engine.Strategies
|
||||
|
||||
foreach (var res in results)
|
||||
{
|
||||
if (res.Parts == null)
|
||||
continue;
|
||||
|
||||
if (comparer != null)
|
||||
{
|
||||
if (best == null || comparer.IsBetter(res.Parts, best, workArea))
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.IO.Bom
|
||||
@@ -31,23 +29,8 @@ namespace OpenNest.IO.Bom
|
||||
{
|
||||
var result = new BomAnalysis();
|
||||
|
||||
// Build a case-insensitive lookup of DXF files in the folder (if it exists)
|
||||
var folderExists = Directory.Exists(dxfFolder);
|
||||
var dxfFiles = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (folderExists)
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(dxfFolder, "*.dxf"))
|
||||
{
|
||||
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
||||
dxfFiles[nameWithoutExt] = file;
|
||||
}
|
||||
foreach (var file in Directory.GetFiles(dxfFolder, "*.dwg"))
|
||||
{
|
||||
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
||||
dxfFiles.TryAdd(nameWithoutExt, file);
|
||||
}
|
||||
}
|
||||
// Case-insensitive lookup of the drawing files in the folder (if it exists)
|
||||
var drawings = DrawingFileIndex.Load(dxfFolder);
|
||||
|
||||
// Partition items into: skipped, unmatched, or matched (grouped)
|
||||
var matched = new List<MatchedPart>();
|
||||
@@ -60,20 +43,12 @@ namespace OpenNest.IO.Bom
|
||||
continue;
|
||||
}
|
||||
|
||||
var lookupName = item.FileName;
|
||||
|
||||
if (
|
||||
lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
||||
|
||||
if (!folderExists)
|
||||
if (!drawings.FolderExists)
|
||||
{
|
||||
// No folder to search — group items without a DXF path
|
||||
matched.Add(new MatchedPart { Item = item, DxfPath = null });
|
||||
}
|
||||
else if (dxfFiles.TryGetValue(lookupName, out var dxfPath))
|
||||
else if (drawings.Find(item.FileName) is { } dxfPath)
|
||||
{
|
||||
matched.Add(new MatchedPart { Item = item, DxfPath = dxfPath });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.IO.Bom;
|
||||
|
||||
/// <summary>
|
||||
/// The rows of one material and thickness; each group becomes one nest.
|
||||
/// </summary>
|
||||
public sealed class BomImportGroup
|
||||
{
|
||||
public BomImportGroup(string material, double thickness, IReadOnlyList<BomPartRow> parts)
|
||||
{
|
||||
Material = material;
|
||||
Thickness = thickness;
|
||||
Parts = parts;
|
||||
}
|
||||
|
||||
/// <summary>The material as the group's first row spells it.</summary>
|
||||
public string Material { get; }
|
||||
|
||||
public double Thickness { get; }
|
||||
|
||||
public IReadOnlyList<BomPartRow> Parts { get; }
|
||||
|
||||
/// <summary>Sum of the rows' quantities.</summary>
|
||||
public int TotalQty => Parts.Sum(p => p.Qty ?? 0);
|
||||
|
||||
public string Key => BomImportGroups.Key(Material, Thickness);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups importable rows by material and thickness. The Groups tab and
|
||||
/// Create Nests both use this, so they always agree.
|
||||
/// </summary>
|
||||
public static class BomImportGroups
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the groups of <see cref="BomRowStatus.Ready"/> rows. Material
|
||||
/// is compared case-insensitively. Groups are ordered by material, then
|
||||
/// thickness.
|
||||
/// </summary>
|
||||
public static List<BomImportGroup> Build(IEnumerable<BomPartRow> rows)
|
||||
{
|
||||
return rows.Where(p => p.Status == BomRowStatus.Ready)
|
||||
.GroupBy(p => new
|
||||
{
|
||||
Material = p.Material.ToUpperInvariant(),
|
||||
Thickness = p.Thickness.Value,
|
||||
})
|
||||
.OrderBy(g => g.First().Material)
|
||||
.ThenBy(g => g.Key.Thickness)
|
||||
.Select(g => new BomImportGroup(g.First().Material, g.Key.Thickness, g.ToList()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key that identifies a group's plate settings across regrouping.
|
||||
/// </summary>
|
||||
public static string Key(string material, double thickness) =>
|
||||
$"{material?.ToUpperInvariant()}|{thickness}";
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.IO.Bom;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the import dialog's part rows from the BOM items and the drawing
|
||||
/// folder.
|
||||
/// </summary>
|
||||
public static class BomImportRows
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns one row per BOM item, in BOM order. Every row with a file
|
||||
/// name gets the drawing it matches in <paramref name="dxfFolder"/>,
|
||||
/// whether or not its material and thickness are filled in. A blank
|
||||
/// BOM quantity becomes 1, marked as assumed.
|
||||
/// </summary>
|
||||
public static List<BomPartRow> Build(List<BomItem> items, string dxfFolder)
|
||||
{
|
||||
var index = DrawingFileIndex.Load(dxfFolder);
|
||||
var rows = new List<BomPartRow>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
rows.Add(
|
||||
new BomPartRow
|
||||
{
|
||||
ItemNum = item.ItemNum,
|
||||
FileName = item.FileName,
|
||||
BomQty = item.Qty,
|
||||
Qty = item.Qty ?? 1,
|
||||
QtyAssumed = item.Qty == null,
|
||||
Description = item.Description,
|
||||
Material = item.Material,
|
||||
Thickness = item.Thickness,
|
||||
DxfPath = string.IsNullOrWhiteSpace(item.FileName)
|
||||
? null
|
||||
: index.Find(item.FileName),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.IO.Bom;
|
||||
|
||||
/// <summary>The import dialog's one-line summary of the part rows.</summary>
|
||||
public static class BomImportSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// For example "12 ready, 2 need a thickness, 1 no drawing found, 3 had
|
||||
/// no BOM quantity (1 used)".
|
||||
/// </summary>
|
||||
public static string Describe(IEnumerable<BomPartRow> rows)
|
||||
{
|
||||
var list = rows.ToList();
|
||||
var counts = list.GroupBy(r => r.Status).ToDictionary(g => g.Key, g => g.Count());
|
||||
int Count(BomRowStatus status) => counts.TryGetValue(status, out var n) ? n : 0;
|
||||
|
||||
var parts = new List<string> { $"{Count(BomRowStatus.Ready)} ready" };
|
||||
Add(parts, Count(BomRowStatus.NeedsMaterial), "needs a material", "need a material");
|
||||
Add(parts, Count(BomRowStatus.NeedsThickness), "needs a thickness", "need a thickness");
|
||||
Add(parts, Count(BomRowStatus.NeedsQuantity), "needs a quantity", "need a quantity");
|
||||
Add(parts, Count(BomRowStatus.NoDrawing), "no drawing found", "no drawing found");
|
||||
Add(parts, Count(BomRowStatus.NoFileName), "no file name", "no file name");
|
||||
Add(
|
||||
parts,
|
||||
list.Count(r => r.IsEditable && r.QtyAssumed),
|
||||
"had no BOM quantity (1 used)",
|
||||
"had no BOM quantity (1 used)"
|
||||
);
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static void Add(List<string> parts, int count, string one, string many)
|
||||
{
|
||||
if (count > 0)
|
||||
parts.Add($"{count} {(count == 1 ? one : many)}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.IO.Bom;
|
||||
|
||||
/// <summary>Plate size and spacing for one group's nest.</summary>
|
||||
public sealed class BomGroupPlateSettings
|
||||
{
|
||||
public double PlateWidth { get; set; }
|
||||
|
||||
public double PlateLength { get; set; }
|
||||
|
||||
public double PartSpacing { get; set; }
|
||||
|
||||
public double EdgeLeft { get; set; }
|
||||
|
||||
public double EdgeBottom { get; set; }
|
||||
|
||||
public double EdgeRight { get; set; }
|
||||
|
||||
public double EdgeTop { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The nest built for one group, or none when no drawing imported, plus a
|
||||
/// message for every drawing that could not be imported.
|
||||
/// </summary>
|
||||
public sealed class BomNestBuildResult
|
||||
{
|
||||
public BomNestBuildResult(Nest nest, IReadOnlyList<string> errors)
|
||||
{
|
||||
Nest = nest;
|
||||
Errors = errors;
|
||||
}
|
||||
|
||||
/// <summary>The new nest, or null when none of the group's drawings imported.</summary>
|
||||
public Nest Nest { get; }
|
||||
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
}
|
||||
|
||||
/// <summary>Builds the nest for one BOM import group.</summary>
|
||||
public static class BomNestBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a nest named "<paramref name="jobName"/> - thickness material".
|
||||
/// <paramref name="applySavedDefaults"/> runs first (units, quadrant,
|
||||
/// plate), then the group's plate size and spacing, material and
|
||||
/// thickness are set. Each drawing file is imported once, needing the
|
||||
/// total quantity of the rows that name it (a blank quantity counts as
|
||||
/// 1). The nest gets one plate when at least one drawing imported.
|
||||
/// </summary>
|
||||
public static BomNestBuildResult Build(
|
||||
BomImportGroup group,
|
||||
BomGroupPlateSettings plate,
|
||||
string jobName,
|
||||
Action<Nest> applySavedDefaults
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(group);
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
|
||||
var material = group.Material;
|
||||
var thickness = group.Thickness;
|
||||
var errors = new List<string>();
|
||||
|
||||
var nest = new Nest($"{jobName} - {thickness:0.###} {material}");
|
||||
nest.DateCreated = DateTime.Now;
|
||||
nest.DateLastModified = DateTime.Now;
|
||||
// Saved defaults first (units, quadrant, plate), as New does;
|
||||
// then the group's own plate size and spacing.
|
||||
applySavedDefaults?.Invoke(nest);
|
||||
nest.PlateDefaults.Size = new Size(plate.PlateWidth, plate.PlateLength);
|
||||
nest.Thickness = thickness;
|
||||
nest.Material = new Material(material);
|
||||
nest.PlateDefaults.PartSpacing = plate.PartSpacing;
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(
|
||||
plate.EdgeLeft,
|
||||
plate.EdgeBottom,
|
||||
plate.EdgeRight,
|
||||
plate.EdgeTop
|
||||
);
|
||||
|
||||
// Rows naming the same drawing file become one drawing that needs
|
||||
// their total: the nest's drawing set is keyed by drawing name.
|
||||
foreach (var rows in group.Parts.GroupBy(p => p.DxfPath, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var part = rows.First();
|
||||
if (!File.Exists(part.DxfPath))
|
||||
{
|
||||
errors.Add($"{part.FileName}: DXF file not found");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var drawing = CadImporter.ImportDrawing(
|
||||
part.DxfPath,
|
||||
new CadImportOptions { Quantity = rows.Sum(p => p.Qty ?? 1) }
|
||||
);
|
||||
drawing.Material = new Material(material);
|
||||
nest.Drawings.Add(drawing);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{part.FileName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (nest.Drawings.Count == 0)
|
||||
return new BomNestBuildResult(null, errors);
|
||||
|
||||
nest.CreatePlate();
|
||||
return new BomNestBuildResult(nest, errors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace OpenNest.IO.Bom;
|
||||
|
||||
/// <summary>Whether a BOM row can be imported, and if not, why.</summary>
|
||||
public enum BomRowStatus
|
||||
{
|
||||
/// <summary>Drawing found and every value set: the row is imported.</summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>Drawing found but the material is blank.</summary>
|
||||
NeedsMaterial,
|
||||
|
||||
/// <summary>Drawing found but the thickness is blank, zero or negative.</summary>
|
||||
NeedsThickness,
|
||||
|
||||
/// <summary>Drawing found but the quantity is below 1.</summary>
|
||||
NeedsQuantity,
|
||||
|
||||
/// <summary>No drawing file matches the row's file name.</summary>
|
||||
NoDrawing,
|
||||
|
||||
/// <summary>The BOM row has no file name.</summary>
|
||||
NoFileName,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One BOM line as the import dialog shows and edits it: the values read
|
||||
/// from the BOM, the drawing file it resolved to and whether it can be
|
||||
/// imported. Changing Material, Thickness or Qty raises
|
||||
/// <see cref="PropertyChanged"/> for that value and for the status.
|
||||
/// </summary>
|
||||
public class BomPartRow : INotifyPropertyChanged
|
||||
{
|
||||
private string material;
|
||||
private double? thickness;
|
||||
private int? qty;
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
public int? ItemNum { get; set; }
|
||||
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>The quantity the BOM gave, unchanged by edits; null when blank.</summary>
|
||||
public int? BomQty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The quantity to import. Set it only to a value accepted by
|
||||
/// <see cref="BomQuantity.TryParse"/>, or use <see cref="TrySetQuantity"/>.
|
||||
/// Setting it clears <see cref="QtyAssumed"/>.
|
||||
/// </summary>
|
||||
public int? Qty
|
||||
{
|
||||
get => qty;
|
||||
set
|
||||
{
|
||||
QtyAssumed = false;
|
||||
Set(ref qty, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True while Qty is the 1 used for a blank BOM quantity.</summary>
|
||||
public bool QtyAssumed { get; set; }
|
||||
|
||||
public string Description { get; set; }
|
||||
|
||||
public string Material
|
||||
{
|
||||
get => material;
|
||||
set => Set(ref material, value);
|
||||
}
|
||||
|
||||
public double? Thickness
|
||||
{
|
||||
get => thickness;
|
||||
set => Set(ref thickness, value);
|
||||
}
|
||||
|
||||
/// <summary>The matched drawing file, or null when none was found.</summary>
|
||||
public string DxfPath { get; set; }
|
||||
|
||||
public BomRowStatus Status
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(FileName))
|
||||
return BomRowStatus.NoFileName;
|
||||
if (string.IsNullOrWhiteSpace(DxfPath))
|
||||
return BomRowStatus.NoDrawing;
|
||||
if (string.IsNullOrWhiteSpace(Material))
|
||||
return BomRowStatus.NeedsMaterial;
|
||||
if (Thickness is not double t || !double.IsFinite(t) || t <= 0)
|
||||
return BomRowStatus.NeedsThickness;
|
||||
if (Qty is not int q || q < 1)
|
||||
return BomRowStatus.NeedsQuantity;
|
||||
return BomRowStatus.Ready;
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText => Describe(Status);
|
||||
|
||||
/// <summary>
|
||||
/// True when the row has a drawing, so the operator can complete its
|
||||
/// values; rows without one can never be imported.
|
||||
/// </summary>
|
||||
public bool IsEditable => Status is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing);
|
||||
|
||||
/// <summary>
|
||||
/// Sets Qty from typed text when <see cref="BomQuantity.TryParse"/>
|
||||
/// accepts it; otherwise leaves the row unchanged and returns false.
|
||||
/// </summary>
|
||||
public bool TrySetQuantity(string text)
|
||||
{
|
||||
if (!BomQuantity.TryParse(text, out var value))
|
||||
return false;
|
||||
|
||||
Qty = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string Describe(BomRowStatus status) =>
|
||||
status switch
|
||||
{
|
||||
BomRowStatus.Ready => "Ready",
|
||||
BomRowStatus.NeedsMaterial => "Needs material",
|
||||
BomRowStatus.NeedsThickness => "Needs thickness",
|
||||
BomRowStatus.NeedsQuantity => "Needs quantity",
|
||||
BomRowStatus.NoDrawing => "No drawing found",
|
||||
BomRowStatus.NoFileName => "No file name",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
private void Set<T>(ref T field, T value, [CallerMemberName] string name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
return;
|
||||
|
||||
field = value;
|
||||
OnPropertyChanged(name);
|
||||
OnPropertyChanged(nameof(Status));
|
||||
OnPropertyChanged(nameof(StatusText));
|
||||
}
|
||||
|
||||
private void OnPropertyChanged(string name) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace OpenNest.IO.Bom;
|
||||
|
||||
/// <summary>Reads a part quantity the operator typed in the import dialog.</summary>
|
||||
public static class BomQuantity
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepts a whole number of at least 1, with optional surrounding
|
||||
/// spaces. Rejects blanks, signs, decimals, exponents, group
|
||||
/// separators and values above <see cref="int.MaxValue"/>.
|
||||
/// </summary>
|
||||
public static bool TryParse(string text, out int quantity)
|
||||
{
|
||||
if (
|
||||
int.TryParse(
|
||||
text,
|
||||
NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite,
|
||||
CultureInfo.CurrentCulture,
|
||||
out quantity
|
||||
)
|
||||
&& quantity >= 1
|
||||
)
|
||||
return true;
|
||||
|
||||
quantity = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace OpenNest.IO.Bom;
|
||||
|
||||
/// <summary>
|
||||
/// The .dxf and .dwg files in one folder, looked up by the name a BOM uses.
|
||||
/// A .dxf wins over a .dwg with the same name.
|
||||
/// </summary>
|
||||
internal sealed class DrawingFileIndex
|
||||
{
|
||||
private readonly Dictionary<string, string> files = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private DrawingFileIndex(string folder)
|
||||
{
|
||||
FolderExists = Directory.Exists(folder);
|
||||
if (!FolderExists)
|
||||
return;
|
||||
|
||||
foreach (var file in Directory.GetFiles(folder, "*.dxf"))
|
||||
files[Path.GetFileNameWithoutExtension(file)] = file;
|
||||
foreach (var file in Directory.GetFiles(folder, "*.dwg"))
|
||||
files.TryAdd(Path.GetFileNameWithoutExtension(file), file);
|
||||
}
|
||||
|
||||
public bool FolderExists { get; }
|
||||
|
||||
public static DrawingFileIndex Load(string folder) => new(folder);
|
||||
|
||||
/// <summary>
|
||||
/// The drawing file for a BOM file name, ignoring case and a .dxf or
|
||||
/// .dwg extension; null when there is none.
|
||||
/// </summary>
|
||||
public string Find(string bomFileName) =>
|
||||
files.TryGetValue(LookupName(bomFileName), out var path) ? path : null;
|
||||
|
||||
/// <summary>The BOM file name without a .dxf or .dwg extension.</summary>
|
||||
public static string LookupName(string fileName)
|
||||
{
|
||||
fileName ??= "";
|
||||
if (
|
||||
fileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|
||||
|| fileName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
return Path.GetFileNameWithoutExtension(fileName);
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using CncProgram = OpenNest.CNC.Program;
|
||||
|
||||
namespace OpenNest.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Production HTTP routes + real SQLite, driven through the desktop app's own
|
||||
/// <see cref="RemoteNestRepository"/> and <see cref="NestSaveSession"/>.
|
||||
/// Every test owns a fresh server and database.
|
||||
/// </summary>
|
||||
public sealed class NestApiTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Host_UsesPrivateDatabase_NotDefaultPath()
|
||||
{
|
||||
var defaultPath = Path.GetFullPath(Path.Combine("data", "nests.db"));
|
||||
Assert.False(File.Exists(defaultPath), "Precondition: no default database in the test directory.");
|
||||
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var response = await client.GetAsync("/healthz");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.True(File.Exists(factory.DatabasePath));
|
||||
Assert.False(File.Exists(defaultPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_WithOpenDatabase_ReturnsExistingSuccessShape()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var response = await client.GetAsync("/healthz");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("{\"status\":\"ok\"}", await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_WithDisposedDatabase_Returns503WithoutStorageDetails()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var client = factory.CreateClient();
|
||||
factory.Services.GetRequiredService<NestDatabase>().Dispose();
|
||||
|
||||
using var response = await client.GetAsync("/healthz");
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Equal("{\"status\":\"unavailable\"}", await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ThenListAndDownload_ReturnsExactRecordAndArchive()
|
||||
{
|
||||
using var server = new TestServerSession();
|
||||
var nest = SyntheticNest();
|
||||
var archive = Archive(nest);
|
||||
var session = new NestSaveSession();
|
||||
|
||||
var created = await session.SaveAsync(server.Repository, server.Url, nest, archive);
|
||||
|
||||
Assert.NotEqual(Guid.Empty, created.Id);
|
||||
Assert.NotEqual(default, created.SavedAt);
|
||||
Assert.Equal(archive.LongLength, created.FileSize);
|
||||
AssertSameMetadata(Expected(nest, created, archive), created);
|
||||
|
||||
var listed = Assert.Single(await server.Repository.ListAsync());
|
||||
AssertSameMetadata(created, listed);
|
||||
AssertSameMetadata(created, (await server.Repository.GetMetadataAsync(created.Id))!);
|
||||
|
||||
var downloaded = await server.Repository.GetFileAsync(created.Id);
|
||||
Assert.Equal(archive, downloaded);
|
||||
AssertArchive(downloaded!, nest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_AfterCreate_UpdatesSameRecordArchiveAndMetadata()
|
||||
{
|
||||
using var server = new TestServerSession();
|
||||
var nest = SyntheticNest();
|
||||
var session = new NestSaveSession();
|
||||
var created = await session.SaveAsync(server.Repository, server.Url, nest, Archive(nest));
|
||||
|
||||
nest.Name = "Synthetic integration nest updated";
|
||||
nest.Status = NestStatus.ToBeCut;
|
||||
nest.Plates[0].Parts[0].Location = new Vector(4, 5);
|
||||
var archive = Archive(nest);
|
||||
var updated = await session.SaveAsync(server.Repository, server.Url, nest, archive);
|
||||
|
||||
Assert.Equal(created.Id, updated.Id);
|
||||
AssertSameMetadata(Expected(nest, updated, archive), updated);
|
||||
var listed = Assert.Single(await server.Repository.ListAsync());
|
||||
AssertSameMetadata(updated, listed);
|
||||
var downloaded = await server.Repository.GetFileAsync(created.Id);
|
||||
Assert.Equal(archive, downloaded);
|
||||
AssertArchive(downloaded!, nest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveCopy_CreatesDistinctRecordAndLeavesOriginal()
|
||||
{
|
||||
using var server = new TestServerSession();
|
||||
var nest = SyntheticNest();
|
||||
var session = new NestSaveSession();
|
||||
var originalArchive = Archive(nest);
|
||||
var original = await session.SaveAsync(server.Repository, server.Url, nest, originalArchive);
|
||||
|
||||
nest.Name = "Synthetic integration nest copy";
|
||||
var copyArchive = Archive(nest);
|
||||
var copy = await session.SaveAsync(server.Repository, server.Url, nest, copyArchive, saveCopy: true);
|
||||
|
||||
Assert.NotEqual(original.Id, copy.Id);
|
||||
Assert.Equal(copy.Id, session.RemoteId);
|
||||
var ids = (await server.Repository.ListAsync()).Select(r => r.Id).Order().ToArray();
|
||||
Assert.Equal(new[] { original.Id, copy.Id }.Order().ToArray(), ids);
|
||||
AssertSameMetadata(original, (await server.Repository.GetMetadataAsync(original.Id))!);
|
||||
Assert.Equal(originalArchive, await server.Repository.GetFileAsync(original.Id));
|
||||
Assert.Equal(copyArchive, await server.Repository.GetFileAsync(copy.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MetadataUpdate_ChangesFieldsWithoutTouchingArchiveOrFileSize()
|
||||
{
|
||||
using var server = new TestServerSession();
|
||||
var nest = SyntheticNest();
|
||||
var archive = Archive(nest);
|
||||
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, archive);
|
||||
|
||||
var change = Clone(created);
|
||||
change.Name = "Renamed by metadata update";
|
||||
change.Status = NestStatus.HasBeenCut;
|
||||
change.Comments = "Metadata-only change";
|
||||
change.FileSize = 1; // Server-computed; must be ignored.
|
||||
var updated = await server.Repository.UpdateMetadataAsync(created.Id, change);
|
||||
|
||||
Assert.Equal(created.Id, updated.Id);
|
||||
Assert.Equal("Renamed by metadata update", updated.Name);
|
||||
Assert.Equal(NestStatus.HasBeenCut, updated.Status);
|
||||
Assert.Equal("Metadata-only change", updated.Comments);
|
||||
Assert.Equal(archive.LongLength, updated.FileSize);
|
||||
AssertSameMetadata(updated, (await server.Repository.GetMetadataAsync(created.Id))!);
|
||||
Assert.Equal(archive, await server.Repository.GetFileAsync(created.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RemovesRecordAndArchive()
|
||||
{
|
||||
using var server = new TestServerSession();
|
||||
var nest = SyntheticNest();
|
||||
var created = await new NestSaveSession().SaveAsync(server.Repository, server.Url, nest, Archive(nest));
|
||||
|
||||
await server.Repository.DeleteAsync(created.Id);
|
||||
|
||||
Assert.Empty(await server.Repository.ListAsync());
|
||||
Assert.Null(await server.Repository.GetMetadataAsync(created.Id));
|
||||
Assert.Null(await server.Repository.GetFileAsync(created.Id));
|
||||
}
|
||||
|
||||
public static TheoryData<string, string> InvalidUploads()
|
||||
{
|
||||
var data = new TheoryData<string, string>();
|
||||
foreach (var route in new[] { "create", "update" })
|
||||
{
|
||||
foreach (var body in new[]
|
||||
{
|
||||
"not multipart", "malformed multipart", "missing boundary", "missing metadata",
|
||||
"invalid metadata JSON", "null metadata", "missing file", "empty file",
|
||||
})
|
||||
{
|
||||
data.Add(route, body);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidUploads))]
|
||||
public async Task Upload_WithMissingOrMalformedParts_Returns400AndLeavesStoreUnchanged(string route, string body)
|
||||
{
|
||||
using var server = new TestServerSession();
|
||||
var existing = await server.SeedAsync();
|
||||
var before = await server.SnapshotAsync();
|
||||
|
||||
var method = route == "create" ? HttpMethod.Post : HttpMethod.Put;
|
||||
var path = route == "create" ? "/api/nests" : $"/api/nests/{existing.Id}/file";
|
||||
using var request = new HttpRequestMessage(method, path) { Content = InvalidUploadContent(body) };
|
||||
using var response = await server.Client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(before, await server.SnapshotAsync());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{not-json")]
|
||||
[InlineData("null")]
|
||||
public async Task MetadataUpdate_WithInvalidJson_Returns400AndLeavesStoreUnchanged(string json)
|
||||
{
|
||||
using var server = new TestServerSession();
|
||||
var existing = await server.SeedAsync();
|
||||
var before = await server.SnapshotAsync();
|
||||
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await server.Client.PutAsync($"/api/nests/{existing.Id}/metadata", content);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal(before, await server.SnapshotAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownId_Returns404OnEveryRouteWithoutCreatingRecords()
|
||||
{
|
||||
using var server = new TestServerSession();
|
||||
var existing = await server.SeedAsync();
|
||||
var before = await server.SnapshotAsync();
|
||||
var unknown = Guid.NewGuid();
|
||||
var record = Clone(existing);
|
||||
record.Id = unknown;
|
||||
|
||||
var requests = new Func<HttpRequestMessage>[]
|
||||
{
|
||||
() => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}"),
|
||||
() => new HttpRequestMessage(HttpMethod.Get, $"/api/nests/{unknown}/file"),
|
||||
() => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/file")
|
||||
{
|
||||
Content = Multipart(JsonSerializer.Serialize(record, JsonOptions), new byte[] { 1, 2, 3 }),
|
||||
},
|
||||
() => new HttpRequestMessage(HttpMethod.Put, $"/api/nests/{unknown}/metadata")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8,
|
||||
"application/json"),
|
||||
},
|
||||
() => new HttpRequestMessage(HttpMethod.Delete, $"/api/nests/{unknown}"),
|
||||
};
|
||||
|
||||
foreach (var create in requests)
|
||||
{
|
||||
using var request = create();
|
||||
using var response = await server.Client.SendAsync(request);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.NotFound,
|
||||
$"{request.Method} {request.RequestUri} returned {(int)response.StatusCode}, expected 404.");
|
||||
}
|
||||
|
||||
Assert.Equal(before, await server.SnapshotAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_WithUnusableDatabasePath_Fails()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
// A regular file where the data directory should be: the database cannot be created.
|
||||
var blocker = Path.Combine(directory, "not-a-directory");
|
||||
File.WriteAllText(blocker, "");
|
||||
using var factory = new ServerFactory(Path.Combine(blocker, "nests.db"));
|
||||
|
||||
var error = Record.Exception(() => factory.CreateClient());
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.False(File.Exists(Path.Combine(blocker, "nests.db")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_RemovesTemporaryDatabaseDirectory()
|
||||
{
|
||||
string directory;
|
||||
using (var server = new TestServerSession())
|
||||
{
|
||||
await server.SeedAsync();
|
||||
directory = server.Factory.DirectoryPath;
|
||||
Assert.True(File.Exists(server.Factory.DatabasePath));
|
||||
}
|
||||
|
||||
Assert.False(Directory.Exists(directory));
|
||||
}
|
||||
|
||||
private static HttpContent InvalidUploadContent(string body)
|
||||
{
|
||||
var metadata = JsonSerializer.Serialize(new NestRecord { Name = "Synthetic rejected upload" }, JsonOptions);
|
||||
switch (body)
|
||||
{
|
||||
case "not multipart":
|
||||
return new StringContent(metadata, Encoding.UTF8, "application/json");
|
||||
case "malformed multipart":
|
||||
var content = new StringContent("--other-boundary\r\nnot a multipart body");
|
||||
content.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data; boundary=expected-boundary");
|
||||
return content;
|
||||
case "missing boundary":
|
||||
var unbounded = Multipart(metadata, new byte[] { 1 });
|
||||
unbounded.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data");
|
||||
return unbounded;
|
||||
case "missing metadata":
|
||||
return Multipart(null, new byte[] { 1 });
|
||||
case "invalid metadata JSON":
|
||||
return Multipart("{not-json", new byte[] { 1 });
|
||||
case "null metadata":
|
||||
return Multipart("null", new byte[] { 1 });
|
||||
case "missing file":
|
||||
return Multipart(metadata, null);
|
||||
case "empty file":
|
||||
return Multipart(metadata, Array.Empty<byte>());
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(body), body, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static MultipartFormDataContent Multipart(string? metadata, byte[]? file)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
if (metadata is not null)
|
||||
content.Add(new StringContent(metadata, Encoding.UTF8, "application/json"), "metadata");
|
||||
if (file is not null)
|
||||
content.Add(new ByteArrayContent(file), "file", "synthetic.nest");
|
||||
return content;
|
||||
}
|
||||
|
||||
private static NestRecord Expected(Nest nest, NestRecord saved, byte[] archive)
|
||||
{
|
||||
var expected = NestRecordFactory.FromNest(nest, saved.Id, archive.LongLength);
|
||||
expected.SavedAt = saved.SavedAt;
|
||||
return expected;
|
||||
}
|
||||
|
||||
private static NestRecord Clone(NestRecord record) =>
|
||||
JsonSerializer.Deserialize<NestRecord>(JsonSerializer.Serialize(record, JsonOptions), JsonOptions)!;
|
||||
|
||||
private static void AssertSameMetadata(NestRecord expected, NestRecord actual) =>
|
||||
Assert.Equal(JsonSerializer.Serialize(expected, JsonOptions), JsonSerializer.Serialize(actual, JsonOptions));
|
||||
|
||||
private static void AssertArchive(byte[] archive, Nest expected)
|
||||
{
|
||||
using var stream = new MemoryStream(archive, writable: false);
|
||||
var reader = new NestReader(stream);
|
||||
var nest = reader.Read();
|
||||
|
||||
Assert.Empty(reader.Warnings);
|
||||
Assert.Equal(expected.Name, nest.Name);
|
||||
Assert.Equal(expected.Status, nest.Status);
|
||||
var part = Assert.Single(Assert.Single(nest.Plates).Parts);
|
||||
Assert.Equal(expected.Plates[0].Parts[0].Location.X, part.Location.X);
|
||||
Assert.Equal(expected.Plates[0].Parts[0].Location.Y, part.Location.Y);
|
||||
Assert.Equal(18, Assert.Single(nest.Drawings).Area);
|
||||
}
|
||||
|
||||
internal static Nest SyntheticNest()
|
||||
{
|
||||
var program = new CncProgram();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(6, 0);
|
||||
program.LineTo(6, 3);
|
||||
program.LineTo(0, 3);
|
||||
program.LineTo(0, 0);
|
||||
var drawing = new Drawing("Synthetic rectangle", program);
|
||||
drawing.Quantity.Required = 1;
|
||||
var nest = new Nest("Synthetic integration nest")
|
||||
{
|
||||
Customer = "Synthetic test customer",
|
||||
DateCreated = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Unspecified),
|
||||
Material = new Material("Synthetic alloy", "test-grade", 0.25),
|
||||
Thickness = 0.25,
|
||||
Status = NestStatus.Quote,
|
||||
Notes = "Neutral generated fixture; no customer data",
|
||||
MadeBy = "Synthetic test author",
|
||||
Units = Units.Inches,
|
||||
};
|
||||
nest.Drawings.Add(drawing);
|
||||
var plate = new Plate(new Size(20, 30)) { Quantity = 1 };
|
||||
plate.Parts.Add(new Part(drawing, new Vector(2, 3)));
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
internal static byte[] Archive(Nest nest)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
Assert.True(new NestWriter(nest).Write(stream));
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>One in-memory server, its HTTP client, and the real storage client over it.</summary>
|
||||
private sealed class TestServerSession : IDisposable
|
||||
{
|
||||
public TestServerSession()
|
||||
{
|
||||
Factory = new ServerFactory();
|
||||
Client = Factory.CreateClient();
|
||||
Url = Client.BaseAddress!.ToString();
|
||||
Repository = new RemoteNestRepository(Client, Url);
|
||||
}
|
||||
|
||||
public ServerFactory Factory { get; }
|
||||
public HttpClient Client { get; }
|
||||
public string Url { get; }
|
||||
public RemoteNestRepository Repository { get; }
|
||||
|
||||
public async Task<NestRecord> SeedAsync()
|
||||
{
|
||||
var nest = SyntheticNest();
|
||||
return await new NestSaveSession().SaveAsync(Repository, Url, nest, Archive(nest));
|
||||
}
|
||||
|
||||
/// <summary>Every record's metadata plus its archive hash, in id order.</summary>
|
||||
public async Task<string> SnapshotAsync()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var record in (await Repository.ListAsync()).OrderBy(r => r.Id))
|
||||
{
|
||||
var file = await Repository.GetFileAsync(record.Id);
|
||||
Assert.NotNull(file);
|
||||
lines.Add(JsonSerializer.Serialize(record, JsonOptions) + " " + Convert.ToHexString(SHA256.HashData(file!)));
|
||||
}
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Repository.Dispose();
|
||||
Client.Dispose();
|
||||
Factory.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Server.Tests;
|
||||
|
||||
public sealed class NestDatabaseConcurrencyTests
|
||||
{
|
||||
// Code-level ownership gate: hold the database's monitor and observe a dedicated
|
||||
// synchronous caller waiting on it, not a sleep or a stress-run timing assumption.
|
||||
[Theory]
|
||||
[InlineData("List")]
|
||||
[InlineData("Query")]
|
||||
[InlineData("Get")]
|
||||
[InlineData("GetFile")]
|
||||
[InlineData("Insert")]
|
||||
[InlineData("UpdateFile")]
|
||||
[InlineData("UpdateMetadata")]
|
||||
[InlineData("Delete")]
|
||||
[InlineData("Health")]
|
||||
[InlineData("Dispose")]
|
||||
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath);
|
||||
var id = Guid.NewGuid();
|
||||
var record = new NestRecord { Name = "Monitor ownership fixture" };
|
||||
database.Insert(id, record, new byte[] { 1, 2, 3 });
|
||||
var field = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(field);
|
||||
var sync = field.GetValue(database)!;
|
||||
Assert.NotNull(sync);
|
||||
var completed = new TaskCompletionSource<Exception?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
completed.SetResult(Record.Exception(() =>
|
||||
{
|
||||
switch (operation)
|
||||
{
|
||||
case "List": database.List(); break;
|
||||
case "Query": database.Query(new NestQuery { Search = "fixture" }); break;
|
||||
case "Get": database.Get(id); break;
|
||||
case "GetFile": database.GetFile(id); break;
|
||||
case "Insert": database.Insert(Guid.NewGuid(), record, new byte[] { 4 }); break;
|
||||
case "UpdateFile": database.Update(id, record, new byte[] { 5 }); break;
|
||||
case "UpdateMetadata": database.Update(id, record, null); break;
|
||||
case "Delete": database.Delete(id); break;
|
||||
case "Health": Assert.True(database.IsHealthy()); break;
|
||||
case "Dispose": database.Dispose(); break;
|
||||
default: throw new InvalidOperationException($"Unknown test operation: {operation}");
|
||||
}
|
||||
}));
|
||||
})
|
||||
{ IsBackground = true };
|
||||
|
||||
var observedWaiting = false;
|
||||
var completedWhileHeld = false;
|
||||
lock (sync)
|
||||
{
|
||||
worker.Start();
|
||||
observedWaiting = SpinWait.SpinUntil(() => completed.Task.IsCompleted ||
|
||||
(worker.ThreadState & ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(10));
|
||||
completedWhileHeld = completed.Task.IsCompleted;
|
||||
}
|
||||
|
||||
Assert.True(worker.Join(TimeSpan.FromSeconds(10)), "Operation did not finish after releasing the monitor.");
|
||||
Assert.True(observedWaiting, "Caller never reached the operation within the watchdog.");
|
||||
Assert.False(completedWhileHeld, operation + " bypassed the shared monitor.");
|
||||
Assert.Null(await completed.Task);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concurrency_AllSqlStepsIncludingReadbacks_OwnTheMonitor()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath);
|
||||
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||
.GetValue(database)!;
|
||||
var connection = (SqliteConnection)typeof(NestDatabase)
|
||||
.GetField("_connection", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(database)!;
|
||||
var steps = 0;
|
||||
var unownedSteps = 0;
|
||||
// Observe every native VM step, including iteration of readers and nested
|
||||
// write readbacks. No assertion/exception is thrown across the native callback.
|
||||
SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 1, _ =>
|
||||
{
|
||||
steps++;
|
||||
if (!Monitor.IsEntered(sync))
|
||||
unownedSteps++;
|
||||
return 0;
|
||||
}, null);
|
||||
try
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var record = Fixture(0, 0);
|
||||
Action[] operations =
|
||||
[
|
||||
() => database.Insert(id, record, new byte[] { 1 }),
|
||||
() => database.List(),
|
||||
() => database.Query(new NestQuery { Search = "Synthetic", Limit = 1 }),
|
||||
() => database.Get(id),
|
||||
() => database.GetFile(id),
|
||||
() => database.Update(id, record, new byte[] { 2 }),
|
||||
() => database.Update(id, record, null),
|
||||
() => Assert.True(database.IsHealthy()),
|
||||
() => database.Delete(id),
|
||||
];
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
var before = steps;
|
||||
operation();
|
||||
Assert.True(steps > before, "Operation did not execute the observed SQLite VM.");
|
||||
Assert.Equal(0, unownedSteps);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 0, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrency_DatabaseReadersAndWriters_PreserveExactRecords()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var database = new NestDatabase(factory.DatabasePath);
|
||||
using var start = new Barrier(4);
|
||||
var workers = Enumerable.Range(0, 4).Select(lane => Task.Factory.StartNew(() =>
|
||||
{
|
||||
Assert.True(start.SignalAndWait(TimeSpan.FromSeconds(10)));
|
||||
var records = new List<(NestRecord Record, byte[] File)>();
|
||||
for (var index = 0; index < 12; index++)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var record = Fixture(lane, index);
|
||||
var file = new byte[] { (byte)lane, (byte)index, 0, 255 };
|
||||
var saved = database.Insert(id, record, file);
|
||||
AssertStoredRecord(record, id, file, saved);
|
||||
AssertRecord(saved, database.Get(id)!);
|
||||
Assert.Contains(database.List(), r => r.Id == id);
|
||||
Assert.Equal(file, database.GetFile(id));
|
||||
record.Comments = "Archive updated";
|
||||
file = file.Concat(new byte[] { 42 }).ToArray();
|
||||
saved = database.Update(id, record, file)!;
|
||||
AssertStoredRecord(record, id, file, saved);
|
||||
record.Comments = "Metadata only";
|
||||
saved = database.Update(id, record, null)!;
|
||||
AssertStoredRecord(record, id, file, saved);
|
||||
Assert.Equal(file.LongLength, saved.FileSize);
|
||||
Assert.Equal(file, database.GetFile(id));
|
||||
var transient = Guid.NewGuid();
|
||||
database.Insert(transient, record, new byte[] { 9 });
|
||||
Assert.True(database.Delete(transient));
|
||||
Assert.Null(database.Get(transient));
|
||||
records.Add((saved, file));
|
||||
}
|
||||
return records;
|
||||
}, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)).ToArray();
|
||||
|
||||
var expected = (await Task.WhenAll(workers).WaitAsync(TimeSpan.FromSeconds(60))).SelectMany(r => r).ToArray();
|
||||
Assert.Equal(expected.Select(r => r.Record.Id).Order(), database.List().Select(r => r.Id).Order());
|
||||
foreach (var (record, file) in expected)
|
||||
{
|
||||
AssertRecord(record, database.Get(record.Id)!);
|
||||
Assert.Equal(file, database.GetFile(record.Id));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrency_MultipleHttpClients_PreserveMetadataAndSyntheticArchives()
|
||||
{
|
||||
using var factory = new ServerFactory();
|
||||
using var watchdog = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var workers = Enumerable.Range(0, 8).Select(async lane =>
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
using var repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
|
||||
await start.Task;
|
||||
var records = new List<(NestRecord Record, byte[] File)>();
|
||||
for (var index = 0; index < 4; index++)
|
||||
{
|
||||
var nest = NestApiTests.SyntheticNest();
|
||||
nest.Name = $"Synthetic concurrent nest {lane}-{index}";
|
||||
var session = new NestSaveSession();
|
||||
var file = NestApiTests.Archive(nest);
|
||||
var saved = await session.SaveAsync(repository, client.BaseAddress.ToString(), nest, file,
|
||||
cancellationToken: watchdog.Token);
|
||||
AssertStoredRecord(NestRecordFactory.FromNest(nest, saved.Id, file.LongLength), saved.Id, file, saved);
|
||||
AssertRecord(saved, (await repository.GetMetadataAsync(saved.Id, watchdog.Token))!);
|
||||
Assert.Contains(await repository.ListAsync(watchdog.Token), r => r.Id == saved.Id);
|
||||
nest.Notes = $"Archive change {lane}-{index}";
|
||||
file = NestApiTests.Archive(nest);
|
||||
var updated = await session.SaveAsync(repository, client.BaseAddress.ToString(), nest, file,
|
||||
cancellationToken: watchdog.Token);
|
||||
AssertStoredRecord(NestRecordFactory.FromNest(nest, saved.Id, file.LongLength), saved.Id, file, updated);
|
||||
updated.Comments = $"Metadata only {lane}-{index}";
|
||||
saved = await repository.UpdateMetadataAsync(updated.Id, updated, watchdog.Token);
|
||||
AssertStoredRecord(updated, updated.Id, file, saved);
|
||||
Assert.Equal(file.LongLength, saved.FileSize);
|
||||
Assert.Equal(file, await repository.GetFileAsync(saved.Id, watchdog.Token));
|
||||
using var health = await client.GetAsync("/healthz", watchdog.Token);
|
||||
Assert.Equal(HttpStatusCode.OK, health.StatusCode);
|
||||
records.Add((saved, file));
|
||||
}
|
||||
return records;
|
||||
}).ToArray();
|
||||
start.SetResult();
|
||||
|
||||
var expected = (await Task.WhenAll(workers).WaitAsync(TimeSpan.FromSeconds(70))).SelectMany(r => r).ToArray();
|
||||
using var verifierClient = factory.CreateClient();
|
||||
using var verifier = new RemoteNestRepository(verifierClient, verifierClient.BaseAddress!.ToString());
|
||||
var listed = await verifier.ListAsync(watchdog.Token);
|
||||
Assert.Equal(expected.Select(r => r.Record.Id).Order(), listed.Select(r => r.Id).Order());
|
||||
foreach (var (record, file) in expected)
|
||||
{
|
||||
AssertRecord(record, listed.Single(r => r.Id == record.Id));
|
||||
AssertRecord(record, (await verifier.GetMetadataAsync(record.Id, watchdog.Token))!);
|
||||
Assert.Equal(file, await verifier.GetFileAsync(record.Id, watchdog.Token));
|
||||
}
|
||||
}
|
||||
|
||||
private static NestRecord Fixture(int lane, int index) => new()
|
||||
{
|
||||
Name = $"Synthetic database nest {lane}-{index}",
|
||||
Customer = "Synthetic customer",
|
||||
DateCreated = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
DateModified = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Unspecified),
|
||||
Material = "Synthetic alloy",
|
||||
Thickness = 0.25,
|
||||
Status = NestStatus.ToBeCut,
|
||||
PlateCount = lane + 1,
|
||||
PartCount = index + 1,
|
||||
Comments = "Initial archive",
|
||||
MadeBy = "Synthetic author",
|
||||
};
|
||||
|
||||
private static void AssertStoredRecord(NestRecord metadata, Guid id, byte[] file, NestRecord actual)
|
||||
{
|
||||
var expected = JsonSerializer.Deserialize<NestRecord>(JsonSerializer.Serialize(metadata))!;
|
||||
expected.Id = id;
|
||||
expected.FileSize = file.LongLength;
|
||||
expected.SavedAt = actual.SavedAt;
|
||||
Assert.NotEqual(default, actual.SavedAt);
|
||||
AssertRecord(expected, actual);
|
||||
}
|
||||
|
||||
private static void AssertRecord(NestRecord expected, NestRecord actual) =>
|
||||
Assert.Equal(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(actual));
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded, SQL-filtered browsing (<c>GET /api/nests/query</c>) through the production
|
||||
/// routes, real SQLite and the desktop's <see cref="RemoteNestRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class NestQueryTests : IDisposable
|
||||
{
|
||||
private readonly ServerFactory _factory = new();
|
||||
private readonly HttpClient _client;
|
||||
private readonly RemoteNestRepository _repository;
|
||||
private readonly NestDatabase _database;
|
||||
|
||||
public NestQueryTests()
|
||||
{
|
||||
_client = _factory.CreateClient();
|
||||
_repository = new RemoteNestRepository(_client, _client.BaseAddress!.ToString());
|
||||
_database = _factory.Services.GetRequiredService<NestDatabase>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithSelectiveSearch_ReturnsOnlyMatchesAndTheirTotal()
|
||||
{
|
||||
var seeded = Seed(24);
|
||||
var expected = Ordered(seeded.Where(r => r.Customer == "Beta Works"));
|
||||
|
||||
var page = await _repository.QueryAsync(new NestQuery { Search = " BETA ", Limit = NestQuery.MaxLimit });
|
||||
|
||||
Assert.Equal(expected.Length, page.Total);
|
||||
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
|
||||
Assert.All(page.Items, r => AssertSameMetadata(seeded.Single(s => s.Id == r.Id), r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithSelectiveSearch_DoesNotTransferUnmatchedRows()
|
||||
{
|
||||
Seed(24);
|
||||
|
||||
var body = await _client.GetStringAsync("/api/nests/query?search=beta&limit=500");
|
||||
|
||||
Assert.Contains("Beta Works", body);
|
||||
Assert.DoesNotContain("Alpha Fabrication", body);
|
||||
Assert.DoesNotContain("Gamma 50% Shop", body);
|
||||
Assert.DoesNotContain("Delta_Under", body);
|
||||
Assert.DoesNotContain("\"file\"", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_WithNoMatch_ReturnsEmptyPageAndZeroTotal()
|
||||
{
|
||||
Seed(10);
|
||||
|
||||
var page = await _repository.QueryAsync(new NestQuery { Search = "no such synthetic text" });
|
||||
|
||||
Assert.Empty(page.Items);
|
||||
Assert.Equal(0, page.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_Pages_PartitionTheOrderedSetExactly()
|
||||
{
|
||||
var expected = Ordered(Seed(25)).Select(r => r.Id).ToArray();
|
||||
var collected = new List<Guid>();
|
||||
|
||||
for (var offset = 0; offset < expected.Length; offset += 7)
|
||||
{
|
||||
var page = await _repository.QueryAsync(new NestQuery { Offset = offset, Limit = 7 });
|
||||
Assert.Equal(25, page.Total);
|
||||
Assert.Equal(offset, page.Offset);
|
||||
Assert.Equal(7, page.Limit);
|
||||
Assert.Equal(System.Math.Min(7, expected.Length - offset), page.Items.Count);
|
||||
collected.AddRange(page.Items.Select(r => r.Id));
|
||||
}
|
||||
|
||||
Assert.Equal(expected, collected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(25)]
|
||||
[InlineData(1000)]
|
||||
[InlineData(int.MaxValue)]
|
||||
public async Task Query_OffsetPastTheEnd_ReturnsNoItemsWithTheTrueTotal(int offset)
|
||||
{
|
||||
Seed(25);
|
||||
|
||||
var page = await _repository.QueryAsync(new NestQuery { Offset = offset });
|
||||
|
||||
Assert.Empty(page.Items);
|
||||
Assert.Equal(25, page.Total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_DefaultLimit_BoundsAnUnfilteredRequest()
|
||||
{
|
||||
Seed(NestQuery.DefaultLimit + 5);
|
||||
|
||||
var page = await _repository.QueryAsync(new NestQuery());
|
||||
var raw = JsonDocument.Parse(await _client.GetStringAsync("/api/nests/query"));
|
||||
|
||||
Assert.Equal(NestQuery.DefaultLimit, page.Items.Count);
|
||||
Assert.Equal(NestQuery.DefaultLimit + 5, page.Total);
|
||||
Assert.Equal(NestQuery.DefaultLimit, raw.RootElement.GetProperty("items").GetArrayLength());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task Query_BlankSearch_EqualsTheUnfilteredOrder(string search)
|
||||
{
|
||||
var expected = Ordered(Seed(12)).Select(r => r.Id);
|
||||
|
||||
var page = await _repository.QueryAsync(new NestQuery { Search = search });
|
||||
|
||||
Assert.Equal(expected, page.Items.Select(r => r.Id));
|
||||
Assert.Equal(12, page.Total);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0%", "Gamma 50% Shop")]
|
||||
[InlineData("_", "Delta_Under")]
|
||||
[InlineData(@"\", @"Path C:\jobs")]
|
||||
public async Task Query_TreatsLikeWildcardsAndEscapeAsLiteralText(string search, string expectedText)
|
||||
{
|
||||
var seeded = Seed(24);
|
||||
var expected = Ordered(seeded.Where(r => r.Customer == expectedText || r.Comments == expectedText));
|
||||
Assert.NotEmpty(expected);
|
||||
Assert.True(expected.Length < seeded.Count);
|
||||
|
||||
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
|
||||
|
||||
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
|
||||
Assert.Equal(expected.Length, page.Total);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("to be cut", new[] { NestStatus.ToBeCut })]
|
||||
[InlineData("HAS BEEN", new[] { NestStatus.HasBeenCut })]
|
||||
[InlineData("tobecut", new[] { NestStatus.ToBeCut })]
|
||||
[InlineData("cut", new[] { NestStatus.ToBeCut, NestStatus.HasBeenCut })]
|
||||
[InlineData("quote", new[] { NestStatus.Quote })]
|
||||
public async Task Query_MatchesStoredAndDisplayedStatusNames(string search, NestStatus[] statuses)
|
||||
{
|
||||
var seeded = Seed(24);
|
||||
var expected = Ordered(seeded.Where(r => statuses.Contains(r.Status)));
|
||||
|
||||
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
|
||||
|
||||
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("material", "Synthetic steel")]
|
||||
[InlineData("madeBy", "Synthetic operator B")]
|
||||
[InlineData("comments", "rush")]
|
||||
[InlineData("name", "browse nest 07")]
|
||||
public async Task Query_MatchesEachTextColumn(string column, string search)
|
||||
{
|
||||
var seeded = Seed(24);
|
||||
Func<NestRecord, string> value = column switch
|
||||
{
|
||||
"material" => r => r.Material,
|
||||
"madeBy" => r => r.MadeBy,
|
||||
"comments" => r => r.Comments,
|
||||
_ => r => r.Name,
|
||||
};
|
||||
var expected = Ordered(seeded.Where(r => value(r).Contains(search, StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.NotEmpty(expected);
|
||||
Assert.True(expected.Length < seeded.Count);
|
||||
|
||||
var page = await _repository.QueryAsync(new NestQuery { Search = search, Limit = NestQuery.MaxLimit });
|
||||
|
||||
Assert.Equal(expected.Select(r => r.Id), page.Items.Select(r => r.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_DoesNotMatchDatesOrNumbers()
|
||||
{
|
||||
Seed(12);
|
||||
|
||||
var thickness = await _repository.QueryAsync(new NestQuery { Search = "0.25" });
|
||||
var date = await _repository.QueryAsync(new NestQuery { Search = "2026-01" });
|
||||
|
||||
Assert.Equal(0, thickness.Total);
|
||||
Assert.Equal(0, date.Total);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("limit=0")]
|
||||
[InlineData("limit=501")]
|
||||
[InlineData("limit=abc")]
|
||||
[InlineData("limit=")]
|
||||
[InlineData("limit=-1")]
|
||||
[InlineData("limit=1.5")]
|
||||
[InlineData("offset=-1")]
|
||||
[InlineData("offset=99999999999")]
|
||||
[InlineData("limit=1&limit=2")]
|
||||
[InlineData("serach=beta")]
|
||||
[InlineData("status=quote")]
|
||||
[InlineData("search=Alpha%00not-present")]
|
||||
[InlineData("search=%00")]
|
||||
public async Task Query_InvalidParameters_Return400WithoutItems(string queryString)
|
||||
{
|
||||
Seed(3);
|
||||
|
||||
using var response = await _client.GetAsync("/api/nests/query?" + queryString);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.DoesNotContain("items", body);
|
||||
Assert.DoesNotContain("Synthetic browse nest", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_OverLongSearch_Returns400()
|
||||
{
|
||||
Seed(3);
|
||||
var search = new string('x', NestQuery.MaxSearchLength + 1);
|
||||
|
||||
using var response = await _client.GetAsync("/api/nests/query?search=" + search);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
public static TheoryData<NestSortField, bool> SortCases()
|
||||
{
|
||||
var data = new TheoryData<NestSortField, bool>();
|
||||
foreach (var field in Enum.GetValues<NestSortField>())
|
||||
{
|
||||
data.Add(field, false);
|
||||
data.Add(field, true);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SortCases))]
|
||||
public async Task Query_SortsEachAllowlistedColumnAcrossPages(NestSortField field, bool descending)
|
||||
{
|
||||
var expected = Sorted(Seed(25), field, descending).Select(r => r.Id).ToArray();
|
||||
var collected = new List<Guid>();
|
||||
|
||||
for (var offset = 0; offset < expected.Length; offset += 6)
|
||||
{
|
||||
var page = await _repository.QueryAsync(
|
||||
new NestQuery { Sort = field, Descending = descending, Offset = offset, Limit = 6 });
|
||||
collected.AddRange(page.Items.Select(r => r.Id));
|
||||
}
|
||||
|
||||
Assert.Equal(expected, collected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_SortAndOrderNamesAreCaseInsensitive()
|
||||
{
|
||||
var expected = Sorted(Seed(12), NestSortField.PlateCount, descending: false).Select(r => r.Id);
|
||||
|
||||
var body = await _client.GetStringAsync("/api/nests/query?sort=PLATECOUNT&order=ASC");
|
||||
var ids = JsonDocument.Parse(body).RootElement.GetProperty("items").EnumerateArray()
|
||||
.Select(item => item.GetProperty("id").GetGuid());
|
||||
|
||||
Assert.Equal(expected, ids);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("sort=unknown")]
|
||||
[InlineData("sort=1")]
|
||||
[InlineData("sort=-1")]
|
||||
[InlineData("sort=")]
|
||||
[InlineData("sort=saved_at")]
|
||||
[InlineData("sort=savedAt&sort=name")]
|
||||
[InlineData("order=up")]
|
||||
[InlineData("order=")]
|
||||
[InlineData("order=asc&order=desc")]
|
||||
public async Task Query_InvalidSortOrOrder_Returns400WithoutItems(string queryString)
|
||||
{
|
||||
Seed(3);
|
||||
|
||||
using var response = await _client.GetAsync("/api/nests/query?" + queryString);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.DoesNotContain("items", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_RemainsTheUnboundedFullEnumeration()
|
||||
{
|
||||
var seeded = Seed(NestQuery.DefaultLimit + 5);
|
||||
|
||||
var listed = await _repository.ListAsync();
|
||||
|
||||
Assert.Equal(seeded.Select(r => r.Id).Order(), listed.Select(r => r.Id).Order());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_repository.Dispose();
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Synthetic records with rotating customers/statuses; returns the stored rows.</summary>
|
||||
private List<NestRecord> Seed(int count)
|
||||
{
|
||||
string[] customers = { "Alpha Fabrication", "Beta Works", "Gamma 50% Shop", "Delta_Under" };
|
||||
var statuses = Enum.GetValues<NestStatus>();
|
||||
var stored = new List<NestRecord>();
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var record = new NestRecord
|
||||
{
|
||||
// Alternate case so text sorts must fold case rather than order by code point.
|
||||
Name = (index % 2 == 0 ? "Synthetic" : "synthetic") + $" browse nest {index:D2}",
|
||||
Customer = customers[index % customers.Length],
|
||||
DateCreated = new DateTime(2026, 1, 1 + index % 28, 8, 0, 0, DateTimeKind.Unspecified),
|
||||
DateModified = new DateTime(2026, 1, 2 + index % 27, 9, 0, 0, DateTimeKind.Unspecified),
|
||||
Material = index % 3 == 0 ? "Synthetic steel" : "Synthetic alloy",
|
||||
Thickness = 0.25 + index % 3 * 0.125,
|
||||
Status = statuses[index % statuses.Length],
|
||||
PlateCount = 1 + index % 4,
|
||||
PartCount = 3 + index,
|
||||
Comments = index % 5 == 0 ? "rush" : index % 7 == 1 ? @"Path C:\jobs" : "",
|
||||
MadeBy = index % 2 == 0 ? "Synthetic operator A" : "Synthetic operator B",
|
||||
};
|
||||
stored.Add(_database.Insert(Guid.NewGuid(), record, new byte[3 + index % 4]));
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/// <summary>Independent oracle for the browse order: newest saved first, then id.</summary>
|
||||
private static NestRecord[] Ordered(IEnumerable<NestRecord> records) =>
|
||||
records
|
||||
.OrderByDescending(r => r.SavedAt)
|
||||
.ThenByDescending(r => r.Id.ToString(), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>Independent oracle for allowlisted sorts: ASCII case-folded text, then id.</summary>
|
||||
private static IEnumerable<NestRecord> Sorted(IEnumerable<NestRecord> records, NestSortField field, bool descending)
|
||||
{
|
||||
static Comparison<NestRecord> Text(Func<NestRecord, string> value) =>
|
||||
(a, b) => string.CompareOrdinal(value(a).ToLowerInvariant(), value(b).ToLowerInvariant());
|
||||
|
||||
Comparison<NestRecord> compare = field switch
|
||||
{
|
||||
NestSortField.SavedAt => (a, b) => a.SavedAt.CompareTo(b.SavedAt),
|
||||
NestSortField.Name => Text(r => r.Name),
|
||||
NestSortField.Customer => Text(r => r.Customer),
|
||||
NestSortField.Status => Text(r => r.Status.ToString()),
|
||||
NestSortField.Material => Text(r => r.Material),
|
||||
NestSortField.DateCreated => (a, b) => a.DateCreated.CompareTo(b.DateCreated),
|
||||
NestSortField.DateModified => (a, b) => a.DateModified.CompareTo(b.DateModified),
|
||||
NestSortField.Thickness => (a, b) => a.Thickness.CompareTo(b.Thickness),
|
||||
NestSortField.PlateCount => (a, b) => a.PlateCount.CompareTo(b.PlateCount),
|
||||
NestSortField.PartCount => (a, b) => a.PartCount.CompareTo(b.PartCount),
|
||||
NestSortField.MadeBy => Text(r => r.MadeBy),
|
||||
NestSortField.Comments => Text(r => r.Comments),
|
||||
NestSortField.FileSize => (a, b) => a.FileSize.CompareTo(b.FileSize),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field)),
|
||||
};
|
||||
var sorted = records.ToList();
|
||||
sorted.Sort((a, b) =>
|
||||
{
|
||||
var result = compare(a, b);
|
||||
if (result == 0)
|
||||
result = string.CompareOrdinal(a.Id.ToString(), b.Id.ToString());
|
||||
return descending ? -result : result;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private static void AssertSameMetadata(NestRecord expected, NestRecord actual) =>
|
||||
Assert.Equal(JsonSerializer.Serialize(expected), JsonSerializer.Serialize(actual));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Server\OpenNest.Server.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
|
||||
<!-- IO only writes/reads synthetic .nest archives in tests; the server never references it. -->
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace OpenNest.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts the production routes in memory against a private, disposable SQLite file.
|
||||
/// The production <see cref="NestDatabase"/> registration is replaced before anything
|
||||
/// resolves it, so tests never open the default data/nests.db or an OPENNEST_DB path.
|
||||
/// Disposing the factory disposes the host (and its database) before deleting the directory.
|
||||
/// </summary>
|
||||
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||
{
|
||||
private bool _cleanedUp;
|
||||
|
||||
public ServerFactory(string? databasePath = null)
|
||||
{
|
||||
DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(DirectoryPath);
|
||||
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
|
||||
}
|
||||
|
||||
/// <summary>Private temporary directory owned by this factory.</summary>
|
||||
public string DirectoryPath { get; }
|
||||
|
||||
public string DatabasePath { get; }
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<NestDatabase>();
|
||||
services.AddSingleton(_ => new NestDatabase(DatabasePath));
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (!disposing || _cleanedUp)
|
||||
return;
|
||||
|
||||
_cleanedUp = true;
|
||||
// Pooled connections would keep the file open on Windows after the host disposed it.
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(DirectoryPath))
|
||||
Directory.Delete(DirectoryPath, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,58 @@
|
||||
# Build from the repository root: docker build -f OpenNest.Server/Dockerfile -t opennest-server .
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
# Release builds pass --build-arg VERSION=X.Y.Z and SOURCE_REVISION=<full commit SHA>;
|
||||
# the defaults mark an unversioned development build. Base images may be pinned by digest.
|
||||
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:8.0
|
||||
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
|
||||
FROM ${SDK_IMAGE} AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY OpenNest.Server/OpenNest.Server.csproj OpenNest.Server/
|
||||
COPY OpenNest.Data/OpenNest.Data.csproj OpenNest.Data/
|
||||
COPY OpenNest.Core/OpenNest.Core.csproj OpenNest.Core/
|
||||
RUN dotnet restore OpenNest.Server/OpenNest.Server.csproj
|
||||
|
||||
COPY OpenNest.Server/ OpenNest.Server/
|
||||
COPY OpenNest.Data/ OpenNest.Data/
|
||||
RUN dotnet publish OpenNest.Server/OpenNest.Server.csproj -c Release -o /app --no-restore
|
||||
COPY OpenNest.Core/ OpenNest.Core/
|
||||
ARG VERSION=0.0.0-dev
|
||||
ARG SOURCE_REVISION=unknown
|
||||
RUN dotnet publish OpenNest.Server/OpenNest.Server.csproj -c Release -o /app --no-restore \
|
||||
-p:Version="$VERSION" -p:SourceRevisionId="$SOURCE_REVISION"
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
FROM ${RUNTIME_IMAGE} AS runtime
|
||||
# curl is installed only for the HEALTHCHECK below.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# Runtime files stay root-owned and read-only to the app user.
|
||||
COPY --from=build /app .
|
||||
|
||||
# SQLite database + .nest blobs live here; mount a volume at this path.
|
||||
VOLUME /app/data
|
||||
# SQLite database + .nest blobs live here; mount a volume at this path. The directory
|
||||
# belongs to the base image's non-root app user (APP_UID 1654), so a fresh named volume
|
||||
# inherits that ownership. Existing root-owned data needs a one-time scoped chown.
|
||||
RUN mkdir -p /app/data && chown "$APP_UID:$APP_UID" /app/data && chmod 0750 /app/data
|
||||
ENV OPENNEST_DB=/app/data/nests.db
|
||||
# Listen on the explicit 8090 URL; clear the base image's 8080 port default.
|
||||
ENV ASPNETCORE_URLS=http://+:8090
|
||||
ENV ASPNETCORE_HTTP_PORTS=
|
||||
EXPOSE 8090
|
||||
USER $APP_UID
|
||||
VOLUME /app/data
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=2s --retries=3 \
|
||||
CMD ["curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8090/healthz"]
|
||||
|
||||
ARG VERSION=0.0.0-dev
|
||||
ARG SOURCE_REVISION=unknown
|
||||
ARG RUNTIME_IMAGE
|
||||
LABEL org.opencontainers.image.title="OpenNest.Server" \
|
||||
org.opencontainers.image.description="OpenNest nest-storage HTTP service (SQLite)" \
|
||||
org.opencontainers.image.source="https://github.com/ajisaacs/OpenNest" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.version="$VERSION" \
|
||||
org.opencontainers.image.revision="$SOURCE_REVISION" \
|
||||
org.opencontainers.image.base.name="$RUNTIME_IMAGE"
|
||||
|
||||
ENTRYPOINT ["dotnet", "OpenNest.Server.dll"]
|
||||
@@ -11,6 +11,9 @@ namespace OpenNest.Server;
|
||||
/// </summary>
|
||||
public sealed class NestDatabase : IDisposable
|
||||
{
|
||||
// One reentrant monitor owns each complete connection operation, including
|
||||
// readers, nested Get readbacks, health checks, and disposal.
|
||||
private readonly object _sync = new();
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public NestDatabase(string databasePath)
|
||||
@@ -45,6 +48,8 @@ public sealed class NestDatabase : IDisposable
|
||||
}
|
||||
|
||||
public IReadOnlyList<NestRecord> List()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText =
|
||||
@@ -55,8 +60,11 @@ public sealed class NestDatabase : IDisposable
|
||||
records.Add(ReadRecord(reader));
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
public NestRecord? Get(Guid id)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id";
|
||||
@@ -64,8 +72,50 @@ public sealed class NestDatabase : IDisposable
|
||||
using var reader = command.ExecuteReader();
|
||||
return reader.Read() ? ReadRecord(reader) : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One bounded page of matching metadata plus the total match count. The count and
|
||||
/// the page are read in the same monitor hold, so they describe one state.
|
||||
/// </summary>
|
||||
public NestPage Query(NestQuery query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
var error = query.GetValidationError();
|
||||
if (error is not null)
|
||||
throw new ArgumentException(error, nameof(query));
|
||||
|
||||
var search = query.NormalizedSearch;
|
||||
var where = search.Length == 0 ? "" : SearchPredicate;
|
||||
lock (_sync)
|
||||
{
|
||||
using var count = _connection.CreateCommand();
|
||||
count.CommandText = $"SELECT COUNT(*) FROM nests {where}";
|
||||
AddSearchParameter(count, search);
|
||||
var total = checked((int)(long)count.ExecuteScalar()!);
|
||||
|
||||
using var command = _connection.CreateCommand();
|
||||
var direction = query.Descending ? "DESC" : "ASC";
|
||||
command.CommandText = $"""
|
||||
SELECT {RecordColumns} FROM nests {where}
|
||||
ORDER BY {SortColumn(query.Sort)} {direction}, id {direction}
|
||||
LIMIT $limit OFFSET $offset
|
||||
""";
|
||||
AddSearchParameter(command, search);
|
||||
command.Parameters.AddWithValue("$limit", query.Limit);
|
||||
command.Parameters.AddWithValue("$offset", query.Offset);
|
||||
var records = new List<NestRecord>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
records.Add(ReadRecord(reader));
|
||||
|
||||
return new NestPage { Items = records, Total = total, Offset = query.Offset, Limit = query.Limit };
|
||||
}
|
||||
}
|
||||
|
||||
public byte[]? GetFile(Guid id)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = "SELECT file FROM nests WHERE id = $id";
|
||||
@@ -73,8 +123,11 @@ public sealed class NestDatabase : IDisposable
|
||||
var value = command.ExecuteScalar();
|
||||
return value is byte[] bytes ? bytes : null;
|
||||
}
|
||||
}
|
||||
|
||||
public NestRecord Insert(Guid id, NestRecord record, byte[] file)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
@@ -89,8 +142,11 @@ public sealed class NestDatabase : IDisposable
|
||||
command.ExecuteNonQuery();
|
||||
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
|
||||
}
|
||||
}
|
||||
|
||||
public NestRecord? Update(Guid id, NestRecord record, byte[]? file)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (Get(id) is null)
|
||||
return null;
|
||||
@@ -110,16 +166,42 @@ public sealed class NestDatabase : IDisposable
|
||||
command.ExecuteNonQuery();
|
||||
return Get(id);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Delete(Guid id)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = "DELETE FROM nests WHERE id = $id";
|
||||
command.Parameters.AddWithValue("$id", id.ToString());
|
||||
return command.ExecuteNonQuery() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _connection.Dispose();
|
||||
/// <summary>Checks the live connection without exposing storage error details.</summary>
|
||||
public bool IsHealthy()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var command = _connection.CreateCommand();
|
||||
command.CommandText = "SELECT 1";
|
||||
return command.ExecuteScalar() is long value && value == 1;
|
||||
}
|
||||
catch (Exception ex) when (ex is SqliteException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sync)
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
private void Execute(string sql)
|
||||
{
|
||||
@@ -133,6 +215,47 @@ public sealed class NestDatabase : IDisposable
|
||||
status, plateCount, partCount, comments, madeBy, fileSize, savedAt
|
||||
""";
|
||||
|
||||
// Case-insensitive (ASCII) substring over the text columns and the status's stored
|
||||
// and display names. The search text is always a bound, escaped LIKE parameter.
|
||||
private const string SearchPredicate = """
|
||||
WHERE name LIKE $pattern ESCAPE '\'
|
||||
OR customer LIKE $pattern ESCAPE '\'
|
||||
OR material LIKE $pattern ESCAPE '\'
|
||||
OR madeBy LIKE $pattern ESCAPE '\'
|
||||
OR comments LIKE $pattern ESCAPE '\'
|
||||
OR status LIKE $pattern ESCAPE '\'
|
||||
OR (CASE status WHEN 'ToBeCut' THEN 'To Be Cut' WHEN 'HasBeenCut' THEN 'Has Been Cut'
|
||||
ELSE status END) LIKE $pattern ESCAPE '\'
|
||||
""";
|
||||
|
||||
// Fixed allowlist: request text never reaches the ORDER BY clause.
|
||||
private static string SortColumn(NestSortField field) => field switch
|
||||
{
|
||||
NestSortField.SavedAt => "savedAt",
|
||||
NestSortField.Name => "name COLLATE NOCASE",
|
||||
NestSortField.Customer => "customer COLLATE NOCASE",
|
||||
NestSortField.Status => "status COLLATE NOCASE",
|
||||
NestSortField.Material => "material COLLATE NOCASE",
|
||||
NestSortField.DateCreated => "dateCreated",
|
||||
NestSortField.DateModified => "dateModified",
|
||||
NestSortField.Thickness => "thickness",
|
||||
NestSortField.PlateCount => "plateCount",
|
||||
NestSortField.PartCount => "partCount",
|
||||
NestSortField.MadeBy => "madeBy COLLATE NOCASE",
|
||||
NestSortField.Comments => "comments COLLATE NOCASE",
|
||||
NestSortField.FileSize => "fileSize",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."),
|
||||
};
|
||||
|
||||
private static void AddSearchParameter(SqliteCommand command, string search)
|
||||
{
|
||||
if (search.Length == 0)
|
||||
return;
|
||||
|
||||
var escaped = search.Replace(@"\", @"\\").Replace("%", @"\%").Replace("_", @"\_");
|
||||
command.Parameters.AddWithValue("$pattern", "%" + escaped + "%");
|
||||
}
|
||||
|
||||
private static void AddRecordParameters(
|
||||
SqliteCommand command, Guid id, NestRecord record, byte[]? file, bool updateFile)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Globalization;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>GET /api/nests/query</c> parameters. Unknown, repeated, non-integer or
|
||||
/// out-of-range values are rejected rather than ignored or clamped, so a request
|
||||
/// can never silently receive a broader result than it asked for.
|
||||
/// </summary>
|
||||
internal static class NestQueryRequest
|
||||
{
|
||||
private static readonly string[] Keys = { "search", "sort", "order", "offset", "limit" };
|
||||
|
||||
public static bool TryParse(IQueryCollection values, out NestQuery query, out string error)
|
||||
{
|
||||
query = new NestQuery();
|
||||
foreach (var key in values.Keys)
|
||||
{
|
||||
if (!Keys.Contains(key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
error = $"Unknown query parameter '{key}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (values[key].Count > 1)
|
||||
{
|
||||
error = $"Query parameter '{key}' was given more than once.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var sort = NestSortField.SavedAt;
|
||||
if (values.TryGetValue("sort", out var sortText)
|
||||
&& !TrySortField(sortText.ToString(), out sort))
|
||||
{
|
||||
error = $"sort must be one of: {string.Join(", ", Enum.GetNames<NestSortField>().Select(CamelCase))}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var descending = true;
|
||||
if (values.TryGetValue("order", out var orderText))
|
||||
{
|
||||
if (string.Equals(orderText, "asc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
descending = false;
|
||||
}
|
||||
else if (!string.Equals(orderText, "desc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = "order must be asc or desc.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
var limit = NestQuery.DefaultLimit;
|
||||
if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error))
|
||||
return false;
|
||||
|
||||
query = new NestQuery
|
||||
{
|
||||
Search = values["search"].ToString(),
|
||||
Sort = sort,
|
||||
Descending = descending,
|
||||
Offset = offset,
|
||||
Limit = limit,
|
||||
};
|
||||
error = query.GetValidationError() ?? "";
|
||||
return error.Length == 0;
|
||||
}
|
||||
|
||||
// Names only: Enum.TryParse would also accept numbers and undefined values.
|
||||
private static bool TrySortField(string text, out NestSortField field)
|
||||
{
|
||||
field = NestSortField.SavedAt;
|
||||
return text.Length > 0
|
||||
&& text.All(char.IsAsciiLetter)
|
||||
&& Enum.TryParse(text, ignoreCase: true, out field)
|
||||
&& Enum.IsDefined(field);
|
||||
}
|
||||
|
||||
private static string CamelCase(string name) => char.ToLowerInvariant(name[0]) + name[1..];
|
||||
|
||||
private static bool TryInteger(IQueryCollection values, string key, ref int value, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (!values.TryGetValue(key, out var text))
|
||||
return true;
|
||||
|
||||
if (int.TryParse(text.ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out value))
|
||||
return true;
|
||||
|
||||
error = $"{key} must be a non-negative integer.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.31" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -25,15 +25,27 @@ var databasePath =
|
||||
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
|
||||
?? Environment.GetEnvironmentVariable("OPENNEST_DB")
|
||||
?? Path.Combine("data", "nests.db");
|
||||
builder.Services.AddSingleton(new NestDatabase(databasePath));
|
||||
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
|
||||
|
||||
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
// Open the database now so an unusable data path fails startup, not the first request.
|
||||
app.Services.GetRequiredService<NestDatabase>();
|
||||
|
||||
app.MapGet("/healthz", (NestDatabase db) => db.IsHealthy()
|
||||
? Results.Ok(new { status = "ok" })
|
||||
: Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable));
|
||||
|
||||
// Full enumeration (backup manifests, restore checks and the container smoke).
|
||||
app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
|
||||
|
||||
// Bounded, SQL-filtered page for browsing.
|
||||
app.MapGet("/api/nests/query", (HttpRequest request, NestDatabase db) =>
|
||||
NestQueryRequest.TryParse(request.Query, out var query, out var error)
|
||||
? Results.Ok(db.Query(query))
|
||||
: Results.BadRequest(error));
|
||||
|
||||
app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
|
||||
db.Get(id) is { } record ? Results.Ok(record) : Results.NotFound());
|
||||
|
||||
@@ -91,7 +103,18 @@ static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultip
|
||||
if (!http.Request.HasFormContentType)
|
||||
return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and file parts."));
|
||||
|
||||
var form = await http.Request.ReadFormAsync();
|
||||
IFormCollection form;
|
||||
try
|
||||
{
|
||||
form = await http.Request.ReadFormAsync();
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException || (ex is IOException && ex is not BadHttpRequestException))
|
||||
{
|
||||
// A truncated or malformed multipart body is a client error. BadHttpRequestException
|
||||
// (for example, an oversized body) keeps the status code Kestrel assigns it.
|
||||
return (null, null, Results.BadRequest("Malformed multipart body."));
|
||||
}
|
||||
|
||||
var metadataPart = form["metadata"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(metadataPart))
|
||||
return (null, null, Results.BadRequest("Missing 'metadata' part."));
|
||||
@@ -127,3 +150,6 @@ internal static class MultipartJson
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
}
|
||||
|
||||
// Entry-point type for in-memory integration tests (WebApplicationFactory<Program>).
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copy to a local, untracked env file next to compose.server.yaml and edit it.
|
||||
# Never put credentials in this file or the Compose file.
|
||||
|
||||
# A tested image version or digest, for example ghcr.io/ajisaacs/opennest-server@sha256:<digest>.
|
||||
OPENNEST_SERVER_IMAGE=ghcr.io/ajisaacs/opennest-server:REPLACE-WITH-TESTED-VERSION
|
||||
|
||||
# Loopback for a local test. For shop PCs, use this host's trusted LAN address and
|
||||
# confirm the firewall admits only trusted clients; the service has no authentication.
|
||||
OPENNEST_BIND_ADDRESS=127.0.0.1
|
||||
OPENNEST_HOST_PORT=8090
|
||||
|
||||
# Docker volume holding nests.db; change only to switch to a verified restore.
|
||||
OPENNEST_DATA_VOLUME=opennest-data
|
||||
@@ -0,0 +1,83 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.Tests.Bom;
|
||||
|
||||
/// <summary>
|
||||
/// BOM items to rows to an operator edit to groups to the created nest,
|
||||
/// the same path the import dialog takes.
|
||||
/// </summary>
|
||||
public sealed class BomImportFlowTests : IDisposable
|
||||
{
|
||||
private readonly string folder = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"bom-flow-" + Guid.NewGuid().ToString("N")
|
||||
);
|
||||
|
||||
public BomImportFlowTests()
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
WriteSquare("PT01");
|
||||
WriteSquare("PT02");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(folder))
|
||||
Directory.Delete(folder, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditedQuantity_ReachesTheGroupTotalAndTheNestDrawing()
|
||||
{
|
||||
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", 2), Item("PT02", null) }, folder);
|
||||
|
||||
Assert.True(rows[0].TrySetQuantity("5"));
|
||||
Assert.False(rows[1].TrySetQuantity("0"));
|
||||
|
||||
var group = Assert.Single(BomImportGroups.Build(rows));
|
||||
Assert.Equal(6, group.TotalQty);
|
||||
|
||||
var nest = Assert.IsType<Nest>(BomNestBuilder.Build(group, Plate(), "Job", null).Nest);
|
||||
Assert.Equal(
|
||||
new[] { ("PT01", 5), ("PT02", 1) },
|
||||
nest.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required))
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BomQuantityBelowOne_KeepsThePartOutUntilTheOperatorFixesIt()
|
||||
{
|
||||
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", 0), Item("PT02", 3) }, folder);
|
||||
|
||||
Assert.Equal(new[] { "PT02" }, Assert.Single(BomImportGroups.Build(rows)).Parts.Select(p => p.FileName));
|
||||
|
||||
Assert.True(rows[0].TrySetQuantity("4"));
|
||||
|
||||
var group = Assert.Single(BomImportGroups.Build(rows));
|
||||
Assert.Equal(7, group.TotalQty);
|
||||
}
|
||||
|
||||
private static BomItem Item(string fileName, int? qty) =>
|
||||
new()
|
||||
{
|
||||
FileName = fileName,
|
||||
Qty = qty,
|
||||
Material = "Stainless",
|
||||
Thickness = 0.25,
|
||||
};
|
||||
|
||||
private static BomGroupPlateSettings Plate() => new() { PlateWidth = 60, PlateLength = 120 };
|
||||
|
||||
private void WriteSquare(string name)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
|
||||
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
|
||||
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
|
||||
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
|
||||
Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), Path.Combine(folder, name + ".dxf"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.Tests.Bom;
|
||||
|
||||
public class BomImportGroupsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_GroupsByMaterialIgnoringCaseThenThickness()
|
||||
{
|
||||
var rows = new List<BomPartRow>
|
||||
{
|
||||
Row("PT01", "Stainless", 0.25, qty: 2),
|
||||
Row("PT02", "Aluminum", 0.125, qty: 1),
|
||||
Row("PT03", "STAINLESS", 0.25, qty: 3),
|
||||
Row("PT04", "Stainless", 0.125, qty: 4),
|
||||
Row("PT05", "Aluminum", 0.0625, qty: 3),
|
||||
};
|
||||
|
||||
var groups = BomImportGroups.Build(rows);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "Aluminum 0.0625", "Aluminum 0.125", "Stainless 0.125", "Stainless 0.25" },
|
||||
groups.Select(g => $"{g.Material} {g.Thickness}")
|
||||
);
|
||||
var stainless = groups[3];
|
||||
Assert.Equal(new[] { "PT01", "PT03" }, stainless.Parts.Select(p => p.FileName));
|
||||
Assert.Equal(5, stainless.TotalQty);
|
||||
Assert.Equal(BomImportGroups.Key("stainless", 0.25), stainless.Key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_LeavesOutRowsThatCannotBeImported()
|
||||
{
|
||||
var noFileName = Row("PT02", "Stainless", 0.25);
|
||||
noFileName.FileName = " ";
|
||||
var noDrawing = Row("PT05", "Stainless", 0.25);
|
||||
noDrawing.DxfPath = null;
|
||||
var rows = new List<BomPartRow>
|
||||
{
|
||||
Row("PT01", "Stainless", 0.25),
|
||||
noFileName,
|
||||
Row("PT03", " ", 0.25),
|
||||
Row("PT04", "Stainless", null),
|
||||
noDrawing,
|
||||
Row("PT06", "Stainless", 0),
|
||||
Row("PT07", "Stainless", 0.25, qty: 0),
|
||||
Row("PT08", "Stainless", 0.25, qty: null),
|
||||
};
|
||||
|
||||
var groups = BomImportGroups.Build(rows);
|
||||
|
||||
var group = Assert.Single(groups);
|
||||
Assert.Equal("PT01", Assert.Single(group.Parts).FileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Key_IgnoresMaterialCase()
|
||||
{
|
||||
Assert.Equal(BomImportGroups.Key("Stainless", 0.25), BomImportGroups.Key("STAINLESS", 0.25));
|
||||
Assert.NotEqual(BomImportGroups.Key("Stainless", 0.25), BomImportGroups.Key("Stainless", 0.125));
|
||||
}
|
||||
|
||||
private static BomPartRow Row(string fileName, string material, double? thickness, int? qty = 1) =>
|
||||
new()
|
||||
{
|
||||
FileName = fileName,
|
||||
Material = material,
|
||||
Thickness = thickness,
|
||||
Qty = qty,
|
||||
DxfPath = $"/drawings/{fileName}.dxf",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.Tests.Bom;
|
||||
|
||||
public sealed class BomImportRowsTests : IDisposable
|
||||
{
|
||||
private readonly string folder = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"bom-rows-" + Guid.NewGuid().ToString("N")
|
||||
);
|
||||
|
||||
public BomImportRowsTests()
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(folder))
|
||||
Directory.Delete(folder, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MatchesRowsToDrawingFilesInBomOrder()
|
||||
{
|
||||
Touch("PT01.dxf");
|
||||
Touch("PT03.dwg");
|
||||
var items = new List<BomItem>
|
||||
{
|
||||
Item("PT01", qty: 2, itemNum: 1, description: "Gusset"),
|
||||
Item("PT02", qty: 3, itemNum: 2),
|
||||
Item(null, qty: 4, itemNum: 3),
|
||||
Item("PT03", qty: null, itemNum: 4),
|
||||
};
|
||||
|
||||
var rows = BomImportRows.Build(items, folder);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, rows.Select(r => r.ItemNum ?? 0));
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
BomRowStatus.Ready,
|
||||
BomRowStatus.NoDrawing,
|
||||
BomRowStatus.NoFileName,
|
||||
BomRowStatus.Ready,
|
||||
},
|
||||
rows.Select(r => r.Status)
|
||||
);
|
||||
Assert.Equal(new[] { true, false, false, true }, rows.Select(r => r.IsEditable));
|
||||
Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath);
|
||||
Assert.Null(rows[1].DxfPath);
|
||||
Assert.Null(rows[2].DxfPath);
|
||||
Assert.Equal(Path.Combine(folder, "PT03.dwg"), rows[3].DxfPath);
|
||||
|
||||
Assert.Equal("Gusset", rows[0].Description);
|
||||
Assert.Equal(2, rows[0].Qty);
|
||||
Assert.Equal(2, rows[0].BomQty);
|
||||
Assert.False(rows[0].QtyAssumed);
|
||||
Assert.Equal(1, rows[3].Qty);
|
||||
Assert.Null(rows[3].BomQty);
|
||||
Assert.True(rows[3].QtyAssumed);
|
||||
Assert.Equal("Stainless", rows[0].Material);
|
||||
Assert.Equal(0.25, rows[0].Thickness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MatchesFileNamesIgnoringCase()
|
||||
{
|
||||
Touch("PT01.dxf");
|
||||
|
||||
var rows = BomImportRows.Build(new List<BomItem> { Item("pt01") }, folder);
|
||||
|
||||
Assert.Equal(BomRowStatus.Ready, rows[0].Status);
|
||||
Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_MatchesFileNamesThatIncludeTheExtension()
|
||||
{
|
||||
Touch("PT01.dxf");
|
||||
Touch("PT02.dwg");
|
||||
|
||||
var rows = BomImportRows.Build(
|
||||
new List<BomItem> { Item("PT01.dxf"), Item("PT02.DWG") },
|
||||
folder
|
||||
);
|
||||
|
||||
Assert.All(rows, r => Assert.True(r.IsEditable, r.FileName));
|
||||
Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath);
|
||||
Assert.Equal(Path.Combine(folder, "PT02.dwg"), rows[1].DxfPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RowWithoutThickness_StillFindsItsDrawing()
|
||||
{
|
||||
Touch("PT01.dxf");
|
||||
var item = Item("PT01");
|
||||
item.Thickness = null;
|
||||
|
||||
var rows = BomImportRows.Build(new List<BomItem> { item }, folder);
|
||||
|
||||
Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath);
|
||||
Assert.True(rows[0].IsEditable);
|
||||
Assert.Equal(BomRowStatus.NeedsThickness, rows[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RowWithoutMaterial_NeedsAMaterial()
|
||||
{
|
||||
Touch("PT01.dxf");
|
||||
var item = Item("PT01");
|
||||
item.Material = " ";
|
||||
|
||||
var rows = BomImportRows.Build(new List<BomItem> { item }, folder);
|
||||
|
||||
Assert.True(rows[0].IsEditable);
|
||||
Assert.Equal(BomRowStatus.NeedsMaterial, rows[0].Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-3)]
|
||||
public void Build_RowWithBomQuantityBelowOne_NeedsAQuantity(int bomQty)
|
||||
{
|
||||
Touch("PT01.dxf");
|
||||
|
||||
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", qty: bomQty) }, folder);
|
||||
|
||||
Assert.Equal(bomQty, rows[0].Qty);
|
||||
Assert.False(rows[0].QtyAssumed);
|
||||
Assert.Equal(BomRowStatus.NeedsQuantity, rows[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithMissingFolder_FindsNoDrawings()
|
||||
{
|
||||
var missing = Path.Combine(folder, "missing");
|
||||
|
||||
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01") }, missing);
|
||||
|
||||
Assert.Equal(BomRowStatus.NoDrawing, rows[0].Status);
|
||||
Assert.False(rows[0].IsEditable);
|
||||
Assert.Null(rows[0].DxfPath);
|
||||
}
|
||||
|
||||
private void Touch(string name) => File.WriteAllText(Path.Combine(folder, name), "");
|
||||
|
||||
private static BomItem Item(
|
||||
string? fileName,
|
||||
int? qty = 1,
|
||||
int? itemNum = null,
|
||||
string? description = null
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
ItemNum = itemNum,
|
||||
FileName = fileName,
|
||||
Qty = qty,
|
||||
Description = description,
|
||||
Material = "Stainless",
|
||||
Thickness = 0.25,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.Tests.Bom;
|
||||
|
||||
public class BomImportSummaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Describe_CountsReadyRowsAndEachProblem()
|
||||
{
|
||||
var rows = new List<BomPartRow>
|
||||
{
|
||||
Row("PT01", "Steel", 0.25),
|
||||
Row("PT02", "Steel", 0.25),
|
||||
Row("PT03", null, 0.25),
|
||||
Row("PT04", "Steel", null),
|
||||
Row("PT05", "Steel", null),
|
||||
Row("PT06", "Steel", 0.25, dxfPath: null),
|
||||
Row(null, "Steel", 0.25),
|
||||
Row("PT08", "Steel", 0.25, qty: 0),
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"2 ready, 1 needs a material, 2 need a thickness, 1 needs a quantity, 1 no drawing found, 1 no file name",
|
||||
BomImportSummary.Describe(rows)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_CountsImportableRowsWhoseQuantityWasAssumed()
|
||||
{
|
||||
var assumed = Row("PT01", "Steel", 0.25);
|
||||
assumed.QtyAssumed = true;
|
||||
var noDrawing = Row("PT02", "Steel", 0.25, dxfPath: null);
|
||||
noDrawing.QtyAssumed = true;
|
||||
|
||||
Assert.Equal(
|
||||
"1 ready, 1 no drawing found, 1 had no BOM quantity (1 used)",
|
||||
BomImportSummary.Describe(new[] { assumed, noDrawing })
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_WithEveryRowReady_ListsOnlyTheReadyCount()
|
||||
{
|
||||
Assert.Equal("1 ready", BomImportSummary.Describe(new[] { Row("PT01", "Steel", 0.25) }));
|
||||
Assert.Equal("0 ready", BomImportSummary.Describe(Array.Empty<BomPartRow>()));
|
||||
}
|
||||
|
||||
private static BomPartRow Row(
|
||||
string? fileName,
|
||||
string? material,
|
||||
double? thickness,
|
||||
string? dxfPath = "/d/part.dxf",
|
||||
int qty = 1
|
||||
) =>
|
||||
new()
|
||||
{
|
||||
FileName = fileName,
|
||||
Material = material,
|
||||
Thickness = thickness,
|
||||
DxfPath = dxfPath,
|
||||
Qty = qty,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.Tests.Bom;
|
||||
|
||||
public sealed class BomNestBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string folder = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"bom-nests-" + Guid.NewGuid().ToString("N")
|
||||
);
|
||||
|
||||
public BomNestBuilderTests()
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(folder))
|
||||
Directory.Delete(folder, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_AppliesSavedDefaultsThenGroupSettings()
|
||||
{
|
||||
var group = Group(Row("PT01", qty: 5), Row("PT02", qty: null));
|
||||
var plate = Plate();
|
||||
|
||||
var result = BomNestBuilder.Build(
|
||||
group,
|
||||
plate,
|
||||
"Job 42",
|
||||
nest =>
|
||||
{
|
||||
nest.Units = Units.Millimeters;
|
||||
nest.PlateDefaults.Quadrant = 3;
|
||||
nest.PlateDefaults.Size = new Size(1, 1);
|
||||
nest.PlateDefaults.PartSpacing = 9;
|
||||
}
|
||||
);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
var nest = Assert.IsType<Nest>(result.Nest);
|
||||
Assert.Equal("Job 42 - 0.25 Stainless", nest.Name);
|
||||
Assert.Equal(Units.Millimeters, nest.Units);
|
||||
Assert.Equal(3, nest.PlateDefaults.Quadrant);
|
||||
Assert.Equal(60, nest.PlateDefaults.Size.Width);
|
||||
Assert.Equal(120, nest.PlateDefaults.Size.Length);
|
||||
Assert.Equal(0.2, nest.PlateDefaults.PartSpacing);
|
||||
Assert.Equal(new[] { 0.1, 0.2, 0.3, 0.4 }, Edges(nest.PlateDefaults.EdgeSpacing));
|
||||
Assert.Equal(0.25, nest.Thickness);
|
||||
Assert.Equal("Stainless", nest.Material.Name);
|
||||
Assert.Single(nest.Plates);
|
||||
|
||||
var drawings = nest.Drawings.OrderBy(d => d.Name).ToList();
|
||||
Assert.Equal(new[] { "PT01", "PT02" }, drawings.Select(d => d.Name));
|
||||
Assert.Equal(new[] { 5, 1 }, drawings.Select(d => d.Quantity.Required));
|
||||
Assert.All(drawings, d => Assert.Equal("Stainless", d.Material.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CombinesRowsThatUseTheSameDrawing()
|
||||
{
|
||||
var first = Row("PT01", qty: 2);
|
||||
var second = Row("PT01", qty: 3);
|
||||
second.FileName = "pt01.dxf";
|
||||
|
||||
var result = BomNestBuilder.Build(Group(first, second, Row("PT02", qty: 1)), Plate(), "Job", null);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
var nest = Assert.IsType<Nest>(result.Nest);
|
||||
var drawings = nest.Drawings.OrderBy(d => d.Name).ToList();
|
||||
Assert.Equal(new[] { "PT01", "PT02" }, drawings.Select(d => d.Name));
|
||||
Assert.Equal(new[] { 5, 1 }, drawings.Select(d => d.Quantity.Required));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReportsMissingDrawingAndKeepsTheOthers()
|
||||
{
|
||||
var missing = Row("PT09");
|
||||
File.Delete(missing.DxfPath);
|
||||
|
||||
var result = BomNestBuilder.Build(Group(Row("PT01"), missing), Plate(), "Job", null);
|
||||
|
||||
Assert.Equal("PT09: DXF file not found", Assert.Single(result.Errors));
|
||||
var nest = Assert.IsType<Nest>(result.Nest);
|
||||
Assert.Equal("PT01", Assert.Single(nest.Drawings).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithNoImportedDrawing_ReturnsNoNest()
|
||||
{
|
||||
var unreadable = Row("PT01");
|
||||
File.WriteAllText(unreadable.DxfPath, "not a drawing");
|
||||
|
||||
var result = BomNestBuilder.Build(Group(unreadable), Plate(), "Job", null);
|
||||
|
||||
Assert.Null(result.Nest);
|
||||
Assert.StartsWith("PT01: ", Assert.Single(result.Errors));
|
||||
}
|
||||
|
||||
private BomPartRow Row(string name, int? qty = 1) =>
|
||||
new()
|
||||
{
|
||||
FileName = name,
|
||||
Qty = qty,
|
||||
Material = "Stainless",
|
||||
Thickness = 0.25,
|
||||
DxfPath = WriteSquare(name),
|
||||
};
|
||||
|
||||
private static BomImportGroup Group(params BomPartRow[] rows) => new("Stainless", 0.25, rows);
|
||||
|
||||
private static BomGroupPlateSettings Plate() =>
|
||||
new()
|
||||
{
|
||||
PlateWidth = 60,
|
||||
PlateLength = 120,
|
||||
PartSpacing = 0.2,
|
||||
EdgeLeft = 0.1,
|
||||
EdgeBottom = 0.2,
|
||||
EdgeRight = 0.3,
|
||||
EdgeTop = 0.4,
|
||||
};
|
||||
|
||||
private static double[] Edges(Spacing spacing) =>
|
||||
new[] { spacing.Left, spacing.Bottom, spacing.Right, spacing.Top };
|
||||
|
||||
private string WriteSquare(string name)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
|
||||
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
|
||||
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
|
||||
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
|
||||
|
||||
var path = Path.Combine(folder, name + ".dxf");
|
||||
Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.Tests.Bom;
|
||||
|
||||
public class BomPartRowTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.NoFileName)]
|
||||
[InlineData(" ", "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.NoFileName)]
|
||||
[InlineData("PT01", null, "Steel", 0.25, BomRowStatus.NoDrawing)]
|
||||
[InlineData("PT01", "/d/PT01.dxf", null, 0.25, BomRowStatus.NeedsMaterial)]
|
||||
[InlineData("PT01", "/d/PT01.dxf", "\t", 0.25, BomRowStatus.NeedsMaterial)]
|
||||
[InlineData("PT01", "/d/PT01.dxf", "Steel", null, BomRowStatus.NeedsThickness)]
|
||||
[InlineData("PT01", "/d/PT01.dxf", "Steel", 0.0, BomRowStatus.NeedsThickness)]
|
||||
[InlineData("PT01", "/d/PT01.dxf", "Steel", -0.25, BomRowStatus.NeedsThickness)]
|
||||
[InlineData("PT01", "/d/PT01.dxf", "Steel", double.NaN, BomRowStatus.NeedsThickness)]
|
||||
[InlineData("PT01", "/d/PT01.dxf", "Steel", double.PositiveInfinity, BomRowStatus.NeedsThickness)]
|
||||
[InlineData("PT01", "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.Ready)]
|
||||
public void Status_FollowsTheFirstMissingValue(
|
||||
string? fileName,
|
||||
string? dxfPath,
|
||||
string? material,
|
||||
double? thickness,
|
||||
BomRowStatus expected
|
||||
)
|
||||
{
|
||||
var row = new BomPartRow
|
||||
{
|
||||
FileName = fileName,
|
||||
DxfPath = dxfPath,
|
||||
Material = material,
|
||||
Thickness = thickness,
|
||||
Qty = 1,
|
||||
};
|
||||
|
||||
Assert.Equal(expected, row.Status);
|
||||
Assert.Equal(expected is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing), row.IsEditable);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(0)]
|
||||
[InlineData(-2)]
|
||||
public void Status_NeedsAQuantityOfAtLeastOne(int? qty)
|
||||
{
|
||||
var row = ReadyRow();
|
||||
row.Qty = qty;
|
||||
|
||||
Assert.Equal(BomRowStatus.NeedsQuantity, row.Status);
|
||||
Assert.True(row.IsEditable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_ChangesWhenTheOperatorFillsInAValue()
|
||||
{
|
||||
var row = new BomPartRow { FileName = "PT01", DxfPath = "/d/PT01.dxf" };
|
||||
Assert.Equal(BomRowStatus.NeedsMaterial, row.Status);
|
||||
|
||||
row.Material = "Steel";
|
||||
Assert.Equal(BomRowStatus.NeedsThickness, row.Status);
|
||||
|
||||
row.Thickness = 0.25;
|
||||
Assert.Equal(BomRowStatus.NeedsQuantity, row.Status);
|
||||
|
||||
Assert.True(row.TrySetQuantity("3"));
|
||||
Assert.Equal(BomRowStatus.Ready, row.Status);
|
||||
Assert.Equal("Ready", row.StatusText);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("2.5")]
|
||||
[InlineData("")]
|
||||
[InlineData("abc")]
|
||||
public void TrySetQuantity_RefusesInvalidTextAndKeepsTheQuantity(string text)
|
||||
{
|
||||
var row = ReadyRow();
|
||||
row.Qty = 4;
|
||||
row.QtyAssumed = true;
|
||||
var changes = Changes(row);
|
||||
|
||||
Assert.False(row.TrySetQuantity(text));
|
||||
|
||||
Assert.Equal(4, row.Qty);
|
||||
Assert.True(row.QtyAssumed);
|
||||
Assert.Empty(changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrySetQuantity_SetsTheQuantityAndReportsTheChange()
|
||||
{
|
||||
var row = ReadyRow();
|
||||
row.QtyAssumed = true;
|
||||
var changes = Changes(row);
|
||||
|
||||
Assert.True(row.TrySetQuantity(" 12 "));
|
||||
|
||||
Assert.Equal(12, row.Qty);
|
||||
Assert.False(row.QtyAssumed);
|
||||
Assert.Equal(new[] { "Qty", "Status", "StatusText" }, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditingMaterialOrThickness_ReportsTheValueAndTheStatus()
|
||||
{
|
||||
var row = ReadyRow();
|
||||
var changes = Changes(row);
|
||||
|
||||
row.Material = "Aluminum";
|
||||
row.Thickness = 0.125;
|
||||
row.Thickness = 0.125;
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "Material", "Status", "StatusText", "Thickness", "Status", "StatusText" },
|
||||
changes
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Describe_NamesEveryStatus()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"Ready",
|
||||
"Needs material",
|
||||
"Needs thickness",
|
||||
"Needs quantity",
|
||||
"No drawing found",
|
||||
"No file name",
|
||||
},
|
||||
Enum.GetValues<BomRowStatus>().Select(BomPartRow.Describe)
|
||||
);
|
||||
}
|
||||
|
||||
private static BomPartRow ReadyRow() =>
|
||||
new()
|
||||
{
|
||||
FileName = "PT01",
|
||||
DxfPath = "/d/PT01.dxf",
|
||||
Material = "Steel",
|
||||
Thickness = 0.25,
|
||||
Qty = 1,
|
||||
};
|
||||
|
||||
private static List<string?> Changes(BomPartRow row)
|
||||
{
|
||||
var names = new List<string?>();
|
||||
row.PropertyChanged += (_, e) => names.Add(e.PropertyName);
|
||||
return names;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.Tests.Bom;
|
||||
|
||||
public class BomQuantityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("5", 5)]
|
||||
[InlineData(" 7 ", 7)]
|
||||
[InlineData("2147483647", int.MaxValue)]
|
||||
public void TryParse_AcceptsWholeNumbersOfAtLeastOne(string text, int expected)
|
||||
{
|
||||
Assert.True(BomQuantity.TryParse(text, out var quantity));
|
||||
Assert.Equal(expected, quantity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("+3")]
|
||||
[InlineData("2.5")]
|
||||
[InlineData("1e3")]
|
||||
[InlineData("1,000")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("2147483648")]
|
||||
public void TryParse_RejectsEverythingElse(string? text)
|
||||
{
|
||||
Assert.False(BomQuantity.TryParse(text!, out var quantity));
|
||||
Assert.Equal(0, quantity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Reflection;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingCaptureFidelityTests
|
||||
{
|
||||
public static IEnumerable<object[]> UnsupportedOriginals()
|
||||
{
|
||||
foreach (var kind in new[] { "linear", "program", "mode" })
|
||||
foreach (var nested in new[] { false, true })
|
||||
foreach (var target in new[] { "eligible", "locked", "ineligible", "placed", "compatibility" })
|
||||
yield return new object[] { kind, nested, target };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(UnsupportedOriginals))]
|
||||
public void Service_RefusesOriginalUnsupportedSemanticsBeforeClone(string kind, bool nested, string target)
|
||||
{
|
||||
var original = Rectangle();
|
||||
if (kind == "linear")
|
||||
original.Codes[1] = new CustomLinear { EndPoint = ((Motion)original.Codes[1]).EndPoint };
|
||||
if (kind == "program")
|
||||
{
|
||||
var custom = new CustomProgram();
|
||||
custom.Codes.AddRange(original.Codes);
|
||||
original = custom;
|
||||
}
|
||||
var invalid = original;
|
||||
if (nested)
|
||||
{
|
||||
var root = new Program();
|
||||
root.Codes.Add(new SubProgramCall(original, 0) { Id = -3 });
|
||||
original = root;
|
||||
}
|
||||
// Part construction uses legacy Clone, so original subclass information is lost.
|
||||
var part = new Part(new Drawing("original", original));
|
||||
if (kind == "linear")
|
||||
{
|
||||
var placed = nested ? ((SubProgramCall)part.Program.Codes[0]).Program : part.Program;
|
||||
Assert.IsType<LinearMove>(placed.Codes[1]);
|
||||
}
|
||||
if (target == "placed")
|
||||
{
|
||||
part = new Part(new Drawing("supported", Rectangle()));
|
||||
Assert.True(part.RestoreLeadInProgram(original, false));
|
||||
}
|
||||
if (kind == "mode")
|
||||
typeof(Program).GetField("mode", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(invalid, (Mode)100);
|
||||
part.LeadInsLocked = target == "locked";
|
||||
var clean = part.BaseDrawing.Program;
|
||||
var placedProgram = part.Program;
|
||||
var codes = original.Codes.ToArray();
|
||||
var invalidCodes = invalid.Codes.ToArray();
|
||||
var bounds = part.BoundingBox;
|
||||
var quantity = part.BaseDrawing.Quantity.Nested;
|
||||
var cloneCalls = invalid.Codes.OfType<CustomLinear>().Sum(c => c.CloneCalls);
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part],
|
||||
confirmedParameters: target == "compatibility" ? null : ExplicitContourTests.Parameters(),
|
||||
eligibleParts: target == "ineligible" ? [] : null));
|
||||
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.False(result.IndependentlyReplayed);
|
||||
Assert.Contains(result.Findings, f => ReferenceEquals(f.SourcePart, part) && f.SourceOrdinal == 0);
|
||||
Assert.Same(clean, part.BaseDrawing.Program);
|
||||
Assert.Same(placedProgram, part.Program);
|
||||
Assert.Equal(codes, original.Codes);
|
||||
Assert.Equal(invalidCodes, invalid.Codes);
|
||||
Assert.Same(bounds, part.BoundingBox);
|
||||
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
|
||||
Assert.Equal(cloneCalls, invalid.Codes.OfType<CustomLinear>().Sum(c => c.CloneCalls));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("linear")]
|
||||
[InlineData("program")]
|
||||
[InlineData("mode")]
|
||||
public void Service_RefusesUnsupportedInactiveRegisteredGraphBeforeClone(string kind)
|
||||
{
|
||||
var part = new Part(new Drawing("registered", Rectangle()));
|
||||
Program inactive = kind == "program" ? new CustomProgram() : new Program();
|
||||
if (kind == "linear") inactive.Codes.Add(new CustomLinear());
|
||||
if (kind == "mode")
|
||||
typeof(Program).GetField("mode", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(inactive, (Mode)100);
|
||||
part.Program.SubPrograms[-27] = inactive;
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
|
||||
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.Same(inactive, part.Program.SubPrograms[-27]);
|
||||
Assert.All(inactive.Codes.OfType<CustomLinear>(), c => Assert.Equal(0, c.CloneCalls));
|
||||
}
|
||||
|
||||
private static Program Rectangle() => LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||
private sealed class CustomProgram : Program { }
|
||||
private sealed class CustomLinear : LinearMove
|
||||
{
|
||||
public int CloneCalls { get; private set; }
|
||||
public override ICode Clone()
|
||||
{
|
||||
CloneCalls++;
|
||||
return base.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingCaptureRotationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0.3, false)]
|
||||
[InlineData(-0.3, false)]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(0.3, true)]
|
||||
public void SharedDiamond_CaptureAndReadyPreserveOnceRotatedCleanGeometry(double angle, bool incrementalRoot)
|
||||
{
|
||||
var leaf = Rectangle(1, 2);
|
||||
leaf.Mode = Mode.Incremental;
|
||||
var left = new Program();
|
||||
left.Codes.Add(new SubProgramCall(leaf, 0));
|
||||
left.Mode = Mode.Incremental;
|
||||
var right = new Program();
|
||||
right.Codes.Add(new SubProgramCall(leaf, 0));
|
||||
right.Mode = Mode.Incremental;
|
||||
var clean = Rectangle(10, 10);
|
||||
clean.Codes.Add(new SubProgramCall(left, 0) { Offset = new Vector(3, 3) });
|
||||
clean.Codes.Add(new SubProgramCall(right, 0) { Offset = new Vector(7, 3) });
|
||||
if (incrementalRoot) clean.Mode = Mode.Incremental;
|
||||
var part = new Part(new Drawing("shared diamond", clean));
|
||||
part.Rotate(angle);
|
||||
part.Location = new Vector(11, 13);
|
||||
var before = ExplicitContourTests.Fingerprint(clean);
|
||||
var beforeLeaf = ExplicitContourTests.Fingerprint(leaf);
|
||||
var beforePlaced = ExplicitContourTests.Fingerprint(part.Program);
|
||||
var bounds = part.BoundingBox;
|
||||
var quantity = part.BaseDrawing.Quantity.Nested;
|
||||
var settings = new CuttingParameters
|
||||
{
|
||||
ExternalLeadIn = new LineLeadIn { Length = 0.1 },
|
||||
InternalLeadIn = new LineLeadIn { Length = 0.1 },
|
||||
ExternalLeadOut = new NoLeadOut(),
|
||||
InternalLeadOut = new NoLeadOut(),
|
||||
PierceClearance = 0.01
|
||||
};
|
||||
var original = ExecutionMotionReader.ReadSupported(part.Program, part.Location, null);
|
||||
var expectedCuts = original.Motions.Where(IsCut).ToArray();
|
||||
var once = new Vector(3, 5).Rotate(angle) + part.Location;
|
||||
Assert.Contains(expectedCuts, m => m.End.DistanceTo(once) < 1e-8);
|
||||
// Retain the review's independent per-parent clone/once-rotation control.
|
||||
var legacy = (Program)clean.Clone();
|
||||
legacy.Rotate(angle);
|
||||
var legacyCuts = ExecutionMotionReader.ReadSupported(legacy, part.Location, null).Motions.Where(IsCut).ToArray();
|
||||
Assert.Equal(expectedCuts.Length, legacyCuts.Length);
|
||||
Assert.All(Enumerable.Range(0, expectedCuts.Length), i =>
|
||||
Assert.True(expectedCuts[i].End.DistanceTo(legacyCuts[i].End) < 1e-8));
|
||||
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], new Vector(9, 13), confirmedParameters: settings));
|
||||
var captured = Assert.Single(snapshot.Placements);
|
||||
var nominal = captured.Material.Rings.SelectMany(r => r).ToArray();
|
||||
Assert.Equal(expectedCuts.Length, nominal.Length);
|
||||
Assert.All(Enumerable.Range(0, nominal.Length), i =>
|
||||
Assert.True(nominal[i].End.DistanceTo(expectedCuts[i].End) < 1e-8,
|
||||
$"Nominal curve {i} moved from {expectedCuts[i].End} to {nominal[i].End}."));
|
||||
var ready = CuttingPlanService.Plan(snapshot);
|
||||
Assert.True(ready.Status == CuttingPlanStatus.Ready, string.Join("; ", ready.Findings.Select(f => f.Message)));
|
||||
Assert.True(ready.IndependentlyReplayed);
|
||||
var emitted = ExecutionMotionReader.ReadSupported(Assert.Single(ready.ProposedOrder).CopyProgram(), part.Location, null)
|
||||
.Motions.Where(IsCut).ToArray();
|
||||
Assert.Equal(expectedCuts.Sum(m => m.Length), emitted.Sum(m => m.Length), 8);
|
||||
Assert.All(emitted, move => Assert.Contains(expectedCuts, expected => expected.Curve.SameSupport(move.Curve)
|
||||
&& expected.Curve.Contains(move.Curve.Start) && expected.Curve.Contains(move.End)));
|
||||
Assert.Equal(before, ExplicitContourTests.Fingerprint(clean));
|
||||
Assert.Equal(beforeLeaf, ExplicitContourTests.Fingerprint(leaf));
|
||||
Assert.Equal(beforePlaced, ExplicitContourTests.Fingerprint(part.Program));
|
||||
Assert.Same(leaf, ((SubProgramCall)left.Codes[0]).Program);
|
||||
Assert.Same(leaf, ((SubProgramCall)right.Codes[0]).Program);
|
||||
Assert.Same(bounds, part.BoundingBox);
|
||||
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
private static bool IsCut(ExecutionMotion motion) => !motion.Rapid && motion.Layer is LayerType.Cut or LayerType.Display;
|
||||
|
||||
private static Program Rectangle(double width, double height)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(0, height);
|
||||
program.LineTo(width, height);
|
||||
program.LineTo(width, 0);
|
||||
program.LineTo(0, 0);
|
||||
return program;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingDependencyTests
|
||||
{
|
||||
// Started just above P, nearest-first cuts P before the far cutoff start. P leaves upward
|
||||
// and the cutoff's trimmed cut stops short of P, so P, cutoff, Q crosses nothing: only the
|
||||
// nominal-span dependency puts the cutoff first.
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Plan_CutOffWhoseNominalSpanCrossesAPart_IsCutFirstAndAppliedThatWay(bool regenerate)
|
||||
{
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var (_, plate, p, q, cut) = CutOffPlate(parameters, orphan: false);
|
||||
var request = CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5),
|
||||
confirmedParameters: regenerate ? parameters : null);
|
||||
var snapshot = CuttingPlanService.Capture(request);
|
||||
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(0));
|
||||
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
|
||||
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(2));
|
||||
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
|
||||
Assert.True(order.IndexOf(cut) < order.IndexOf(p), string.Join(",", result.ProposedOrder.Select(o => o.SourceOrdinal)));
|
||||
Assert.Equal(regenerate, result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, p)).IsRegenerated);
|
||||
Assert.False(result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, cut)).IsRegenerated);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
|
||||
Assert.Equal(order, plate.Parts);
|
||||
Assert.Contains(q, plate.Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_CutOffReclassifiedAfterPlanning_IsStale()
|
||||
{
|
||||
var (_, plate, p, q, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5)));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
cut.BaseDrawing.IsCutOff = false;
|
||||
|
||||
var commit = CuttingPlanService.Apply([result]);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
|
||||
Assert.Equal(new[] { p, q, cut }, plate.Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_OrphanedCutOff_PrecedesEveryPart()
|
||||
{
|
||||
var (_, plate, p, q, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: true);
|
||||
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5)));
|
||||
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(0));
|
||||
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(1));
|
||||
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.Same(cut, result.ProposedOrder[0].SourcePart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_PreservedOrderCuttingAPartBeforeItsCutOff_IsAConstraintConflict()
|
||||
{
|
||||
var (_, plate, p, _, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
|
||||
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5), preservePartOrder: true));
|
||||
|
||||
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
var finding = Assert.Single(result.Findings);
|
||||
Assert.Same(p, finding.SourcePart);
|
||||
Assert.Same(cut, finding.OtherSourcePart);
|
||||
Assert.Contains("cutoff", finding.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Replay_RechecksDependenciesInsteadOfTrustingTheSearch(bool regenerate)
|
||||
{
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var (_, plate, _, _, _) = CutOffPlate(parameters, orphan: false);
|
||||
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5),
|
||||
confirmedParameters: regenerate ? parameters : null));
|
||||
var ready = CuttingPlanService.Plan(snapshot);
|
||||
Assert.Equal(CuttingPlanStatus.Ready, ready.Status);
|
||||
// Same verified placements, counterfeit order: crossed part before its cutoff.
|
||||
var counterfeit = ready.ProposedOrder.OrderBy(o => o.SourceOrdinal).ToArray();
|
||||
|
||||
var replayed = regenerate
|
||||
? CuttingPlanService.ReplayPrograms(snapshot, counterfeit, 0, default)
|
||||
: CuttingPlanService.Replay(snapshot, counterfeit.Select(o => o.SourceOrdinal).ToArray(), 0, default);
|
||||
|
||||
Assert.Equal(CuttingPlanStatus.InvalidInput, replayed.Status);
|
||||
Assert.Empty(replayed.ProposedOrder);
|
||||
Assert.Contains(replayed.Findings, f => f.SourceOrdinal == 0 && f.OtherSourceOrdinal == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_DetachedListWithACutOff_IsUnsupported()
|
||||
{
|
||||
var (_, plate, _, _, _) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
|
||||
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate.Parts.ToArray()));
|
||||
|
||||
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
|
||||
Assert.Contains("plate-scoped", Assert.Single(result.Findings).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_PartInsideAHostCutout_IsCutBeforeTheHost()
|
||||
{
|
||||
var (plate, host, inner) = NestedPlate(Rectangle(2.7, 2.7, 0.6, 0.6));
|
||||
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
|
||||
Assert.Equal(new[] { 1 }, snapshot.Dependencies.PrerequisitesOf(0));
|
||||
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
|
||||
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.Equal(new[] { inner, host }, result.ProposedOrder.Select(o => o.SourcePart));
|
||||
|
||||
var preserved = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, preservePartOrder: true));
|
||||
Assert.Equal(CuttingPlanStatus.ConstraintConflict, preserved.Status);
|
||||
var finding = Assert.Single(preserved.Findings);
|
||||
Assert.Same(host, finding.SourcePart);
|
||||
Assert.Same(inner, finding.OtherSourcePart);
|
||||
Assert.Contains("nested in its cutout", finding.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dependencies_NonmaterialMotionsCannotHideANestedPart()
|
||||
{
|
||||
// Same insert as above plus a remote rapid and scribe mark: material is unchanged, so
|
||||
// the inner-before-host prerequisite must not depend on whole-program bounds.
|
||||
var (plate, host, inner) = NestedPlate(Marked(Rectangle(2.7, 2.7, 0.6, 0.6)));
|
||||
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
|
||||
Assert.Null(snapshot.Findings.FirstOrDefault()?.Message);
|
||||
Assert.True(inner.BoundingBox.Left < host.BoundingBox.Left);
|
||||
|
||||
Assert.Equal(new[] { 1 }, snapshot.Dependencies.PrerequisitesOf(0));
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.Equal(new[] { inner, host }, result.ProposedOrder.Select(o => o.SourcePart));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_PartStraddlingAHostCutoutEdge_IsAmbiguousContainment()
|
||||
{
|
||||
var (plate, host, inner) = NestedPlate(Rectangle(3.6, 2.8, 0.8, 0.4));
|
||||
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
|
||||
|
||||
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
|
||||
var finding = Assert.Single(result.Findings);
|
||||
Assert.Same(inner, finding.SourcePart);
|
||||
Assert.Same(host, finding.OtherSourcePart);
|
||||
Assert.Contains("Containment", finding.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_PartInAConcavePocketOutsideTheHost_HasNoDependency()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
var host = Pocketed();
|
||||
var inner = Rectangle(4.5, 5, 1, 1);
|
||||
plate.Parts.Add(host);
|
||||
plate.Parts.Add(inner);
|
||||
|
||||
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
|
||||
|
||||
Assert.Null(snapshot.Findings.FirstOrDefault()?.Message);
|
||||
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(0));
|
||||
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.Plan(snapshot).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DependencyGraph_ReportsCyclesAndFirstViolation()
|
||||
{
|
||||
Assert.NotNull(CuttingDependencyGraph.FromPrerequisites([[1], [0], []]).FindCycle());
|
||||
var chain = CuttingDependencyGraph.FromPrerequisites([[], [0], [1]]);
|
||||
Assert.Null(chain.FindCycle());
|
||||
Assert.Null(chain.FirstViolation([0, 1, 2]));
|
||||
Assert.Equal((2, 1), chain.FirstViolation([0, 2, 1]));
|
||||
Assert.True(chain.IsReady(1, [0]));
|
||||
Assert.False(chain.IsReady(2, [0]));
|
||||
}
|
||||
|
||||
private static (Nest, Plate, Part P, Part Q, Part Cut) CutOffPlate(CuttingParameters parameters, bool orphan)
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
var p = Emitted(parameters, new Vector(10, 10));
|
||||
var q = Emitted(parameters, new Vector(40, 20));
|
||||
var cutOff = new CutOff(new Vector(11, 0), CutOffAxis.Vertical) { StartLimit = 0, EndLimit = 30 };
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(11, 30));
|
||||
program.Codes.Add(new LinearMove(11, 12.5) { Layer = LayerType.Cut });
|
||||
cutOff.Drawing.Program = program;
|
||||
if (!orphan)
|
||||
plate.CutOffs.Add(cutOff);
|
||||
var cut = new Part(cutOff.Drawing);
|
||||
plate.Parts.Add(p);
|
||||
plate.Parts.Add(q);
|
||||
plate.Parts.Add(cut);
|
||||
return (nest, plate, p, q, cut);
|
||||
}
|
||||
|
||||
// A regenerable 2x2 square with its emitted lead at the top-edge midpoint.
|
||||
private static Part Emitted(CuttingParameters parameters, Vector location)
|
||||
{
|
||||
var clean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
|
||||
var prepared = PreparedContours.Capture(clean, parameters);
|
||||
var part = new Part(new Drawing("same", clean), location);
|
||||
Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(1, 3))]), false));
|
||||
part.CuttingParameters = parameters;
|
||||
return part;
|
||||
}
|
||||
|
||||
// Host: 10x10 square with circular cutouts of radius 1 at (3,3) and (7,3), cut hole, hole,
|
||||
// perimeter from the right so its own program has no completed-contour crossing.
|
||||
private static (Plate, Part Host, Part Inner) NestedPlate(Part inner)
|
||||
{
|
||||
var clean = PreparedContourTests.Holes();
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var prepared = PreparedContours.Capture(clean, parameters);
|
||||
var program = prepared.Emit(
|
||||
[
|
||||
prepared.Entry(0, 0, new Vector(4, 3)),
|
||||
prepared.Entry(1, 0, new Vector(8, 3)),
|
||||
prepared.ClosestEntry(2, new Vector(12, 3))
|
||||
]);
|
||||
var host = new Part(new Drawing("same", clean));
|
||||
Assert.True(host.RestoreLeadInProgram(program, false));
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Parts.Add(host);
|
||||
plate.Parts.Add(inner);
|
||||
return (plate, host, inner);
|
||||
}
|
||||
|
||||
// Prefixes a remote rapid and scribe mark to both the clean and placed programs.
|
||||
private static Part Marked(Part part)
|
||||
{
|
||||
static Program Prefix(Program source)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(-20, -20));
|
||||
program.Codes.Add(new LinearMove(-19, -20) { Layer = LayerType.Scribe });
|
||||
program.Codes.AddRange(source.Codes);
|
||||
return program;
|
||||
}
|
||||
|
||||
var marked = new Part(new Drawing("same", Prefix(part.BaseDrawing.Program)), part.Location);
|
||||
Assert.True(marked.RestoreLeadInProgram(Prefix(part.Program), false));
|
||||
return marked;
|
||||
}
|
||||
|
||||
// A U-shaped part open at the top between x 3 and 7, lead on its right edge.
|
||||
private static Part Pocketed()
|
||||
{
|
||||
var outline = new[] { (10.0, 0.0), (0.0, 0.0), (0.0, 10.0), (3.0, 10.0), (3.0, 3.0), (7.0, 3.0),
|
||||
(7.0, 10.0), (10.0, 10.0), (10.0, 5.0) };
|
||||
var clean = new Program();
|
||||
clean.MoveTo(10, 5);
|
||||
foreach (var (x, y) in outline)
|
||||
clean.LineTo(x, y);
|
||||
var placed = new Program();
|
||||
placed.MoveTo(10.25, 5);
|
||||
placed.Codes.Add(new LinearMove(10, 5) { Layer = LayerType.Leadin });
|
||||
foreach (var (x, y) in outline)
|
||||
placed.LineTo(x, y);
|
||||
placed.Codes.Add(new LinearMove(10.25, 5) { Layer = LayerType.Leadout });
|
||||
var part = new Part(new Drawing("same", clean));
|
||||
Assert.True(part.RestoreLeadInProgram(placed, false));
|
||||
return part;
|
||||
}
|
||||
|
||||
// Fixed-program rectangle at (x, y), contour starting at its right-edge midpoint with a
|
||||
// 0.25 straight lead-in and lead-out to the right.
|
||||
private static Part Rectangle(double x, double y, double width, double height)
|
||||
{
|
||||
var clean = new Program();
|
||||
clean.MoveTo(width, height / 2);
|
||||
Contour(clean, width, height);
|
||||
var part = new Part(new Drawing("same", clean), new Vector(x, y));
|
||||
var placed = new Program();
|
||||
placed.MoveTo(width + 0.25, height / 2);
|
||||
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
|
||||
Contour(placed, width, height);
|
||||
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
|
||||
Assert.True(part.RestoreLeadInProgram(placed, false));
|
||||
return part;
|
||||
}
|
||||
|
||||
private static void Contour(Program program, double width, double height)
|
||||
{
|
||||
program.LineTo(width, 0);
|
||||
program.LineTo(0, 0);
|
||||
program.LineTo(0, height);
|
||||
program.LineTo(width, height);
|
||||
program.LineTo(width, height / 2);
|
||||
}
|
||||
|
||||
private static string Describe(CuttingPlanResult r) =>
|
||||
$"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingPartOrderTests
|
||||
{
|
||||
private static readonly Vector[] Line = [new(1, 0), new(-2, 0), new(3, 0)];
|
||||
|
||||
[Fact]
|
||||
public void Plan_ShortensTheNearestNeighbourTour()
|
||||
{
|
||||
// Nearest first gives 0, 2, 1 (1 + 2 + 5 = 8); going left first is 2 + 3 + 2 = 7.
|
||||
var order = Plan(Line, NoPrerequisites(3));
|
||||
|
||||
Assert.Equal(new[] { 1, 0, 2 }, order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_NeverPlacesAPartBeforeItsPrerequisite()
|
||||
{
|
||||
// The shortest tour starts with part 1, but part 2 must come before it.
|
||||
var prerequisites = NoPrerequisites(3);
|
||||
prerequisites[1] = [2];
|
||||
|
||||
var order = Plan(Line, prerequisites);
|
||||
|
||||
Assert.Equal(new[] { 0, 2, 1 }, order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WaitsForAPrerequisiteBeforeTheNearestPart()
|
||||
{
|
||||
// Part 0 is nearest and the shorter tour, but part 1 must be cut first.
|
||||
var prerequisites = NoPrerequisites(2);
|
||||
prerequisites[0] = [1];
|
||||
|
||||
Assert.Equal(new[] { 1, 0 }, Plan([new(1, 0), new(5, 0)], prerequisites));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_OnASubset_TreatsPrerequisitesOutsideItAsDone()
|
||||
{
|
||||
var prerequisites = NoPrerequisites(3);
|
||||
prerequisites[1] = [0];
|
||||
|
||||
var order = CuttingPartOrder.Plan([1, 2], Line, new Vector(4, 0), prerequisites, CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { 2, 1 }, order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_ReversalsShortenTheTour()
|
||||
{
|
||||
// Nearest neighbour gives 4, 1, 0, 2, 3. With reversals the tour ends at 4, 3, 1, 0, 2
|
||||
// (14.14); moving short runs alone stops at 2, 0, 4, 1, 3 (15.30).
|
||||
Vector[] centres = [new(-2, -1), new(0, -2), new(-4, 3), new(2, -4), new(0, -1)];
|
||||
|
||||
Assert.Equal(new[] { 4, 3, 1, 0, 2 }, Plan(centres, NoPrerequisites(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_MovesARunOfPartsWhereReversalsCannotHelp()
|
||||
{
|
||||
// Nearest neighbour and 2-opt stop at 1, 3, 2, 0 (13.78); moving part 0 to the front
|
||||
// gives 0, 1, 3, 2 (12.16).
|
||||
Vector[] centres = [new(-3, 0), new(1, 0), new(4, -3), new(3, 0)];
|
||||
|
||||
Assert.Equal(new[] { 0, 1, 3, 2 }, Plan(centres, NoPrerequisites(4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_EqualTours_PreferTheLowerOrdinalFirst()
|
||||
{
|
||||
Assert.Equal(new[] { 0, 1 }, Plan([new(0, 5), new(0, -5)], NoPrerequisites(2)));
|
||||
Assert.Equal(new[] { 0, 1 }, Plan([new(0, -5), new(0, 5)], NoPrerequisites(2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_PrerequisiteCycle_Throws()
|
||||
{
|
||||
var prerequisites = NoPrerequisites(2);
|
||||
prerequisites[0] = [1];
|
||||
prerequisites[1] = [0];
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Plan([new(0, 0), new(1, 0)], prerequisites));
|
||||
}
|
||||
|
||||
private static int[] Plan(Vector[] centres, IReadOnlyCollection<int>[] prerequisites) =>
|
||||
CuttingPartOrder.Plan(centres, Vector.Zero, prerequisites, CancellationToken.None);
|
||||
|
||||
private static IReadOnlyCollection<int>[] NoPrerequisites(int count) =>
|
||||
Enumerable.Range(0, count).Select(_ => (IReadOnlyCollection<int>)Array.Empty<int>()).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingPlanBatchTests
|
||||
{
|
||||
[Fact]
|
||||
public void Apply_EveryPlateReady_InstallsAllPlatesAndRecordsOwnedSettingsOnEach()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var first = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1));
|
||||
var second = Plate(nest, Clean("c", 1, 1));
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([first, second], parameters, preservePartOrder: false).Plan();
|
||||
|
||||
Assert.True(proposal.CanApply);
|
||||
Assert.All(proposal.Plates, p => Assert.True(p.IsReady));
|
||||
Assert.Equal(3, proposal.Plates.Sum(p => p.RegeneratedCount));
|
||||
Assert.All(first.Parts.Concat(second.Parts), part => Assert.False(part.HasManualLeadIns)); // Planning never mutates.
|
||||
((LineLeadIn)parameters.ExternalLeadIn).Length = length * 3; // Confirmed settings were copied at capture.
|
||||
|
||||
var commit = proposal.Apply();
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
|
||||
foreach (var planned in proposal.Plates)
|
||||
foreach (var placement in planned.Result.ProposedOrder)
|
||||
Assert.True(ProgramContent.Equal(placement.CopyProgram(), placement.SourcePart.Program));
|
||||
Assert.All(first.Parts.Concat(second.Parts), part => Assert.True(part.HasManualLeadIns));
|
||||
Assert.NotNull(first.CuttingParameters);
|
||||
Assert.NotSame(parameters, first.CuttingParameters);
|
||||
Assert.NotSame(first.CuttingParameters, second.CuttingParameters);
|
||||
Assert.Equal(length, ((LineLeadIn)first.CuttingParameters.ExternalLeadIn).Length);
|
||||
Assert.Equal(length, ((LineLeadIn)second.CuttingParameters.ExternalLeadIn).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_OneBlockedPlate_RefusesTheWholeBatchAndNamesThePlateAndPart()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var ready = Plate(nest, Clean("open", 1, 1));
|
||||
var locked = Clean("locked", 1, 1);
|
||||
locked.LeadInsLocked = true; // Locked programs never regenerate: no lead-in is a conflict.
|
||||
var blocked = Plate(nest, locked);
|
||||
var readyProgram = ready.Parts[0].Program;
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(),
|
||||
preservePartOrder: false, plateNumbers: [3, 7]).Plan();
|
||||
|
||||
Assert.False(proposal.CanApply);
|
||||
Assert.True(proposal.Plates[0].IsReady);
|
||||
var refused = proposal.Plates[1];
|
||||
Assert.Equal(CuttingPlanStatus.ConstraintConflict, refused.Result.Status);
|
||||
Assert.False(refused.KeptCurrentOrder); // Only a budget-exhausted search is retried.
|
||||
var text = string.Join("\n", proposal.Describe("in"));
|
||||
Assert.StartsWith("Apply is unavailable: 1 of 2 plates", text);
|
||||
Assert.Contains("Plate 3: ready.", text);
|
||||
Assert.Contains("Plate 7: blocked", text);
|
||||
Assert.Contains("- Part 1 (locked):", text);
|
||||
|
||||
var commit = proposal.Apply();
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
|
||||
Assert.Same(readyProgram, ready.Parts[0].Program);
|
||||
Assert.False(ready.Parts[0].HasManualLeadIns);
|
||||
Assert.Null(ready.CuttingParameters);
|
||||
Assert.Null(blocked.CuttingParameters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_OverlappingParts_BlocksApplyAndNamesBothParts()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Clean("first", 1, 1), Clean("second", 6, 6));
|
||||
var programs = plate.Parts.Select(p => p.Program).ToArray();
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
Assert.Equal(CuttingPlanStatus.Ready, planned.Result.Status); // The route alone is fine...
|
||||
var pair = Assert.Single(planned.Overlap.Pairs);
|
||||
Assert.Equal((0, 1), (pair.PartAId, pair.PartBId));
|
||||
Assert.False(planned.IsReady); // ...but overlapping material blocks the plate.
|
||||
Assert.False(proposal.CanApply);
|
||||
var text = string.Join("\n", proposal.Describe("in"));
|
||||
Assert.Contains("Plate 1: blocked: parts overlap or could not be checked for overlap.", text);
|
||||
Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text);
|
||||
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
|
||||
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
|
||||
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Mode.Absolute)]
|
||||
[InlineData(Mode.Incremental)]
|
||||
public void Plan_SubprogramHoles_AreNeverAFalseOverlap(Mode mode)
|
||||
{
|
||||
var hole = new Program();
|
||||
hole.MoveTo(1, 0);
|
||||
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
|
||||
hole.Mode = mode;
|
||||
var clean = ExplicitContourTests.Square(false);
|
||||
clean.SubPrograms[-1] = hole;
|
||||
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(3, 3), Id = -1 });
|
||||
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(7, 3), Id = -1 });
|
||||
clean.Mode = mode;
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, new Part(new Drawing("holes", clean), new Vector(1, 1)));
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
Assert.True(planned.IsRouteReady);
|
||||
Assert.Empty(planned.Overlap.Pairs);
|
||||
if (mode == Mode.Incremental)
|
||||
Assert.True(planned.IsReady, string.Join("; ", planned.Overlap.Issues.Select(i => i.Message)));
|
||||
else
|
||||
{
|
||||
// The overlap check cannot read absolute subprograms yet: blocked as unchecked, not as overlapping.
|
||||
Assert.False(planned.IsReady);
|
||||
Assert.Contains("Overlap check incomplete for part 1: Absolute-mode subprograms",
|
||||
string.Join("\n", proposal.Describe("in")));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_RefusedGraphs_BlockThePlateInsteadOfThrowing()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var missing = Clean("missing", 1, 1);
|
||||
missing.BaseDrawing.Program.Codes = null;
|
||||
var plate = Plate(nest, missing);
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
Assert.False(planned.IsReady);
|
||||
Assert.False(proposal.CanApply);
|
||||
Assert.Null(proposal.BuildPreview(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Grid(4));
|
||||
var order = plate.Parts.ToArray();
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false, null,
|
||||
reorderBudget: 50, CancellationToken.None).Plan();
|
||||
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
Assert.True(planned.KeptCurrentOrder);
|
||||
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, planned.ReorderAttempt!.Status);
|
||||
Assert.True(planned.IsReady);
|
||||
Assert.False(planned.OrderChanged);
|
||||
Assert.Equal(order, planned.Result.ProposedOrder.Select(p => p.SourcePart));
|
||||
Assert.Contains("the current order is kept", string.Join("\n", proposal.Describe("in")));
|
||||
Assert.True(proposal.CanApply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_PreservedOrder_DoesNotSearchForANewOrder()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Grid(4));
|
||||
var phases = new List<CuttingPlanPhase>();
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: true)
|
||||
.Plan(new Recorder(phases));
|
||||
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
Assert.True(planned.IsReady);
|
||||
Assert.False(planned.KeptCurrentOrder);
|
||||
Assert.Equal(new[] { CuttingPlanPhase.CheckingOverlap, CuttingPlanPhase.KeepingOrder }, phases);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_AfterALiveEdit_IsStaleAndChangesNothing()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1));
|
||||
var programs = plate.Parts.Select(p => p.Program).ToArray();
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||
Assert.True(proposal.CanApply);
|
||||
|
||||
plate.Parts[1].Offset(0, 1);
|
||||
var commit = proposal.Apply();
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
|
||||
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
|
||||
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||
Assert.Null(plate.CuttingParameters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_Cancelled_OffersNothingToApply()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Clean("a", 1, 1));
|
||||
var batch = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
var proposal = batch.Plan(token: cancellation.Token);
|
||||
|
||||
Assert.True(proposal.IsCancelled);
|
||||
Assert.False(proposal.CanApply);
|
||||
Assert.Equal("Planning was cancelled. Nothing has changed.", proposal.Describe("in")[0]);
|
||||
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
|
||||
Assert.False(plate.Parts[0].HasManualLeadIns);
|
||||
Assert.Null(plate.CuttingParameters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPreview_ReadyPlate_IsDetachedAndShowsTheProposal()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Grid(4));
|
||||
plate.Quantity = 3;
|
||||
var drawing = plate.Parts[0].BaseDrawing;
|
||||
var nested = drawing.Quantity.Nested;
|
||||
var liveParts = plate.Parts.ToArray();
|
||||
var livePrograms = liveParts.Select(p => p.Program).ToArray();
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||
|
||||
var preview = proposal.BuildPreview(0);
|
||||
|
||||
Assert.Equal(0, preview.Quantity);
|
||||
Assert.Equal(plate.Size, preview.Size);
|
||||
Assert.Equal(nested, drawing.Quantity.Nested);
|
||||
var proposed = proposal.Plates[0].Result.ProposedOrder;
|
||||
Assert.Equal(proposed.Count, preview.Parts.Count);
|
||||
for (var i = 0; i < proposed.Count; i++)
|
||||
{
|
||||
Assert.DoesNotContain(preview.Parts[i], liveParts);
|
||||
Assert.Same(proposed[i].SourcePart.BaseDrawing, preview.Parts[i].BaseDrawing);
|
||||
Assert.Equal(proposed[i].Location, preview.Parts[i].Location);
|
||||
Assert.True(ProgramContent.Equal(proposed[i].CopyProgram(), preview.Parts[i].Program));
|
||||
}
|
||||
Assert.Equal(liveParts, plate.Parts);
|
||||
Assert.Equal(livePrograms, plate.Parts.Select(p => p.Program));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPreview_NotReadyOrChangedPlate_ShowsNothing()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var ready = Plate(nest, Clean("a", 1, 1));
|
||||
var locked = Clean("locked", 1, 1);
|
||||
locked.LeadInsLocked = true;
|
||||
var blocked = Plate(nest, locked);
|
||||
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan();
|
||||
Assert.True(proposal.Plates[0].IsReady);
|
||||
Assert.False(proposal.Plates[1].IsReady);
|
||||
|
||||
// A refused plate may hold graphs that are unsafe to copy, so it is never previewed.
|
||||
Assert.Null(proposal.BuildPreview(1));
|
||||
Assert.NotNull(proposal.BuildPreview(0));
|
||||
|
||||
// A moved part would draw the replayed program somewhere it was never checked.
|
||||
ready.Parts[0].Offset(25, 0);
|
||||
Assert.Null(proposal.BuildPreview(0));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 20000)]
|
||||
[InlineData(10, 20000)]
|
||||
[InlineData(100, 40000)]
|
||||
[InlineData(6_000_000, int.MaxValue)]
|
||||
public void PlateBudget_ScalesWithPartCountFromTheDefault(int parts, int expected) =>
|
||||
Assert.Equal(expected, CuttingPlanBatch.PlateBudget(parts));
|
||||
|
||||
[Fact]
|
||||
public void Plan_LargeGrid_FindsANewOrderWithinThePlateBudget()
|
||||
{
|
||||
// Row-major 10 x 10 needs more than the 20000 default to reorder (about 26000).
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Grid(100));
|
||||
plate.Size = new Size(120, 120);
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
Assert.True(planned.IsReady, string.Join("\n", proposal.Describe("in")));
|
||||
Assert.False(planned.KeptCurrentOrder);
|
||||
Assert.Equal(100, planned.PartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capture_RejectsMissingDuplicateOrMisnumberedPlates()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Clean("a", 1, 1));
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => CuttingPlanBatch.Capture(null, parameters, false));
|
||||
Assert.Throws<ArgumentNullException>(() => CuttingPlanBatch.Capture([plate], null, false));
|
||||
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([], parameters, false));
|
||||
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate, plate], parameters, false));
|
||||
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate, null], parameters, false));
|
||||
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate], parameters, false, [1, 2]));
|
||||
}
|
||||
|
||||
private static Plate Plate(Nest nest, params Part[] parts)
|
||||
{
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Size = new Size(100, 100);
|
||||
foreach (var part in parts)
|
||||
plate.Parts.Add(part);
|
||||
return plate;
|
||||
}
|
||||
|
||||
private static Part Clean(string name, double x, double y) =>
|
||||
new(new Drawing(name, ExplicitContourTests.Square(false)), new Vector(x, y));
|
||||
|
||||
private static Part[] Grid(int count)
|
||||
{
|
||||
var drawing = new Drawing("grid", PreparedContourTests.Holes());
|
||||
var side = (int)System.Math.Ceiling(System.Math.Sqrt(count));
|
||||
return Enumerable.Range(0, count)
|
||||
.Select(i => new Part(drawing, new Vector(1 + i % side * 11, 1 + i / side * 11)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed class Recorder(List<CuttingPlanPhase> phases) : IProgress<CuttingPlanProgress>
|
||||
{
|
||||
public void Report(CuttingPlanProgress value) => phases.Add(value.Phase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
using System.Reflection;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingPlanCommitTests
|
||||
{
|
||||
[Fact]
|
||||
public void Apply_FixedProgramPlan_ReordersWithoutAccountingEventsAndPublishesOnce()
|
||||
{
|
||||
var (nest, plate, parts) = FixedPlate();
|
||||
var programs = parts.Select(p => p.Program).ToArray();
|
||||
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
|
||||
var events = Watch(plate);
|
||||
Assert.Contains(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
|
||||
f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Equal(parts, plate.Parts); // Planning never mutates.
|
||||
var commit = CuttingPlanService.Apply([result]);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
|
||||
Assert.Empty(commit.RefreshErrors);
|
||||
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, plate.Parts);
|
||||
Assert.Equal((0, 0, 1), events());
|
||||
Assert.Equal(quantities, parts.Select(p => p.BaseDrawing.Quantity.Nested));
|
||||
Assert.Equal(programs, parts.Select(p => p.Program)); // Fixed programs are not replaced.
|
||||
Assert.DoesNotContain(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
|
||||
f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
|
||||
// The same proposal is bound to the captured state, which no longer exists.
|
||||
var again = CuttingPlanService.Apply([result]);
|
||||
Assert.Equal(CuttingCommitStatus.Stale, again.Status);
|
||||
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, plate.Parts);
|
||||
Assert.Equal((0, 0, 1), events());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_RegeneratedPlan_InstallsTheExactReplayedProgramWithOwnedSettings()
|
||||
{
|
||||
var (nest, plate, part, _) = RegeneratedPlate(Vector.Zero);
|
||||
var location = part.Location;
|
||||
var rotation = part.Rotation;
|
||||
var quantity = part.BaseDrawing.Quantity.Nested;
|
||||
// Caller-confirmed settings are planning input, not plate state: editing them after
|
||||
// capture neither stales the plan nor leaks into what is installed.
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
var proposal = Assert.Single(result.ProposedOrder);
|
||||
Assert.True(proposal.IsRegenerated);
|
||||
((LineLeadIn)parameters.ExternalLeadIn).Length = length * 3;
|
||||
|
||||
var commit = CuttingPlanService.Apply([result]);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
|
||||
Assert.True(ProgramContent.Equal(proposal.CopyProgram(), part.Program));
|
||||
Assert.NotSame(parameters, part.CuttingParameters);
|
||||
Assert.Equal(length, ((LineLeadIn)part.CuttingParameters.ExternalLeadIn).Length);
|
||||
Assert.True(part.HasManualLeadIns);
|
||||
Assert.False(part.LeadInsLocked);
|
||||
Assert.Equal(BitConverter.DoubleToInt64Bits(location.X), BitConverter.DoubleToInt64Bits(part.Location.X));
|
||||
Assert.Equal(BitConverter.DoubleToInt64Bits(location.Y), BitConverter.DoubleToInt64Bits(part.Location.Y));
|
||||
Assert.Equal(BitConverter.DoubleToInt64Bits(rotation), BitConverter.DoubleToInt64Bits(part.Rotation));
|
||||
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
|
||||
var bounds = part.Program.BoundingBox();
|
||||
bounds.Offset(part.Location);
|
||||
Assert.Equal((bounds.X, bounds.Y, bounds.Width, bounds.Length),
|
||||
(part.BoundingBox.X, part.BoundingBox.Y, part.BoundingBox.Width, part.BoundingBox.Length));
|
||||
// The installed program is owned: later proposal copies cannot alias it.
|
||||
var detached = proposal.CopyProgram();
|
||||
detached.Codes.Clear();
|
||||
Assert.NotEmpty(part.Program.Codes);
|
||||
Assert.Empty(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("order")]
|
||||
[InlineData("pose")]
|
||||
[InlineData("program-in-place")]
|
||||
[InlineData("program-replaced")]
|
||||
[InlineData("lock")]
|
||||
[InlineData("quantity")]
|
||||
[InlineData("cutoff")]
|
||||
[InlineData("same-name-drawing")]
|
||||
[InlineData("list-replaced")]
|
||||
[InlineData("added")]
|
||||
[InlineData("cutoff-classification")]
|
||||
[InlineData("part-settings-in-place")]
|
||||
[InlineData("part-settings-nested")]
|
||||
[InlineData("plate-settings-in-place")]
|
||||
[InlineData("plate-settings-replaced")]
|
||||
public void Apply_AnyChangeAfterCapture_IsStaleAndChangesNothing(string change)
|
||||
{
|
||||
var (_, plate, parts) = FixedPlate();
|
||||
var cutOff = new CutOff(new Vector(30, 0), CutOffAxis.Vertical);
|
||||
plate.CutOffs.Add(cutOff);
|
||||
plate.CuttingParameters = new CuttingParameters();
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
switch (change)
|
||||
{
|
||||
case "order": plate.Parts.Reorder([parts[2], parts[1], parts[0]]); break;
|
||||
case "pose": parts[0].Offset(1e-12, 0); break;
|
||||
case "program-in-place": ((Motion)parts[1].Program.Codes[1]).Feedrate = 7; break;
|
||||
case "program-replaced":
|
||||
Assert.True(parts[1].RestoreLeadInProgram((Program)parts[1].Program.Clone(), false)); break;
|
||||
case "lock": parts[2].LeadInsLocked = true; break;
|
||||
case "quantity": plate.Quantity = 2; break;
|
||||
case "cutoff":
|
||||
cutOff.Position = new Vector(BitConverter.Int64BitsToDouble(
|
||||
BitConverter.DoubleToInt64Bits(30.0) + 1), 0); break;
|
||||
// Every fixture drawing has the same name: identity must be by reference.
|
||||
case "same-name-drawing": parts[2].BaseDrawing.Program.Codes.Add(new Comment("edited")); break;
|
||||
case "list-replaced":
|
||||
var list = new ObservableList<Part>();
|
||||
foreach (var part in parts) list.Add(part);
|
||||
plate.Parts = list; break;
|
||||
case "added": plate.Parts.Add(Rectangle(20, 0, 2, 2)); break;
|
||||
// Classification decides lead, material, obstacle and dependency treatment.
|
||||
case "cutoff-classification": parts[0].BaseDrawing.IsCutOff = true; break;
|
||||
// A regenerated part would otherwise overwrite an edit made after capture.
|
||||
case "part-settings-in-place": parts[0].CuttingParameters.Kerf = 0.125; break;
|
||||
case "part-settings-nested": parts[0].CuttingParameters.Assignment.Preference = "LIAT"; break;
|
||||
case "plate-settings-in-place": plate.CuttingParameters.PierceClearance = 0.25; break;
|
||||
case "plate-settings-replaced": plate.CuttingParameters = new CuttingParameters(); break;
|
||||
}
|
||||
var after = PlateCuttingState.Capture(plate);
|
||||
var events = Watch(plate);
|
||||
|
||||
var commit = CuttingPlanService.Apply([result]);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
|
||||
Assert.Same(plate, commit.Plate);
|
||||
Assert.False(string.IsNullOrEmpty(commit.Message));
|
||||
Assert.True(after.IsCurrent(), after.Difference());
|
||||
Assert.Equal((0, 0, 0), events());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_MalformedLiveProgram_IsStaleInsteadOfThrowing()
|
||||
{
|
||||
var (_, plate, parts) = FixedPlate();
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
parts[1].Program.Codes = null!;
|
||||
var events = Watch(plate);
|
||||
|
||||
var commit = CuttingPlanService.Apply([result]);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
|
||||
Assert.Equal(parts, plate.Parts);
|
||||
Assert.Null(parts[1].Program.Codes);
|
||||
Assert.Equal((0, 0, 0), events());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgramContent_ComparesAuthoredKeySpellingExactly()
|
||||
{
|
||||
Program With(string key)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new LinearMove(1, 0)
|
||||
{
|
||||
VariableRefs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { [key] = "v" }
|
||||
});
|
||||
return program;
|
||||
}
|
||||
|
||||
Assert.True(ProgramContent.Equal(With("X"), With("X")));
|
||||
Assert.False(ProgramContent.Equal(With("X"), With("x")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_PartSharedByTwoPlates_IsRefusedWithoutChange()
|
||||
{
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var clean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
|
||||
var prepared = PreparedContours.Capture(clean, parameters);
|
||||
var shared = new Part(new Drawing("same", clean), new Vector(5, 5));
|
||||
Assert.True(shared.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(3, 1))]), false));
|
||||
var nest = new Nest();
|
||||
var regenerated = nest.CreatePlate();
|
||||
var fixedPlate = nest.CreatePlate();
|
||||
regenerated.Parts.Add(shared);
|
||||
fixedPlate.Parts.Add(shared);
|
||||
var results = new[]
|
||||
{
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(regenerated, confirmedParameters: parameters)),
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(fixedPlate))
|
||||
};
|
||||
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
|
||||
Assert.True(results[0].ProposedOrder[0].IsRegenerated);
|
||||
var program = shared.Program;
|
||||
var states = new[] { regenerated, fixedPlate }.Select(p => PlateCuttingState.Capture(p)).ToArray();
|
||||
|
||||
var commit = CuttingPlanService.Apply(results);
|
||||
|
||||
// Installing through one plate would silently replace the other plate's fixed program.
|
||||
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
|
||||
Assert.Same(program, shared.Program);
|
||||
Assert.All(states, s => Assert.True(s.IsCurrent(), s.Difference()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommitInstaller_IsOnlyReachableThroughTheVerifiedService()
|
||||
{
|
||||
// The installer validates root references only; owned, verified payloads come from
|
||||
// CuttingPlanService.Apply. Public access would accept nested aliases of live programs.
|
||||
Assert.False(typeof(CuttingPlanCommit).IsPublic);
|
||||
Assert.False(typeof(PlateCuttingPlan).IsPublic);
|
||||
Assert.False(typeof(PlannedPartProgram).IsPublic);
|
||||
Assert.True(typeof(CuttingPlanService).GetMethod(nameof(CuttingPlanService.Apply),
|
||||
[typeof(IEnumerable<CuttingPlanResult>), typeof(CancellationToken)])!.IsPublic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_LaterPlateStale_AppliesNothingAnywhere()
|
||||
{
|
||||
var (_, first, firstParts) = FixedPlate();
|
||||
var (_, second, secondParts) = FixedPlate();
|
||||
var results = new[]
|
||||
{
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second))
|
||||
};
|
||||
secondParts[0].LeadInsLocked = true;
|
||||
var firstState = PlateCuttingState.Capture(first);
|
||||
var events = Watch(first);
|
||||
|
||||
var commit = CuttingPlanService.Apply(results);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
|
||||
Assert.Same(second, commit.Plate);
|
||||
Assert.Equal(firstParts, first.Parts);
|
||||
Assert.True(firstState.IsCurrent(), firstState.Difference());
|
||||
Assert.Equal((0, 0, 0), events());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_InstallFailureOnLaterPlate_RestoresEveryPlateExactlyWithoutPublishing()
|
||||
{
|
||||
var (_, first, firstParts) = FixedPlate();
|
||||
var (_, second, part, parameters) = RegeneratedPlate(Vector.Zero);
|
||||
var (_, third, thirdPart, thirdParameters) = RegeneratedPlate(Vector.Zero);
|
||||
var results = new[]
|
||||
{
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second, confirmedParameters: parameters)),
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(third, confirmedParameters: thirdParameters))
|
||||
};
|
||||
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
|
||||
var states = new[] { first, second, third }.Select(p => PlateCuttingState.Capture(p)).ToArray();
|
||||
var program = part.Program;
|
||||
var bounds = part.BoundingBox;
|
||||
var events = new[] { Watch(first), Watch(second), Watch(third) };
|
||||
var failure = new InvalidOperationException("injected");
|
||||
var installs = 0;
|
||||
|
||||
var commit = CuttingPlanService.Apply(results, default, (plate, _) =>
|
||||
{
|
||||
installs++;
|
||||
if (ReferenceEquals(plate, third)) throw failure;
|
||||
});
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Failed, commit.Status);
|
||||
Assert.Same(failure, commit.Error);
|
||||
Assert.Equal(2, installs); // The second plate really was installed before the failure.
|
||||
Assert.Equal(firstParts, first.Parts);
|
||||
Assert.Same(program, part.Program);
|
||||
Assert.Same(bounds, part.BoundingBox);
|
||||
Assert.Same(parameters, part.CuttingParameters);
|
||||
Assert.All(states, s => Assert.True(s.IsCurrent(), s.Difference()));
|
||||
Assert.All(events, e => Assert.Equal((0, 0, 0), e()));
|
||||
Assert.Same(thirdParameters, thirdPart.CuttingParameters);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_CancelledBeforeCommit_ChangesNothing()
|
||||
{
|
||||
var (_, plate, parts) = FixedPlate();
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
|
||||
var state = PlateCuttingState.Capture(plate);
|
||||
var events = Watch(plate);
|
||||
using var cancel = new CancellationTokenSource();
|
||||
cancel.Cancel();
|
||||
|
||||
var commit = CuttingPlanService.Apply([result], cancel.Token);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Cancelled, commit.Status);
|
||||
Assert.Equal(parts, plate.Parts);
|
||||
Assert.True(state.IsCurrent(), state.Difference());
|
||||
Assert.Equal((0, 0, 0), events());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_ObserverFailureAfterPublication_IsAReportedRefreshFailureNotARollback()
|
||||
{
|
||||
var (_, first, firstParts) = FixedPlate();
|
||||
var (_, second, secondParts) = FixedPlate();
|
||||
var results = new[]
|
||||
{
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
|
||||
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second))
|
||||
};
|
||||
var failure = new InvalidOperationException("view refresh");
|
||||
var seen = new List<string>();
|
||||
first.PartsReordered += (_, _) => throw failure;
|
||||
first.PartsReordered += (_, _) => seen.Add("first");
|
||||
second.PartsReordered += (_, _) => seen.Add("second");
|
||||
|
||||
var commit = CuttingPlanService.Apply(results);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
|
||||
Assert.Same(failure, Assert.Single(commit.RefreshErrors));
|
||||
Assert.Equal(new[] { "first", "second" }, seen);
|
||||
Assert.Equal(new[] { firstParts[1], firstParts[0], firstParts[2] }, first.Parts);
|
||||
Assert.Equal(new[] { secondParts[1], secondParts[0], secondParts[2] }, second.Parts);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("detached")]
|
||||
[InlineData("not-ready")]
|
||||
[InlineData("duplicate")]
|
||||
[InlineData("empty")]
|
||||
public void Apply_RefusesResultsThatAreNotApplicablePlatePlans(string fault)
|
||||
{
|
||||
var (_, plate, parts) = FixedPlate();
|
||||
var state = PlateCuttingState.Capture(plate);
|
||||
var events = Watch(plate);
|
||||
CuttingPlanResult[] results = fault switch
|
||||
{
|
||||
"detached" => [CuttingPlanService.Plan(new CuttingPlanRequest(parts))],
|
||||
"not-ready" => [CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, preservePartOrder: true))],
|
||||
"duplicate" => Enumerable.Repeat(CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate)), 2).ToArray(),
|
||||
_ => []
|
||||
};
|
||||
if (fault == "detached")
|
||||
Assert.Equal(CuttingPlanStatus.Ready, results[0].Status);
|
||||
if (fault == "not-ready")
|
||||
Assert.Equal(CuttingPlanStatus.ConstraintConflict, results[0].Status);
|
||||
|
||||
var commit = CuttingPlanService.Apply(results);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
|
||||
Assert.Equal(parts, plate.Parts);
|
||||
Assert.True(state.IsCurrent(), state.Difference());
|
||||
Assert.Equal((0, 0, 0), events());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Request_NullDetachedListStaysUnambiguousAndInvalid()
|
||||
{
|
||||
// A plate overload of the constructor made this existing call ambiguous (CS0121).
|
||||
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest(null)).Status);
|
||||
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(null)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_AllPlatesWithEmptySentinel_KeepsSentinelAndPlateList()
|
||||
{
|
||||
var nest = new Nest();
|
||||
using var manager = new PlateManager(nest);
|
||||
var plate = nest.CreatePlate();
|
||||
foreach (var part in Fixture())
|
||||
plate.Parts.Add(part);
|
||||
Assert.Equal(2, nest.Plates.Count);
|
||||
var sentinel = nest.Plates[1];
|
||||
Assert.Empty(sentinel.Parts);
|
||||
var listChanges = 0;
|
||||
manager.PlateListChanged += (_, _) => listChanges++;
|
||||
var sentinelEvents = Watch(sentinel);
|
||||
var results = nest.Plates.ToArray().Select(p => CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(p))).ToArray();
|
||||
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
|
||||
Assert.Empty(results[1].ProposedOrder);
|
||||
|
||||
var commit = CuttingPlanService.Apply(results);
|
||||
|
||||
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
|
||||
Assert.Equal(new[] { plate, sentinel }, nest.Plates);
|
||||
Assert.Equal(0, listChanges);
|
||||
Assert.Equal((0, 0, 0), sentinelEvents());
|
||||
Assert.Equal(3, plate.Parts.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("missing")]
|
||||
[InlineData("duplicate")]
|
||||
[InlineData("foreign")]
|
||||
[InlineData("count")]
|
||||
[InlineData("null")]
|
||||
public void Reorder_RejectsAnythingButTheSameReferencesWithoutChangeOrEvents(string fault)
|
||||
{
|
||||
var (_, plate, parts) = FixedPlate();
|
||||
var events = Watch(plate);
|
||||
Part[] order = fault switch
|
||||
{
|
||||
"missing" => [parts[0], parts[1], parts[1]],
|
||||
"duplicate" => [parts[0], parts[1], parts[2], parts[2]],
|
||||
"foreign" => [parts[0], parts[1], Rectangle(0, 0, 1, 1)],
|
||||
"count" => [parts[0], parts[1]],
|
||||
_ => [parts[0], parts[1], null!]
|
||||
};
|
||||
|
||||
Assert.Throws<ArgumentException>(() => plate.Parts.Reorder(order));
|
||||
|
||||
Assert.Equal(parts, plate.Parts);
|
||||
Assert.Equal((0, 0, 0), events());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgramContent_DetectsEveryAuthoredFieldAndSharingShape()
|
||||
{
|
||||
// Guard: a new settable instruction field must be added to ProgramContent and here.
|
||||
var expected = new Dictionary<Type, string[]>
|
||||
{
|
||||
[typeof(RapidMove)] = ["EndPoint", "Feedrate", "Suppressed", "UseExactStop", "VariableRefs"],
|
||||
[typeof(LinearMove)] = ["EndPoint", "Feedrate", "Layer", "Suppressed", "UseExactStop", "VariableRefs"],
|
||||
[typeof(ArcMove)] = ["CenterPoint", "EndPoint", "Feedrate", "Layer", "Rotation", "Suppressed",
|
||||
"UseExactStop", "VariableRefs"],
|
||||
[typeof(SubProgramCall)] = ["Id", "Offset", "Program", "Rotation"],
|
||||
[typeof(Comment)] = ["Value"],
|
||||
[typeof(Feedrate)] = ["Value", "VariableRef"],
|
||||
[typeof(Kerf)] = ["Value"],
|
||||
[typeof(Program)] = ["Mode"]
|
||||
};
|
||||
foreach (var (type, names) in expected)
|
||||
Assert.Equal(names, type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.SetMethod?.IsPublic == true && p.GetIndexParameters().Length == 0)
|
||||
.Select(p => p.Name).Order());
|
||||
|
||||
var mutations = new Action<Program>[]
|
||||
{
|
||||
p => ((Motion)p.Codes[0]).EndPoint = Nudge(((Motion)p.Codes[0]).EndPoint),
|
||||
p => ((Motion)p.Codes[1]).UseExactStop = true,
|
||||
p => ((Motion)p.Codes[1]).Feedrate = 3,
|
||||
p => ((Motion)p.Codes[1]).Suppressed = true,
|
||||
p => ((Motion)p.Codes[1]).VariableRefs!["x"] = "other",
|
||||
p => ((Motion)p.Codes[1]).VariableRefs = null,
|
||||
p => ((LinearMove)p.Codes[1]).Layer = LayerType.Display,
|
||||
p => ((ArcMove)p.Codes[2]).Layer = LayerType.Display,
|
||||
p => ((ArcMove)p.Codes[2]).Rotation = RotationType.CW,
|
||||
p => ((ArcMove)p.Codes[2]).CenterPoint = Nudge(((ArcMove)p.Codes[2]).CenterPoint),
|
||||
p => ((SubProgramCall)p.Codes[3]).Id = 9,
|
||||
p => ((SubProgramCall)p.Codes[3]).Offset = Nudge(((SubProgramCall)p.Codes[3]).Offset),
|
||||
p => ((SubProgramCall)p.Codes[3]).Rotation = 1,
|
||||
p => ((SubProgramCall)p.Codes[3]).Program.Codes.Add(new Comment("hole")),
|
||||
p => ((Comment)p.Codes[5]).Value = "other",
|
||||
p => ((Feedrate)p.Codes[6]).Value = 2,
|
||||
p => ((Feedrate)p.Codes[6]).VariableRef = "f",
|
||||
p => ((Kerf)p.Codes[7]).Value = KerfType.Right,
|
||||
p => p.Mode = Mode.Incremental,
|
||||
p => p.Rotate(1e-9),
|
||||
p => p.Variables["v"] = new VariableDefinition("v", "2", 2),
|
||||
p => p.SubPrograms[5] = new Program(),
|
||||
// Two calls sharing one hole versus two equal but distinct holes.
|
||||
p => ((SubProgramCall)p.Codes[4]).BindProgram(OwnedProgramCopy.Copy(((SubProgramCall)p.Codes[4]).Program))
|
||||
};
|
||||
var original = Rich();
|
||||
Assert.True(ProgramContent.Equal(original, OwnedProgramCopy.Copy(original)));
|
||||
for (var i = 0; i < mutations.Length; i++)
|
||||
{
|
||||
var changed = OwnedProgramCopy.Copy(original);
|
||||
mutations[i](changed);
|
||||
Assert.False(ProgramContent.Equal(original, changed), $"Mutation {i} was not detected.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector Nudge(Vector v) =>
|
||||
new(BitConverter.Int64BitsToDouble(BitConverter.DoubleToInt64Bits(v.X) + 1), v.Y);
|
||||
|
||||
private static Program Rich()
|
||||
{
|
||||
var hole = new Program();
|
||||
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Cut });
|
||||
var program = new Program();
|
||||
program.Variables["v"] = new VariableDefinition("v", "1", 1);
|
||||
program.Codes.Add(new RapidMove(1, 1));
|
||||
program.Codes.Add(new LinearMove(2, 1)
|
||||
{
|
||||
Layer = LayerType.Cut,
|
||||
VariableRefs = new Dictionary<string, string> { ["x"] = "v" }
|
||||
});
|
||||
program.Codes.Add(new ArcMove(new Vector(3, 2), new Vector(2, 2), RotationType.CCW) { Layer = LayerType.Cut });
|
||||
program.Codes.Add(new SubProgramCall(hole, 0) { Id = 1, Offset = new Vector(5, 5) });
|
||||
program.Codes.Add(new SubProgramCall(hole, 0) { Id = 1, Offset = new Vector(7, 5) });
|
||||
program.Codes.Add(new Comment("note"));
|
||||
program.Codes.Add(new Feedrate(1));
|
||||
program.Codes.Add(new Kerf(KerfType.Left));
|
||||
program.SubPrograms[1] = hole;
|
||||
return program;
|
||||
}
|
||||
|
||||
private static Func<(int Added, int Removed, int Reordered)> Watch(Plate plate)
|
||||
{
|
||||
var added = 0;
|
||||
var removed = 0;
|
||||
var reordered = 0;
|
||||
plate.PartAdded += (_, _) => added++;
|
||||
plate.PartRemoved += (_, _) => removed++;
|
||||
plate.PartsReordered += (_, _) => reordered++;
|
||||
return () => (added, removed, reordered);
|
||||
}
|
||||
|
||||
private static (Nest, Plate, Part[]) FixedPlate()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
var parts = Fixture();
|
||||
foreach (var part in parts)
|
||||
plate.Parts.Add(part);
|
||||
return (nest, plate, parts);
|
||||
}
|
||||
|
||||
private static (Nest, Plate, Part, CuttingParameters) RegeneratedPlate(Vector location)
|
||||
{
|
||||
var clean = PreparedContourTests.Holes();
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var prepared = PreparedContours.Capture(clean, parameters);
|
||||
var original = prepared.Emit(
|
||||
[
|
||||
prepared.Entry(0, 0, new Vector(2, 3)),
|
||||
prepared.Entry(1, 0, new Vector(8, 3)),
|
||||
prepared.ClosestEntry(2, new Vector(-2, 3))
|
||||
]);
|
||||
var part = new Part(new Drawing("same", clean), location);
|
||||
Assert.True(part.RestoreLeadInProgram(original, false));
|
||||
part.CuttingParameters = parameters;
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Parts.Add(part);
|
||||
return (nest, plate, part, parameters);
|
||||
}
|
||||
|
||||
// Slice 1 fixture: A,B,C in this order crosses completed A; B,A,C does not.
|
||||
private static Part[] Fixture() =>
|
||||
[
|
||||
Rectangle(4, 0, 4, 4),
|
||||
Rectangle(0, 1, 2, 2),
|
||||
Rectangle(10, 1, 2, 2)
|
||||
];
|
||||
|
||||
private static Part Rectangle(double x, double y, double width, double height)
|
||||
{
|
||||
var clean = new Program();
|
||||
clean.MoveTo(width, height / 2);
|
||||
Contour(clean, width, height);
|
||||
var part = new Part(new Drawing("same", clean), new Vector(x, y));
|
||||
var placed = new Program();
|
||||
placed.MoveTo(width + 0.25, height / 2);
|
||||
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
|
||||
Contour(placed, width, height);
|
||||
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
|
||||
Assert.True(part.RestoreLeadInProgram(placed, false));
|
||||
part.CuttingParameters = new CuttingParameters();
|
||||
return part;
|
||||
}
|
||||
|
||||
private static void Contour(Program program, double width, double height)
|
||||
{
|
||||
program.LineTo(width, 0);
|
||||
program.LineTo(0, 0);
|
||||
program.LineTo(0, height);
|
||||
program.LineTo(width, height);
|
||||
program.LineTo(width, height / 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingPlanServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void FixedPrograms_OriginalOrderCrossesA_ReorderedWholeProgramsDoNot()
|
||||
{
|
||||
var parts = Fixture();
|
||||
var original = Analyze(parts);
|
||||
Assert.Equal(new int?[] { 2, 3 }, original.Findings.Select(f => f.PartNumber));
|
||||
Assert.All(original.Findings, crossing =>
|
||||
{
|
||||
Assert.Equal(PostVerificationKind.RapidCrossing, crossing.Kind);
|
||||
Assert.Equal(1, crossing.OtherPartNumber);
|
||||
});
|
||||
var reordered = Analyze(parts[1], parts[0], parts[2]);
|
||||
Assert.Empty(reordered.Findings);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Plan_ReordersWholeFixedPrograms_AndIndependentlyReplaysEveryPlacement(bool locked)
|
||||
{
|
||||
var parts = Fixture();
|
||||
foreach (var part in parts)
|
||||
part.LeadInsLocked = locked;
|
||||
var unchanged = Unchanged(parts);
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
|
||||
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
|
||||
Assert.Equal(parts.Length, result.ProposedOrder.Select(p => p.SourcePart).Distinct().Count());
|
||||
Assert.Empty(result.Findings);
|
||||
Assert.Empty(Analyze(result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
|
||||
Assert.Equal(new Vector(12.25, 2), result.ProposedOrder[^1].Execution.DeparturePoint);
|
||||
unchanged();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_BacktracksRatherThanReturningUnsafeNearestOrder()
|
||||
{
|
||||
var parts = Fixture();
|
||||
var start = new Vector(8.25, 2); // A is nearest but no continuation after A can reach B safely.
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, start));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
|
||||
Assert.True(result.Expansions > parts.Length);
|
||||
Assert.Empty(AnalyzeFrom(start, result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Mode.Absolute)]
|
||||
[InlineData(Mode.Incremental)]
|
||||
public void Plan_LockedFixedInternalCrossingIsConflictWithExactSourceIdentity(Mode mode)
|
||||
{
|
||||
var part = Rectangle("holes", 100, 50, 20, 20);
|
||||
var hole = new Program();
|
||||
hole.MoveTo(0.5, 0);
|
||||
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
|
||||
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
|
||||
hole.Mode = mode;
|
||||
var placed = new Program();
|
||||
placed.SubPrograms[-7] = hole;
|
||||
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(5, 5), Id = -7 });
|
||||
placed.MoveTo(8, 5);
|
||||
placed.MoveTo(2, 5);
|
||||
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(15, 5), Id = -7 });
|
||||
placed.Mode = mode;
|
||||
Assert.True(part.RestoreLeadInProgram(placed, true));
|
||||
var parts = new[] { Fixture()[0], part };
|
||||
var unchanged = Unchanged(parts);
|
||||
Assert.Contains(Analyze(part).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
|
||||
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.NotEmpty(result.Findings);
|
||||
Assert.All(result.Findings, finding =>
|
||||
{
|
||||
Assert.Equal(PostVerificationKind.RapidCrossing, finding.Kind);
|
||||
Assert.Equal(1, finding.SourceOrdinal);
|
||||
Assert.Equal(1, finding.OtherSourceOrdinal);
|
||||
Assert.Same(part, finding.SourcePart);
|
||||
Assert.Same(part, finding.OtherSourcePart);
|
||||
});
|
||||
unchanged();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("recursive")]
|
||||
[InlineData("missing-call")]
|
||||
[InlineData("null-code")]
|
||||
[InlineData("nonfinite")]
|
||||
[InlineData("bad-arc")]
|
||||
[InlineData("empty")]
|
||||
[InlineData("motionless")]
|
||||
[InlineData("null-codes")]
|
||||
public void Plan_MalformedOrEmptyCuttingStateNeverReady(string fault)
|
||||
{
|
||||
var part = Fixture()[0];
|
||||
var program = part.Program;
|
||||
switch (fault)
|
||||
{
|
||||
case "recursive": program.Codes.Add(new SubProgramCall { Program = program }); break;
|
||||
case "missing-call": program.Codes.Add(new SubProgramCall()); break;
|
||||
case "null-code": program.Codes.Add(null!); break;
|
||||
case "nonfinite": program.MoveTo(double.NaN, 0); break;
|
||||
case "bad-arc": program.ArcTo(1, 1, 0, 0, RotationType.CW); break;
|
||||
case "empty": program.Codes.Clear(); break;
|
||||
case "motionless": program.Codes.Clear(); program.MoveTo(1, 1); break;
|
||||
case "null-codes": program.Codes = null!; break;
|
||||
}
|
||||
var beforeCodes = program.Codes?.ToArray();
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
|
||||
Assert.Equal(CuttingPlanStatus.InvalidInput, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.Same(program, part.Program);
|
||||
Assert.Equal(beforeCodes, program.Codes?.ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("suppressed")]
|
||||
[InlineData("missing-lead")]
|
||||
[InlineData("retention-unknown")]
|
||||
[InlineData("cutoff")]
|
||||
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
|
||||
{
|
||||
var part = Fixture()[0];
|
||||
var expected = CuttingPlanStatus.UnsupportedGeometry;
|
||||
switch (fault)
|
||||
{
|
||||
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
|
||||
case "missing-lead":
|
||||
part.Program.Codes.RemoveAt(1);
|
||||
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
|
||||
expected = CuttingPlanStatus.ConstraintConflict; break;
|
||||
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
|
||||
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
|
||||
}
|
||||
var unchanged = Unchanged([part]);
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
|
||||
Assert.Equal(expected, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.NotEmpty(result.Findings);
|
||||
unchanged();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_BudgetExhaustionHasNoFallbackOrMutation()
|
||||
{
|
||||
var parts = Fixture();
|
||||
var unchanged = Unchanged(parts);
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 1));
|
||||
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
|
||||
Assert.Equal(1, result.Expansions);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.False(result.IndependentlyReplayed);
|
||||
unchanged();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_CancelledCaptureAndWorkerHaveNoMutation()
|
||||
{
|
||||
var parts = Fixture();
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
|
||||
var unchanged = Unchanged(parts);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
Assert.Equal(CuttingPlanStatus.Cancelled,
|
||||
CuttingPlanService.Plan(new CuttingPlanRequest(parts), cancellation.Token).Status);
|
||||
Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancellation.Token).Status);
|
||||
unchanged();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_OwnsMotionsPosesLocksAndOrder_NotLiveProgramsOrSettings()
|
||||
{
|
||||
var parts = Fixture();
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
|
||||
var ownedEnds = snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)).ToArray();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
part.Program.Codes.Clear();
|
||||
part.BaseDrawing.Program.Codes.Clear();
|
||||
part.Location = new Vector(999, 999);
|
||||
part.CuttingParameters.TabsEnabled = true;
|
||||
part.LeadInsLocked = true;
|
||||
}
|
||||
Array.Reverse(parts);
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
|
||||
Assert.Equal(ownedEnds, snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)));
|
||||
Assert.Equal(new Vector(4, 0), snapshot.Placements[0].Location);
|
||||
Assert.All(snapshot.Placements, p => Assert.False(p.LeadInsLocked));
|
||||
Assert.True(((ICollection<FixedProgramPlacement>)snapshot.Placements).IsReadOnly);
|
||||
Assert.True(((ICollection<ExecutionMotion>)snapshot.Placements[0].Execution.Motions).IsReadOnly);
|
||||
Assert.True(((ICollection<FixedProgramPlacement>)result.ProposedOrder).IsReadOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_DeterministicReferenceIdentity_NotDrawingNamesOrNameEquality()
|
||||
{
|
||||
var parts = Fixture();
|
||||
foreach (var part in parts)
|
||||
part.BaseDrawing.Name = "same";
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
|
||||
for (var repeat = 0; repeat < 5; repeat++)
|
||||
Assert.Equal(new[] { parts[1], parts[0], parts[2] },
|
||||
CuttingPlanService.Plan(snapshot).ProposedOrder.Select(p => p.SourcePart));
|
||||
Assert.Equal(CuttingPlanStatus.InvalidInput,
|
||||
CuttingPlanService.Plan(new CuttingPlanRequest([parts[0], parts[0]])).Status);
|
||||
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest([])).Status);
|
||||
Assert.Equal(CuttingPlanStatus.InvalidInput,
|
||||
CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 0)).Status);
|
||||
Assert.Equal(CuttingPlanStatus.InvalidInput,
|
||||
CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(double.NaN, 0))).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitStart_UsesActualLeadoutDeparture_AndDefaultRemainsZero()
|
||||
{
|
||||
var part = Fixture()[1];
|
||||
var defaultResult = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
|
||||
var explicitResult = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(2.25, 2)));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, defaultResult.Status);
|
||||
Assert.Equal(CuttingPlanStatus.Ready, explicitResult.Status);
|
||||
Assert.Equal(Vector.Zero.DistanceTo(new Vector(2.25, 2)), defaultResult.RapidDistance, 10);
|
||||
Assert.Equal(0, explicitResult.RapidDistance);
|
||||
Assert.Equal(new Vector(2.25, 2), explicitResult.ProposedOrder[0].Execution.DeparturePoint);
|
||||
Assert.Empty(Analyze(part).Findings);
|
||||
Assert.Empty(AnalyzeFrom(Vector.Zero, part).Findings);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unsafe")]
|
||||
[InlineData("duplicate")]
|
||||
[InlineData("omitted")]
|
||||
[InlineData("unknown")]
|
||||
public void IndependentReplay_RefusesUnsafeOrIncompleteProposals(string fault)
|
||||
{
|
||||
var parts = Fixture();
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
|
||||
var unchanged = Unchanged(parts);
|
||||
var order = fault switch
|
||||
{
|
||||
"unsafe" => new[] { 0, 1, 2 },
|
||||
"duplicate" => [1, 0, 0],
|
||||
"omitted" => [1, 0],
|
||||
_ => [1, 0, 3]
|
||||
};
|
||||
var result = CuttingPlanService.Replay(snapshot, order, 0, default);
|
||||
Assert.Equal(fault == "unsafe" ? CuttingPlanStatus.ConstraintConflict : CuttingPlanStatus.InvalidInput,
|
||||
result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.False(result.IndependentlyReplayed);
|
||||
if (fault == "unsafe")
|
||||
{
|
||||
Assert.Equal(new int?[] { 1, 2 }, result.Findings.Select(f => f.SourceOrdinal));
|
||||
Assert.All(result.Findings, f => Assert.Same(parts[0], f.OtherSourcePart));
|
||||
}
|
||||
unchanged();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualRapidDistance_TiesKeepSourceOrdinal()
|
||||
{
|
||||
var parts = new[] { Rectangle("same", 10, -3, 2, 2), Rectangle("same", 10, 1, 2, 2) };
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Equal(parts, result.ProposedOrder.Select(p => p.SourcePart));
|
||||
Assert.Empty(Analyze(parts).Findings);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Mode.Absolute)]
|
||||
[InlineData(Mode.Incremental)]
|
||||
public void OwnedReader_SharedSignedSubcallsPreserveTrueLeadoutDepartureAndOwnership(Mode mode)
|
||||
{
|
||||
var sub = new Program();
|
||||
sub.MoveTo(1.25, 0);
|
||||
sub.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
|
||||
sub.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
|
||||
sub.Codes.Add(new LinearMove(1.25, 0) { Layer = LayerType.Leadout });
|
||||
sub.Mode = mode;
|
||||
var program = new Program();
|
||||
program.SubPrograms[-7] = sub;
|
||||
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(5, 5) });
|
||||
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(15, 5) });
|
||||
var before = sub.ToString();
|
||||
var owned = ExecutionMotionReader.Read(program, new Vector(100, 50), Vector.Zero, default);
|
||||
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
|
||||
Assert.Equal(new Vector(106.25, 55), owned.Motions[3].End);
|
||||
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
|
||||
sub.Codes.Clear();
|
||||
program.Codes.Clear();
|
||||
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
|
||||
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
|
||||
Assert.NotEmpty(before);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_CopiedReleasedStateDoesNotLeakBranchObstacles()
|
||||
{
|
||||
var parts = Fixture();
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
|
||||
var parent = new ReleasedContourState();
|
||||
var branch = parent.Copy();
|
||||
Assert.Empty(branch.Check(snapshot.Placements[0].Execution, Vector.Zero, 1));
|
||||
Assert.NotEmpty(branch.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
|
||||
Assert.Empty(parent.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
|
||||
}
|
||||
|
||||
private static Action Unchanged(Part[] parts)
|
||||
{
|
||||
var order = parts.ToArray();
|
||||
var programs = parts.Select(p => p.Program).ToArray();
|
||||
var codeRefs = parts.Select(p => p.Program.Codes.ToArray()).ToArray();
|
||||
var text = parts.Select(p => p.Program.ToString()).ToArray();
|
||||
var locations = parts.Select(p => p.Location).ToArray();
|
||||
var rotations = parts.Select(p => p.Rotation).ToArray();
|
||||
var bounds = parts.Select(p => p.BoundingBox).ToArray();
|
||||
var parameters = parts.Select(p => p.CuttingParameters).ToArray();
|
||||
var locks = parts.Select(p => p.LeadInsLocked).ToArray();
|
||||
var manual = parts.Select(p => p.HasManualLeadIns).ToArray();
|
||||
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
|
||||
var subs = parts.SelectMany(p => p.Program.Codes.OfType<SubProgramCall>())
|
||||
.Select(call => (call, call.Program, text: call.Program.ToString(), call.Offset, call.Rotation, call.Id)).ToArray();
|
||||
return () =>
|
||||
{
|
||||
Assert.Equal(order, parts);
|
||||
for (var index = 0; index < parts.Length; index++)
|
||||
{
|
||||
var part = parts[index];
|
||||
Assert.Same(programs[index], part.Program);
|
||||
Assert.Equal(codeRefs[index], part.Program.Codes);
|
||||
Assert.Equal(text[index], part.Program.ToString());
|
||||
Assert.Equal(locations[index], part.Location);
|
||||
Assert.Equal(rotations[index], part.Rotation);
|
||||
Assert.Same(bounds[index], part.BoundingBox);
|
||||
Assert.Same(parameters[index], part.CuttingParameters);
|
||||
Assert.Equal(locks[index], part.LeadInsLocked);
|
||||
Assert.Equal(manual[index], part.HasManualLeadIns);
|
||||
Assert.Equal(quantities[index], part.BaseDrawing.Quantity.Nested);
|
||||
}
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
Assert.Same(sub.Program, sub.call.Program);
|
||||
Assert.Equal(sub.text, sub.call.Program.ToString());
|
||||
Assert.Equal(sub.Offset, sub.call.Offset);
|
||||
Assert.Equal(sub.Rotation, sub.call.Rotation);
|
||||
Assert.Equal(sub.Id, sub.call.Id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Part[] Fixture() =>
|
||||
[
|
||||
Rectangle("A", 4, 0, 4, 4),
|
||||
Rectangle("B", 0, 1, 2, 2),
|
||||
Rectangle("C", 10, 1, 2, 2)
|
||||
];
|
||||
|
||||
private static Part Rectangle(string name, double x, double y, double width, double height)
|
||||
{
|
||||
var clean = new Program();
|
||||
clean.MoveTo(width, height / 2);
|
||||
Contour(clean, width, height);
|
||||
var part = new Part(new Drawing(name, clean), new Vector(x, y));
|
||||
var placed = new Program();
|
||||
placed.MoveTo(width + 0.25, height / 2);
|
||||
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
|
||||
Contour(placed, width, height);
|
||||
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
|
||||
Assert.True(part.RestoreLeadInProgram(placed, false));
|
||||
part.CuttingParameters = new CuttingParameters();
|
||||
return part;
|
||||
}
|
||||
|
||||
private static void Contour(Program program, double width, double height)
|
||||
{
|
||||
program.LineTo(width, 0);
|
||||
program.LineTo(0, 0);
|
||||
program.LineTo(0, height);
|
||||
program.LineTo(width, height);
|
||||
program.LineTo(width, height / 2);
|
||||
}
|
||||
|
||||
// Independent replay uses private placements/drawings, never an event-wired plate
|
||||
// around the source drawings (which would change their quantity accounting).
|
||||
private static PostVerificationReport Analyze(params Part[] parts) => AnalyzeFrom(Vector.Zero, parts);
|
||||
|
||||
private static PostVerificationReport AnalyzeFrom(Vector start, params Part[] parts)
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = nest.CreatePlate();
|
||||
foreach (var source in parts)
|
||||
{
|
||||
var copy = new Part(new Drawing("replay", (Program)source.BaseDrawing.Program.Clone()), source.Location);
|
||||
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
|
||||
plate.Parts.Add(copy);
|
||||
}
|
||||
return PostVerificationAnalyzer.Analyze(nest, start);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user