mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-05 02:32:11 -04:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
run: dotnet test OpenNest.Tests/OpenNest.Tests.csproj
|
||||||
- name: Run OpenNest.Engine.Tests
|
- name: Run OpenNest.Engine.Tests
|
||||||
run: dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
|
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
|
- name: Run OpenNest.IO.Tests
|
||||||
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
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
|
||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||||
- name: Run all test projects on Windows
|
- name: Run all test projects on Windows
|
||||||
run: |
|
run: |
|
||||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
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 --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
|
|||||||
dotnet build OpenNest.sln
|
dotnet build OpenNest.sln
|
||||||
|
|
||||||
# Cross-platform suites: run independently on Linux/macOS/Windows
|
# Cross-platform suites: run independently on Linux/macOS/Windows
|
||||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
|
# Use Release for routine runs; use Debug explicitly for DEBUG-only work counters.
|
||||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
|
||||||
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
|
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
|
# Windows runtime tests
|
||||||
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
|
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,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,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,124 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
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, false, 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;
|
||||||
|
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 Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
|
||||||
|
}
|
||||||
@@ -234,6 +234,22 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return null;
|
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)
|
private void EmitRawContour(Program program, Shape shape)
|
||||||
{
|
{
|
||||||
var startPoint = GetShapeStartPoint(shape);
|
var startPoint = GetShapeStartPoint(shape);
|
||||||
@@ -300,12 +316,57 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return HashCode.Combine(r, a);
|
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(
|
private void EmitContour(
|
||||||
Program program,
|
Program program,
|
||||||
Shape shape,
|
Shape shape,
|
||||||
Vector point,
|
Vector point,
|
||||||
Entity entity,
|
Entity entity,
|
||||||
ContourType? forceType = null
|
ContourType? forceType = null,
|
||||||
|
bool exactCirclePrograms = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var contourType = forceType ?? DetectContourType(shape);
|
var contourType = forceType ?? DetectContourType(shape);
|
||||||
@@ -351,7 +412,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
subPgm.Mode = Mode.Incremental;
|
subPgm.Mode = Mode.Incremental;
|
||||||
|
|
||||||
// Deduplicate: check if an identical sub-program already exists
|
// 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))
|
if (!program.SubPrograms.ContainsKey(key))
|
||||||
program.SubPrograms[key] = subPgm;
|
program.SubPrograms[key] = subPgm;
|
||||||
|
|
||||||
@@ -376,17 +439,31 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var tabbed = Parameters.TabsEnabled
|
var tabbed = Parameters.TabsEnabled
|
||||||
&& Parameters.TabConfig != null
|
&& Parameters.TabConfig != null
|
||||||
&& contourType == ContourType.External;
|
&& 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.
|
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
|
||||||
|
var leadOutPoint = point;
|
||||||
var leadOutNormal = normal;
|
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,
|
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
|
||||||
Parameters.PierceClearance, out leadOutNormal);
|
Parameters.PierceClearance, out leadOutNormal);
|
||||||
|
}
|
||||||
|
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
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)
|
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ public sealed class OverlapGeometryStamp
|
|||||||
return true;
|
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 struct Entry
|
||||||
{
|
{
|
||||||
private readonly Part part;
|
private readonly Part part;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Diagnostics;
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
@@ -9,12 +11,16 @@ public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Fail
|
|||||||
public sealed class OverlapReportState
|
public sealed class OverlapReportState
|
||||||
{
|
{
|
||||||
private OverlapGeometryStamp stamp;
|
private OverlapGeometryStamp stamp;
|
||||||
|
private OverlapGeometryStamp displayStamp;
|
||||||
|
private OverlapGeometryStamp observedDisplayStamp;
|
||||||
private int uncheckedPartCount;
|
private int uncheckedPartCount;
|
||||||
|
|
||||||
public long Generation { get; private set; }
|
public long Generation { get; private set; }
|
||||||
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
|
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
|
||||||
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
|
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
|
||||||
public PlateOverlapReport Report { get; private set; }
|
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 bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||||
|
|
||||||
public string Message => Status switch
|
public string Message => Status switch
|
||||||
@@ -36,7 +42,8 @@ public sealed class OverlapReportState
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long Begin(Plate plate, bool automatic = false)
|
public long Begin(Plate plate, bool automatic = false)
|
||||||
{
|
{
|
||||||
Clear(OverlapCheckStatus.Checking);
|
RefreshDisplayPairs(plate);
|
||||||
|
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
|
||||||
stamp = OverlapGeometryStamp.Capture(plate);
|
stamp = OverlapGeometryStamp.Capture(plate);
|
||||||
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
|
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
|
||||||
DisplayMode = OverlapDisplayMode.Areas;
|
DisplayMode = OverlapDisplayMode.Areas;
|
||||||
@@ -48,6 +55,9 @@ public sealed class OverlapReportState
|
|||||||
if (!CanComplete(generation, plate))
|
if (!CanComplete(generation, plate))
|
||||||
return false;
|
return false;
|
||||||
Report = report;
|
Report = report;
|
||||||
|
DisplayPairs = report.Pairs;
|
||||||
|
displayStamp = stamp;
|
||||||
|
observedDisplayStamp = stamp;
|
||||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||||
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
|
||||||
return true;
|
return true;
|
||||||
@@ -66,20 +76,52 @@ public sealed class OverlapReportState
|
|||||||
|
|
||||||
public bool EnsureFresh(Plate plate)
|
public bool EnsureFresh(Plate plate)
|
||||||
{
|
{
|
||||||
|
RefreshDisplayPairs(plate);
|
||||||
if (stamp == null)
|
if (stamp == null)
|
||||||
return false;
|
return false;
|
||||||
if (stamp.Matches(plate))
|
if (stamp.Matches(plate))
|
||||||
return true;
|
return true;
|
||||||
Invalidate();
|
Invalidate(plate);
|
||||||
return false;
|
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()
|
public void Invalidate()
|
||||||
{
|
{
|
||||||
|
ClearDisplayPairs();
|
||||||
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
|
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
|
||||||
Clear(OverlapCheckStatus.Stale);
|
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()
|
public void Cancel()
|
||||||
{
|
{
|
||||||
if (IsRunning)
|
if (IsRunning)
|
||||||
@@ -88,8 +130,10 @@ public sealed class OverlapReportState
|
|||||||
|
|
||||||
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
||||||
|
|
||||||
private void Clear(OverlapCheckStatus status)
|
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
|
||||||
{
|
{
|
||||||
|
if (!preserveDisplay)
|
||||||
|
ClearDisplayPairs();
|
||||||
Generation++;
|
Generation++;
|
||||||
Report = null;
|
Report = null;
|
||||||
uncheckedPartCount = 0;
|
uncheckedPartCount = 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Diagnostics;
|
namespace OpenNest.Diagnostics;
|
||||||
@@ -27,9 +28,14 @@ public static class PostVerificationAnalyzer
|
|||||||
return new PostVerificationReport(findings);
|
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);
|
ArgumentNullException.ThrowIfNull(nest);
|
||||||
|
PostVerificationGeometry.Validate(startPoint);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var findings = new List<PostVerificationFinding>();
|
var findings = new List<PostVerificationFinding>();
|
||||||
if (nest.Plates == null)
|
if (nest.Plates == null)
|
||||||
@@ -50,8 +56,8 @@ public static class PostVerificationAnalyzer
|
|||||||
}
|
}
|
||||||
var materialParts = new List<Part>();
|
var materialParts = new List<Part>();
|
||||||
var indices = new List<int>();
|
var indices = new List<int>();
|
||||||
var obstacles = new List<Obstacle>();
|
var obstacles = new ReleasedContourState();
|
||||||
Vector? position = Vector.Zero;
|
Vector? position = startPoint;
|
||||||
for (var index = 0; index < plate.Parts.Count; index++)
|
for (var index = 0; index < plate.Parts.Count; index++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
@@ -65,7 +71,7 @@ public static class PostVerificationAnalyzer
|
|||||||
{
|
{
|
||||||
try
|
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);
|
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
|
||||||
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|
||||||
|| expectsCuts)
|
|| expectsCuts)
|
||||||
@@ -83,12 +89,12 @@ public static class PostVerificationAnalyzer
|
|||||||
{
|
{
|
||||||
if (part == null || !double.IsFinite(part.Rotation))
|
if (part == null || !double.IsFinite(part.Rotation))
|
||||||
throw new ArgumentException("Missing part or invalid 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
|
if (expectsCuts && !moves.Any(move => !move.Rapid
|
||||||
&& move.Layer is LayerType.Cut or LayerType.Display
|
&& move.Layer is LayerType.Cut or LayerType.Display
|
||||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
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;
|
position = moves[^1].End;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (IsInvalid(exception))
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
@@ -119,172 +125,4 @@ public static class PostVerificationAnalyzer
|
|||||||
private static bool IsInvalid(Exception exception) => exception is
|
private static bool IsInvalid(Exception exception) => exception is
|
||||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
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);
|
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,123 @@ internal static class PostVerificationGeometry
|
|||||||
private double Sweep { get; }
|
private double Sweep { get; }
|
||||||
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
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);
|
||||||
|
|
||||||
|
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)
|
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
|
||||||
{
|
{
|
||||||
if (center is not { } c)
|
if (center is not { } c)
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
using System.Threading;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Geometry
|
namespace OpenNest.Geometry
|
||||||
{
|
{
|
||||||
public abstract class Entity : IBoundable
|
public abstract class Entity : IBoundable
|
||||||
{
|
{
|
||||||
|
private static long idCounter;
|
||||||
|
private static readonly Guid idSalt = Guid.NewGuid();
|
||||||
|
|
||||||
protected Box boundingBox;
|
protected Box boundingBox;
|
||||||
|
|
||||||
protected Entity()
|
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;
|
Layer = OpenNest.Geometry.Layer.Default;
|
||||||
boundingBox = new Box();
|
boundingBox = new Box();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||||
|
<InternalsVisibleTo Include="OpenNest.Engine" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Clipper2" Version="2.0.0" />
|
<PackageReference Include="Clipper2" Version="2.0.0" />
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ namespace OpenNest.Data;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface INestRepository
|
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);
|
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);
|
Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary>
|
/// <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
|
/// present but unreadable/invalid, so callers can warn about a corrupt
|
||||||
/// file while still returning usable values.
|
/// file while still returning usable values.
|
||||||
/// </summary>
|
/// </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;
|
var defaults = Fallback;
|
||||||
|
if (Enum.IsDefined(fallbackUnits))
|
||||||
|
defaults.Units = fallbackUnits;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||||
{
|
{
|
||||||
status = NestDefaultsStatus.Missing;
|
status = NestDefaultsStatus.Missing;
|
||||||
@@ -88,13 +105,10 @@ public sealed class NestDefaults
|
|||||||
var json = File.ReadAllText(path);
|
var json = File.ReadAllText(path);
|
||||||
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
|
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||||
{
|
|
||||||
status = NestDefaultsStatus.Invalid;
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
catch (IOException)
|
|
||||||
{
|
{
|
||||||
|
// A file that exists but cannot be read (locked, access-denied)
|
||||||
|
// or parsed must never block creating a nest.
|
||||||
status = NestDefaultsStatus.Invalid;
|
status = NestDefaultsStatus.Invalid;
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
@@ -107,10 +121,7 @@ public sealed class NestDefaults
|
|||||||
|
|
||||||
status = NestDefaultsStatus.Ok;
|
status = NestDefaultsStatus.Ok;
|
||||||
|
|
||||||
if (
|
if (TryParseUnits(dto.Units, out var units))
|
||||||
dto.Units is not null
|
|
||||||
&& Enum.TryParse<Units>(dto.Units, ignoreCase: true, out var units)
|
|
||||||
)
|
|
||||||
defaults.Units = units;
|
defaults.Units = units;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -195,7 +206,7 @@ public sealed class NestDefaults
|
|||||||
var dto = new NestDefaultsDto
|
var dto = new NestDefaultsDto
|
||||||
{
|
{
|
||||||
Version = CurrentVersion,
|
Version = CurrentVersion,
|
||||||
Units = Units.ToString().ToLowerInvariant(),
|
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
|
||||||
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
|
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
|
||||||
Quadrant = Quadrant,
|
Quadrant = Quadrant,
|
||||||
PartSpacing = PartSpacing,
|
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) =>
|
private static bool IsValidSize(double width, double length) =>
|
||||||
!double.IsNaN(width)
|
!double.IsNaN(width)
|
||||||
&& !double.IsNaN(length)
|
&& !double.IsNaN(length)
|
||||||
@@ -247,7 +290,9 @@ public sealed class NestDefaults
|
|||||||
private sealed record NestDefaultsDto
|
private sealed record NestDefaultsDto
|
||||||
{
|
{
|
||||||
public int? Version { get; init; } = CurrentVersion;
|
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 SizeDto? Size { get; init; }
|
||||||
public int? Quadrant { get; init; }
|
public int? Quadrant { get; init; }
|
||||||
public double? PartSpacing { get; init; }
|
public double? PartSpacing { get; init; }
|
||||||
|
|||||||
@@ -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;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
@@ -58,6 +59,40 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
|||||||
return items ?? new List<NestRecord>();
|
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)
|
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
|
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
|
||||||
|
|||||||
@@ -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,165 @@
|
|||||||
|
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. This is not an Apply request.
|
||||||
|
/// </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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Placements = Array.AsReadOnly(placements.ToArray());
|
||||||
|
StartPoint = startPoint;
|
||||||
|
ExpansionBudget = expansionBudget;
|
||||||
|
Failure = failure;
|
||||||
|
Findings = Array.AsReadOnly((findings ?? []).ToArray());
|
||||||
|
Regeneration = regeneration;
|
||||||
|
PreservePartOrder = preservePartOrder;
|
||||||
|
MaxEntries = maxEntries;
|
||||||
|
ExpansionObserver = expansionObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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. Not physical
|
||||||
|
/// safety, posting consent, dependency readiness or an atomic Apply payload. 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
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();
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
// 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.");
|
||||||
|
if (source.BaseDrawing.IsCutOff)
|
||||||
|
throw new NotSupportedException("Cutoff dependency ordering is outside the fixed-program route slice.");
|
||||||
|
// 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);
|
||||||
|
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;
|
||||||
|
if (request.ConfirmedParameters != 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 = LeadMaterialSnapshot.Capture(ownedClean, source.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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
|
||||||
|
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
|
||||||
|
expansionObserver: request.ExpansionObserver);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return Failure(CuttingPlanStatus.Cancelled, "Capture cancelled.");
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
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, 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);
|
||||||
|
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, 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);
|
||||||
|
var checker = new ReleasedContourState();
|
||||||
|
var position = snapshot.StartPoint;
|
||||||
|
var distance = 0.0;
|
||||||
|
var findings = new List<CuttingPlanFinding>();
|
||||||
|
var materials = snapshot.Placements.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)
|
||||||
|
|| 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);
|
||||||
|
complete = 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, token)));
|
||||||
|
var lead = 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 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,64 @@
|
|||||||
|
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, 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.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,164 @@
|
|||||||
|
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>One forward DFS over whole-part selection and emitted contour prefixes.</summary>
|
||||||
|
internal static class JointCuttingPlanSearch
|
||||||
|
{
|
||||||
|
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
|
||||||
|
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions);
|
||||||
|
|
||||||
|
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||||
|
{
|
||||||
|
var expansions = 0;
|
||||||
|
var rejected = new List<CuttingPlanFinding>();
|
||||||
|
var materials = snapshot.Placements.Select(p => p.Material).ToArray();
|
||||||
|
var stack = new Stack<Frame>();
|
||||||
|
stack.Push(new(new([], snapshot.StartPoint, new ReleasedContourState(), null)));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (stack.Count != 0)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var frame = stack.Peek();
|
||||||
|
var node = frame.Node;
|
||||||
|
if (node.Order.Length == snapshot.Placements.Count)
|
||||||
|
return new(CuttingPlanStatus.Ready, node.Order, [], expansions);
|
||||||
|
frame.Children ??= Expand(node).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));
|
||||||
|
}
|
||||||
|
// Entries are capped; exhaustion is not a proof over all possible entries.
|
||||||
|
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.Distinct().ToArray(), expansions);
|
||||||
|
}
|
||||||
|
catch (BudgetExceededException)
|
||||||
|
{
|
||||||
|
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.Distinct().ToArray(), expansions);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return new(CuttingPlanStatus.Cancelled, [], [], expansions);
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<Edge> Expand(Node node)
|
||||||
|
{
|
||||||
|
var sources = node.Active is { } active ? new[] { active.Source }
|
||||||
|
: snapshot.Placements.Where(p => !node.Order.Any(o => o.SourceOrdinal == p.SourceOrdinal)
|
||||||
|
&& (!snapshot.PreservePartOrder || p.SourceOrdinal == 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))
|
||||||
|
yield return new(new([.. node.Order, source], source.Execution.DeparturePoint, checker, null),
|
||||||
|
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 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)) 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)
|
||||||
|
: new Node(node.Order, execution.DeparturePoint, checker,
|
||||||
|
new(source, prefix, arrival, before, distance));
|
||||||
|
yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival, ReleasedContourState checker)
|
||||||
|
{
|
||||||
|
var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, token);
|
||||||
|
rejected.AddRange(Map(snapshot, findings));
|
||||||
|
var lead = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
private sealed class BudgetExceededException : Exception;
|
||||||
|
private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival,
|
||||||
|
ReleasedContourState Before, double Distance);
|
||||||
|
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker, ActivePart Active);
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
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);
|
||||||
|
return PlateFillService.FillItem("Default", plate, 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 IReadOnlyList<PartType> types;
|
||||||
private readonly NoFitCache nfps;
|
private readonly NoFitCache nfps;
|
||||||
|
private readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs;
|
||||||
private readonly NestPlateStock stock;
|
private readonly NestPlateStock stock;
|
||||||
private readonly PackAxis axis;
|
private readonly PackAxis axis;
|
||||||
private readonly double beta;
|
private readonly double beta;
|
||||||
private readonly Box work;
|
private readonly Box work;
|
||||||
private readonly WorkCounter counter;
|
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.counter = counter;
|
||||||
this.types = types;
|
this.types = types;
|
||||||
this.nfps = nfps;
|
this.nfps = nfps;
|
||||||
|
this.pairs = pairs;
|
||||||
this.stock = stock;
|
this.stock = stock;
|
||||||
this.axis = axis;
|
this.axis = axis;
|
||||||
this.beta = beta;
|
this.beta = beta;
|
||||||
@@ -76,39 +89,102 @@ internal sealed class FrontierPacker
|
|||||||
{
|
{
|
||||||
var left = remaining.ToArray();
|
var left = remaining.ToArray();
|
||||||
var states = new List<Region>();
|
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)
|
foreach (var type in types)
|
||||||
{
|
{
|
||||||
if (left[type.Index] <= 0)
|
if (left[type.Index] <= 0)
|
||||||
continue;
|
continue;
|
||||||
foreach (var o in type.Orientations)
|
foreach (var o in type.Orientations)
|
||||||
if (stock.Fits(o.Width, o.Height))
|
Track(o, single: true);
|
||||||
states.Add(new Region(o, work));
|
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 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 partArea = 0.0;
|
||||||
var front = axis == PackAxis.X ? work.Left : work.Bottom;
|
var front = axis == PackAxis.X ? work.Left : work.Bottom;
|
||||||
|
|
||||||
while (states.Count > 0)
|
while (states.Count > 0)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var choice = Choose(states, front);
|
var choice = Choose(states, offered, blockStates, front);
|
||||||
if (choice == null)
|
if (choice == null)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
var (region, point) = choice.Value;
|
var typeIndex = choice[0].Orientation.TypeIndex;
|
||||||
var part = new Placed(region.Orientation, point.x, point.y);
|
foreach (var part in choice)
|
||||||
|
{
|
||||||
placed.Add(part);
|
placed.Add(part);
|
||||||
var typeIndex = region.Orientation.TypeIndex;
|
|
||||||
partArea += types[typeIndex].Area;
|
partArea += types[typeIndex].Area;
|
||||||
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
|
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);
|
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.
|
// independent, so they update in parallel without affecting determinism.
|
||||||
var snapshot = states.ToArray();
|
var snapshot = states.ToArray();
|
||||||
|
foreach (var part in choice)
|
||||||
|
{
|
||||||
counter.Add(snapshot.Length);
|
counter.Add(snapshot.Length);
|
||||||
Parallel.For(
|
Parallel.For(
|
||||||
0,
|
0,
|
||||||
@@ -116,34 +192,42 @@ internal sealed class FrontierPacker
|
|||||||
new ParallelOptions { CancellationToken = token },
|
new ParallelOptions { CancellationToken = token },
|
||||||
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
|
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
states.RemoveAll(s => s.IsEmpty);
|
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);
|
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;
|
IReadOnlyList<Placed>? bestParts = null;
|
||||||
var bestPoint = default(PointD);
|
|
||||||
var bestFills = false;
|
var bestFills = false;
|
||||||
var bestValue = double.PositiveInfinity;
|
var bestValue = double.PositiveInfinity;
|
||||||
var bestSide = double.PositiveInfinity;
|
var bestSide = double.PositiveInfinity;
|
||||||
var bestLead = double.PositiveInfinity;
|
var bestLead = double.PositiveInfinity;
|
||||||
var bestPriority = int.MaxValue;
|
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))
|
var priority = types[typeIndex].Part.Priority;
|
||||||
continue;
|
if (priority > bestPriority)
|
||||||
var area = types[region.Orientation.TypeIndex].Area;
|
return;
|
||||||
var priority = types[region.Orientation.TypeIndex].Part.Priority;
|
|
||||||
if (priority > bestPriority) continue;
|
|
||||||
var fills = advance <= Tie;
|
var fills = advance <= Tie;
|
||||||
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
|
// 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 value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
|
||||||
|
|
||||||
var better = bestRegion == null
|
var better = bestParts == null
|
||||||
|| priority < bestPriority
|
|| priority < bestPriority
|
||||||
|| (fills && !bestFills)
|
|| (fills && !bestFills)
|
||||||
|| (
|
|| (
|
||||||
@@ -157,17 +241,83 @@ internal sealed class FrontierPacker
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (!better)
|
if (!better)
|
||||||
continue;
|
return;
|
||||||
bestRegion = region;
|
bestParts = build();
|
||||||
bestPriority = priority;
|
bestPriority = priority;
|
||||||
bestPoint = point;
|
|
||||||
bestFills = fills;
|
bestFills = fills;
|
||||||
bestValue = value;
|
bestValue = value;
|
||||||
bestSide = side;
|
bestSide = side;
|
||||||
bestLead = lead;
|
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>
|
/// <summary>Legal reference points for one orientation on this sheet.</summary>
|
||||||
@@ -177,9 +327,10 @@ internal sealed class FrontierPacker
|
|||||||
private PathsD free;
|
private PathsD free;
|
||||||
private RectD bounds;
|
private RectD bounds;
|
||||||
|
|
||||||
public Region(Orientation orientation, Box work)
|
public Region(Orientation orientation, Box work, bool single)
|
||||||
{
|
{
|
||||||
Orientation = orientation;
|
Orientation = orientation;
|
||||||
|
Single = single;
|
||||||
minX = work.Left - orientation.MinX;
|
minX = work.Left - orientation.MinX;
|
||||||
maxX = work.Right - orientation.MaxX;
|
maxX = work.Right - orientation.MaxX;
|
||||||
minY = work.Bottom - orientation.MinY;
|
minY = work.Bottom - orientation.MinY;
|
||||||
@@ -203,6 +354,10 @@ internal sealed class FrontierPacker
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Orientation Orientation { get; }
|
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 bool IsEmpty => free.Count == 0;
|
||||||
|
|
||||||
public void Subtract(Nfp nfp, double dx, double dy)
|
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.
|
// Drop numerical dust; a sliver thinner than the precision grid is no real room.
|
||||||
free.RemoveAll(p => p.Count < 3);
|
free.RemoveAll(p => p.Count < 3);
|
||||||
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
|
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>
|
/// <summary>
|
||||||
/// Best vertex of the free region: least front advance, then lowest cross-axis position,
|
/// 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.
|
/// then lowest leading edge. Vertices suffice because every score is linear in position.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
|
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;
|
point = default;
|
||||||
advance = side = lead = double.PositiveInfinity;
|
advance = side = lead = double.PositiveInfinity;
|
||||||
var found = false;
|
var found = false;
|
||||||
var o = Orientation;
|
|
||||||
foreach (var path in free)
|
foreach (var path in free)
|
||||||
foreach (var raw in path)
|
foreach (var raw in path)
|
||||||
{
|
{
|
||||||
@@ -239,15 +430,15 @@ internal sealed class FrontierPacker
|
|||||||
double reach, across, start;
|
double reach, across, start;
|
||||||
if (axis == PackAxis.X)
|
if (axis == PackAxis.X)
|
||||||
{
|
{
|
||||||
reach = x + o.MaxX;
|
reach = x + box.MaxX;
|
||||||
across = y + o.MinY;
|
across = y + box.MinY;
|
||||||
start = x + o.MinX;
|
start = x + box.MinX;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
reach = y + o.MaxY;
|
reach = y + box.MaxY;
|
||||||
across = x + o.MinX;
|
across = x + box.MinX;
|
||||||
start = y + o.MinY;
|
start = y + box.MinY;
|
||||||
}
|
}
|
||||||
var adv = System.Math.Max(0, reach - front);
|
var adv = System.Math.Max(0, reach - front);
|
||||||
var better = !found
|
var better = !found
|
||||||
@@ -263,5 +454,52 @@ internal sealed class FrontierPacker
|
|||||||
}
|
}
|
||||||
return found;
|
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
|
#nullable enable
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
@@ -48,9 +48,10 @@ public sealed class IrregularNestingEngine : INestingEngine
|
|||||||
ArgumentNullException.ThrowIfNull(job);
|
ArgumentNullException.ThrowIfNull(job);
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var types = PartCatalog.Build(job);
|
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];
|
var demand = new int[types.Count];
|
||||||
foreach (var type in types)
|
foreach (var type in types)
|
||||||
{
|
{
|
||||||
@@ -58,7 +59,8 @@ public sealed class IrregularNestingEngine : INestingEngine
|
|||||||
stock.Quantity != 0
|
stock.Quantity != 0
|
||||||
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
|
&& 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;
|
Plan? best = null;
|
||||||
@@ -85,10 +87,26 @@ public sealed class IrregularNestingEngine : INestingEngine
|
|||||||
IReadOnlyList<PartType> types,
|
IReadOnlyList<PartType> types,
|
||||||
IProgress<NestJobProgress>? progress,
|
IProgress<NestJobProgress>? progress,
|
||||||
CancellationToken token
|
CancellationToken token
|
||||||
)
|
) : IDisposable
|
||||||
{
|
{
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var catalog in blocks.Values)
|
||||||
|
catalog.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private const int MaxTail = 3;
|
private const int MaxTail = 3;
|
||||||
private readonly Dictionary<double, NoFitCache> caches = new();
|
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();
|
public WorkCounter Work { get; } = new();
|
||||||
|
|
||||||
@@ -157,6 +175,30 @@ public sealed class IrregularNestingEngine : INestingEngine
|
|||||||
return cache;
|
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>
|
/// <summary>
|
||||||
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
|
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
|
||||||
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
|
/// 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)
|
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||||
continue;
|
continue;
|
||||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
|
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);
|
var fill = packer.Fill(remaining, token);
|
||||||
if (fill.Parts.Count > 0)
|
if (fill.Parts.Count > 0)
|
||||||
trials.Add((fill, NetArea(job.Options, fill)));
|
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>
|
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
|
||||||
public required double Tolerance { get; init; }
|
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 MinX { get; init; }
|
||||||
public required double MinY { get; init; }
|
public required double MinY { get; init; }
|
||||||
public required double MaxX { get; init; }
|
public required double MaxX { get; init; }
|
||||||
@@ -41,6 +45,9 @@ internal sealed class PartType
|
|||||||
public required NestJobPart Part { get; init; }
|
public required NestJobPart Part { get; init; }
|
||||||
public required double Area { get; init; }
|
public required double Area { get; init; }
|
||||||
public required IReadOnlyList<Orientation> Orientations { 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>
|
/// <summary>
|
||||||
@@ -93,15 +100,36 @@ internal static class PartCatalog
|
|||||||
var outline = Polygonize(perimeter, angle, tolerance);
|
var outline = Polygonize(perimeter, angle, tolerance);
|
||||||
if (outline.Count < 3)
|
if (outline.Count < 3)
|
||||||
continue;
|
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));
|
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;
|
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) =>
|
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
|
||||||
JobPartGeometry.TryRead(geometry)?.Perimeter;
|
JobPartGeometry.TryRead(geometry)?.Perimeter;
|
||||||
|
|
||||||
@@ -139,9 +167,21 @@ internal static class PartCatalog
|
|||||||
return path;
|
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);
|
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
|
return new Orientation
|
||||||
{
|
{
|
||||||
TypeIndex = typeIndex,
|
TypeIndex = typeIndex,
|
||||||
@@ -149,10 +189,10 @@ internal static class PartCatalog
|
|||||||
Rotation = angle,
|
Rotation = angle,
|
||||||
Outline = outline,
|
Outline = outline,
|
||||||
Tolerance = tolerance,
|
Tolerance = tolerance,
|
||||||
MinX = bounds.left - tolerance,
|
MinX = bounds.left - padding,
|
||||||
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
|
MinY = bounds.top - padding, // Clipper RectD: top is the minimum Y.
|
||||||
MaxX = bounds.right + tolerance,
|
MaxX = bounds.right + padding,
|
||||||
MaxY = bounds.bottom + tolerance,
|
MaxY = bounds.bottom + padding,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
@@ -40,24 +39,27 @@ namespace OpenNest.Engine.Strategies
|
|||||||
IFillComparer comparer = null
|
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(
|
Parallel.For(
|
||||||
angles,
|
0,
|
||||||
angle =>
|
angles.Count,
|
||||||
|
i =>
|
||||||
{
|
{
|
||||||
var pattern = BuildRotatedPattern(groupParts, angle);
|
var pattern = BuildRotatedPattern(groupParts, angles[i]);
|
||||||
|
|
||||||
if (pattern.Parts.Count == 0)
|
if (pattern.Parts.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
var h = engine.Fill(pattern, NestDirection.Horizontal);
|
||||||
if (h != null && h.Count > 0)
|
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);
|
var v = engine.Fill(pattern, NestDirection.Vertical);
|
||||||
if (v != null && v.Count > 0)
|
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)
|
foreach (var res in results)
|
||||||
{
|
{
|
||||||
|
if (res.Parts == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
if (comparer != null)
|
if (comparer != null)
|
||||||
{
|
{
|
||||||
if (best == null || comparer.IsBetter(res.Parts, best, workArea))
|
if (best == null || comparer.IsBetter(res.Parts, best, workArea))
|
||||||
|
|||||||
@@ -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 .
|
# 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
|
WORKDIR /src
|
||||||
|
|
||||||
COPY OpenNest.Server/OpenNest.Server.csproj OpenNest.Server/
|
COPY OpenNest.Server/OpenNest.Server.csproj OpenNest.Server/
|
||||||
COPY OpenNest.Data/OpenNest.Data.csproj OpenNest.Data/
|
COPY OpenNest.Data/OpenNest.Data.csproj OpenNest.Data/
|
||||||
|
COPY OpenNest.Core/OpenNest.Core.csproj OpenNest.Core/
|
||||||
RUN dotnet restore OpenNest.Server/OpenNest.Server.csproj
|
RUN dotnet restore OpenNest.Server/OpenNest.Server.csproj
|
||||||
|
|
||||||
COPY OpenNest.Server/ OpenNest.Server/
|
COPY OpenNest.Server/ OpenNest.Server/
|
||||||
COPY OpenNest.Data/ OpenNest.Data/
|
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
|
WORKDIR /app
|
||||||
|
# Runtime files stay root-owned and read-only to the app user.
|
||||||
COPY --from=build /app .
|
COPY --from=build /app .
|
||||||
|
|
||||||
# SQLite database + .nest blobs live here; mount a volume at this path.
|
# SQLite database + .nest blobs live here; mount a volume at this path. The directory
|
||||||
VOLUME /app/data
|
# 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
|
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_URLS=http://+:8090
|
||||||
|
ENV ASPNETCORE_HTTP_PORTS=
|
||||||
EXPOSE 8090
|
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"]
|
ENTRYPOINT ["dotnet", "OpenNest.Server.dll"]
|
||||||
@@ -11,6 +11,9 @@ namespace OpenNest.Server;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NestDatabase : IDisposable
|
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;
|
private readonly SqliteConnection _connection;
|
||||||
|
|
||||||
public NestDatabase(string databasePath)
|
public NestDatabase(string databasePath)
|
||||||
@@ -45,6 +48,8 @@ public sealed class NestDatabase : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<NestRecord> List()
|
public IReadOnlyList<NestRecord> List()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
{
|
{
|
||||||
using var command = _connection.CreateCommand();
|
using var command = _connection.CreateCommand();
|
||||||
command.CommandText =
|
command.CommandText =
|
||||||
@@ -55,8 +60,11 @@ public sealed class NestDatabase : IDisposable
|
|||||||
records.Add(ReadRecord(reader));
|
records.Add(ReadRecord(reader));
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public NestRecord? Get(Guid id)
|
public NestRecord? Get(Guid id)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
{
|
{
|
||||||
using var command = _connection.CreateCommand();
|
using var command = _connection.CreateCommand();
|
||||||
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id";
|
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id";
|
||||||
@@ -64,8 +72,50 @@ public sealed class NestDatabase : IDisposable
|
|||||||
using var reader = command.ExecuteReader();
|
using var reader = command.ExecuteReader();
|
||||||
return reader.Read() ? ReadRecord(reader) : null;
|
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)
|
public byte[]? GetFile(Guid id)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
{
|
{
|
||||||
using var command = _connection.CreateCommand();
|
using var command = _connection.CreateCommand();
|
||||||
command.CommandText = "SELECT file FROM nests WHERE id = $id";
|
command.CommandText = "SELECT file FROM nests WHERE id = $id";
|
||||||
@@ -73,8 +123,11 @@ public sealed class NestDatabase : IDisposable
|
|||||||
var value = command.ExecuteScalar();
|
var value = command.ExecuteScalar();
|
||||||
return value is byte[] bytes ? bytes : null;
|
return value is byte[] bytes ? bytes : null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public NestRecord Insert(Guid id, NestRecord record, byte[] file)
|
public NestRecord Insert(Guid id, NestRecord record, byte[] file)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
{
|
{
|
||||||
using var command = _connection.CreateCommand();
|
using var command = _connection.CreateCommand();
|
||||||
command.CommandText = """
|
command.CommandText = """
|
||||||
@@ -89,8 +142,11 @@ public sealed class NestDatabase : IDisposable
|
|||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
|
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public NestRecord? Update(Guid id, NestRecord record, byte[]? file)
|
public NestRecord? Update(Guid id, NestRecord record, byte[]? file)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (Get(id) is null)
|
if (Get(id) is null)
|
||||||
return null;
|
return null;
|
||||||
@@ -110,16 +166,42 @@ public sealed class NestDatabase : IDisposable
|
|||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
return Get(id);
|
return Get(id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool Delete(Guid id)
|
public bool Delete(Guid id)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
{
|
{
|
||||||
using var command = _connection.CreateCommand();
|
using var command = _connection.CreateCommand();
|
||||||
command.CommandText = "DELETE FROM nests WHERE id = $id";
|
command.CommandText = "DELETE FROM nests WHERE id = $id";
|
||||||
command.Parameters.AddWithValue("$id", id.ToString());
|
command.Parameters.AddWithValue("$id", id.ToString());
|
||||||
return command.ExecuteNonQuery() > 0;
|
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)
|
private void Execute(string sql)
|
||||||
{
|
{
|
||||||
@@ -133,6 +215,47 @@ public sealed class NestDatabase : IDisposable
|
|||||||
status, plateCount, partCount, comments, madeBy, fileSize, savedAt
|
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(
|
private static void AddRecordParameters(
|
||||||
SqliteCommand command, Guid id, NestRecord record, byte[]? file, bool updateFile)
|
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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.31" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -25,15 +25,27 @@ var databasePath =
|
|||||||
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
|
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
|
||||||
?? Environment.GetEnvironmentVariable("OPENNEST_DB")
|
?? Environment.GetEnvironmentVariable("OPENNEST_DB")
|
||||||
?? Path.Combine("data", "nests.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.
|
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
|
||||||
var app = builder.Build();
|
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()));
|
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) =>
|
app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
|
||||||
db.Get(id) is { } record ? Results.Ok(record) : Results.NotFound());
|
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)
|
if (!http.Request.HasFormContentType)
|
||||||
return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and file parts."));
|
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();
|
var metadataPart = form["metadata"].FirstOrDefault();
|
||||||
if (string.IsNullOrWhiteSpace(metadataPart))
|
if (string.IsNullOrWhiteSpace(metadataPart))
|
||||||
return (null, null, Results.BadRequest("Missing 'metadata' part."));
|
return (null, null, Results.BadRequest("Missing 'metadata' part."));
|
||||||
@@ -127,3 +150,6 @@ internal static class MultipartJson
|
|||||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
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,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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
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 CuttingReplayContractTests
|
||||||
|
{
|
||||||
|
private static readonly Vector Start = new(-2, 5);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("partial")]
|
||||||
|
[InlineData("retrace")]
|
||||||
|
[InlineData("reverse")]
|
||||||
|
[InlineData("twice")]
|
||||||
|
[InlineData("wrong-point")]
|
||||||
|
[InlineData("wrong-index")]
|
||||||
|
[InlineData("different-entry")]
|
||||||
|
[InlineData("short-lead")]
|
||||||
|
public void Replay_RejectsCounterfeitSelectedPayload(string fault)
|
||||||
|
{
|
||||||
|
var (part, settings) = Fixture();
|
||||||
|
var before = ExplicitContourTests.Fingerprint(part.Program);
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||||
|
var source = snapshot.Placements[0];
|
||||||
|
var choices = new[] { source.Prepared.ClosestEntry(0, Start) };
|
||||||
|
var program = source.Prepared.Emit(choices);
|
||||||
|
switch (fault)
|
||||||
|
{
|
||||||
|
case "partial": program = Path(new Vector(0, 10)); break;
|
||||||
|
case "retrace": program = Path(new(0, 8), new(0, 7), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)); break;
|
||||||
|
case "reverse": program = Path(new(0, 0), new(10, 0), new(10, 10), new(0, 10), new(0, 5)); break;
|
||||||
|
case "twice": program = Path(new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)); break;
|
||||||
|
case "wrong-point": choices[0] = choices[0] with { Point = new Vector(999, 999) }; break;
|
||||||
|
case "wrong-index": choices[0] = choices[0] with { EntityOrdinal = 999 }; break;
|
||||||
|
case "different-entry": choices[0] = source.Prepared.Entry(0, 0, new Vector(0, 6)); break;
|
||||||
|
case "short-lead":
|
||||||
|
program.Mode = Mode.Absolute;
|
||||||
|
var rapid = program.Codes.OfType<RapidMove>().First();
|
||||||
|
var lead = program.Codes.OfType<LinearMove>().First(m => m.Layer == LayerType.Leadin);
|
||||||
|
rapid.EndPoint = lead.EndPoint + (rapid.EndPoint - lead.EndPoint) * 0.5;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var result = CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(program, Read(program), choices)], 0, default);
|
||||||
|
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.Empty(result.ProposedOrder);
|
||||||
|
Assert.False(result.IndependentlyReplayed);
|
||||||
|
Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, "partial")]
|
||||||
|
[InlineData(false, "partial")]
|
||||||
|
[InlineData(true, "reverse")]
|
||||||
|
[InlineData(false, "retrace")]
|
||||||
|
public void ConfirmedFixedProgram_RejectsIncompleteOrWrongDirectedCoverageWithoutRepair(bool locked, string fault)
|
||||||
|
{
|
||||||
|
var (part, settings) = Fixture();
|
||||||
|
var program = fault switch
|
||||||
|
{
|
||||||
|
"reverse" => Path(new(0, 0), new(10, 0), new(10, 10), new(0, 10), new(0, 5)),
|
||||||
|
"retrace" => Path(new(0, 8), new(0, 7), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)),
|
||||||
|
_ => Path(new Vector(0, 10))
|
||||||
|
};
|
||||||
|
Assert.True(part.RestoreLeadInProgram(program, locked));
|
||||||
|
var before = ExplicitContourTests.Fingerprint(part.Program);
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings,
|
||||||
|
eligibleParts: locked ? null : []));
|
||||||
|
var result = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot, snapshot.Placements, 0, default).Status);
|
||||||
|
Assert.Empty(result.ProposedOrder);
|
||||||
|
Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Replay_RejectsDifferentActualContourOrderWithUnchangedChoices()
|
||||||
|
{
|
||||||
|
var settings = ExplicitContourTests.Parameters();
|
||||||
|
var part = new Part(new Drawing("holes", PreparedContourTests.Holes()));
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||||
|
var source = snapshot.Placements[0];
|
||||||
|
var choices = Enumerable.Range(0, 3).Select(i => source.Prepared.ClosestEntry(i, Start)).ToArray();
|
||||||
|
var swapped = new[] { choices[1], choices[0], choices[2] };
|
||||||
|
var program = source.Prepared.Emit(swapped);
|
||||||
|
Assert.NotEqual(CuttingPlanStatus.Ready,
|
||||||
|
CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(program, Read(program), choices)], 0, default).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Replay_PreservesFullAndExactConfiguredTabWithSubdivisions(bool tabbed)
|
||||||
|
{
|
||||||
|
var (part, settings) = Fixture();
|
||||||
|
settings.TabsEnabled = tabbed;
|
||||||
|
settings.TabConfig = new NormalTab { Size = 0.2 };
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||||
|
var ready = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.True(ready.Status == CuttingPlanStatus.Ready, string.Join("; ", ready.Findings.Select(f => f.Message)));
|
||||||
|
var source = snapshot.Placements[0];
|
||||||
|
var proposal = ready.ProposedOrder[0];
|
||||||
|
Assert.Equal(tabbed ? 39.8 : 40, proposal.Execution.Motions.Where(IsCut).Sum(m => m.Length), 8);
|
||||||
|
var subdivided = Subdivide(proposal.CopyProgram());
|
||||||
|
var replay = CuttingPlanService.ReplayPrograms(snapshot,
|
||||||
|
[source.Propose(subdivided, Read(subdivided), proposal.ContourChoices)], 0, default);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
|
||||||
|
Assert.True(replay.IndependentlyReplayed);
|
||||||
|
// A different retained gap is not the selected tab, even though it remains open.
|
||||||
|
if (tabbed)
|
||||||
|
{
|
||||||
|
var counterfeit = proposal.CopyProgram();
|
||||||
|
counterfeit.Mode = Mode.Absolute;
|
||||||
|
var last = counterfeit.Codes.OfType<LinearMove>().Last(m => m.Layer == LayerType.Display);
|
||||||
|
last.EndPoint = new Vector(0, 4.9);
|
||||||
|
Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot,
|
||||||
|
[source.Propose(counterfeit, Read(counterfeit), proposal.ContourChoices)], 0, default).Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedUnknownTabMetadata_IsRefusedButCompatibilityRouteRemainsUnchanged()
|
||||||
|
{
|
||||||
|
var (part, settings) = Fixture();
|
||||||
|
Assert.True(part.RestoreLeadInProgram(Path(new Vector(0, 10)), true));
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.Plan(new CuttingPlanRequest([part], Start)).Status);
|
||||||
|
settings.TabsEnabled = true;
|
||||||
|
settings.TabConfig = new NormalTab { Size = 0.2 };
|
||||||
|
var prepared = PreparedContours.Capture(part.BaseDrawing.Program, settings);
|
||||||
|
var tabbed = prepared.Emit([prepared.ClosestEntry(0, Start)]);
|
||||||
|
Assert.True(part.RestoreLeadInProgram(tabbed, true));
|
||||||
|
Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, confirmedParameters: settings)).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void CompleteFixedProgram_RetainsAuthoredEntryAndDimensions(bool locked)
|
||||||
|
{
|
||||||
|
var (part, settings) = Fixture();
|
||||||
|
part.LeadInsLocked = locked;
|
||||||
|
((LineLeadIn)settings.ExternalLeadIn).Length = 0.7;
|
||||||
|
var before = ExplicitContourTests.Fingerprint(part.Program);
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, confirmedParameters: settings,
|
||||||
|
eligibleParts: locked ? null : []));
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.Equal(before, ExplicitContourTests.Fingerprint(result.ProposedOrder[0].CopyProgram()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("circles")]
|
||||||
|
[InlineData("mixed-circles")]
|
||||||
|
[InlineData("arcs")]
|
||||||
|
public void NativeCurves_SelectedRoundingClampingAndSubdivisionRemainValid(string shape)
|
||||||
|
{
|
||||||
|
var settings = ExplicitContourTests.Parameters();
|
||||||
|
settings.RoundLeadInAngles = true;
|
||||||
|
settings.LeadInAngleIncrement = 90;
|
||||||
|
settings.ArcCircleLeadIn = new LineLeadIn { Length = 10 };
|
||||||
|
var clean = PreparedContourTests.Holes();
|
||||||
|
if (shape == "mixed-circles") clean.Codes.OfType<ArcMove>().Last().Rotation = RotationType.CW;
|
||||||
|
if (shape == "arcs")
|
||||||
|
{
|
||||||
|
clean = ExplicitContourTests.Square(false);
|
||||||
|
clean.MoveTo(6, 3);
|
||||||
|
clean.ArcTo(4, 3, 5, 3, RotationType.CCW);
|
||||||
|
clean.LineTo(6, 3);
|
||||||
|
}
|
||||||
|
var part = new Part(new Drawing("native", clean));
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||||
|
var ready = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.True(ready.Status == CuttingPlanStatus.Ready, string.Join("; ", ready.Findings.Select(f => f.Message)));
|
||||||
|
var source = snapshot.Placements[0];
|
||||||
|
var selected = ready.ProposedOrder[0];
|
||||||
|
if (shape != "arcs")
|
||||||
|
{
|
||||||
|
// Search may legitimately select cardinal candidates. Force off-angle
|
||||||
|
// choices to exercise rounding, right hole before left for clear departure.
|
||||||
|
var choices = new[] { source.Prepared.ClosestEntry(1, Start),
|
||||||
|
source.Prepared.ClosestEntry(0, Start), source.Prepared.ClosestEntry(2, Start) };
|
||||||
|
var explicitProgram = source.Prepared.Emit(choices);
|
||||||
|
selected = source.Propose(explicitProgram, Read(explicitProgram), choices);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready,
|
||||||
|
CuttingPlanService.ReplayPrograms(snapshot, [selected], 0, default).Status);
|
||||||
|
var cuts = selected.Execution.Motions.Where(IsCut).Take(2).ToArray();
|
||||||
|
Assert.Contains(Enumerable.Range(0, 2), i => cuts[i].Start!.Value.DistanceTo(selected.ContourChoices[i].Point) > 0.1);
|
||||||
|
Assert.All(selected.Execution.Motions.Where(m => m.Layer == LayerType.Leadin).Take(2), m => Assert.True(m.Length < 2));
|
||||||
|
}
|
||||||
|
var program = Subdivide(selected.CopyProgram());
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot,
|
||||||
|
[source.Propose(program, Read(program), selected.ContourChoices)], 0, default).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedContract_IsFrozenBeforeReplayAndNeverTrustsCachedExecution()
|
||||||
|
{
|
||||||
|
var (part, settings) = Fixture();
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||||
|
var source = snapshot.Placements[0];
|
||||||
|
var choices = new[] { source.Prepared.ClosestEntry(0, Start) };
|
||||||
|
var program = source.Prepared.Emit(choices);
|
||||||
|
var cachedExecution = Read(program);
|
||||||
|
// Supply stale complete execution alongside actual partial code. This must
|
||||||
|
// refuse even when proposal storage defensively copies its input program.
|
||||||
|
program.Mode = Mode.Absolute;
|
||||||
|
program.Codes.RemoveRange(3, program.Codes.Count - 3);
|
||||||
|
var proposal = source.Propose(program, cachedExecution, choices);
|
||||||
|
((LineLeadIn)settings.ExternalLeadIn).Length = 900;
|
||||||
|
part.BaseDrawing.Program.Codes.Clear();
|
||||||
|
var replay = CuttingPlanService.ReplayPrograms(snapshot, [proposal], 0, default);
|
||||||
|
Assert.NotEqual(CuttingPlanStatus.Ready, replay.Status);
|
||||||
|
Assert.Empty(replay.ProposedOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedReindexedMergedNativeIntervals_PreserveCompleteCoverageAndPayload()
|
||||||
|
{
|
||||||
|
var settings = ExplicitContourTests.Parameters();
|
||||||
|
var clean = ExplicitContourTests.Square(false);
|
||||||
|
clean.Codes.Insert(1, new LinearMove(0, 7));
|
||||||
|
var part = new Part(new Drawing("subdivided clean", clean));
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(5, 10.3);
|
||||||
|
program.Codes.Add(new LinearMove(5, 10) { Layer = LayerType.Leadin });
|
||||||
|
program.LineTo(10, 10); program.LineTo(10, 0); program.LineTo(0, 0);
|
||||||
|
program.LineTo(0, 10); program.LineTo(5, 10);
|
||||||
|
Assert.True(part.RestoreLeadInProgram(program, true));
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(5, 12), confirmedParameters: settings));
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.Equal(ExplicitContourTests.Fingerprint(program), ExplicitContourTests.Fingerprint(result.ProposedOrder[0].CopyProgram()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Part, CuttingParameters) Fixture()
|
||||||
|
{
|
||||||
|
var settings = ExplicitContourTests.Parameters();
|
||||||
|
var clean = ExplicitContourTests.Square(false);
|
||||||
|
var prepared = PreparedContours.Capture(clean, settings);
|
||||||
|
var part = new Part(new Drawing("square", clean));
|
||||||
|
Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, Start)]), false));
|
||||||
|
return (part, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program Path(params Vector[] points)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(-0.3, 5);
|
||||||
|
program.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
|
||||||
|
foreach (var point in points) program.LineTo(point);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OwnedExecution Read(Program program) => ExecutionMotionReader.Read(program, Vector.Zero, null, default);
|
||||||
|
private static bool IsCut(ExecutionMotion motion) => !motion.Rapid && motion.Layer is LayerType.Cut or LayerType.Display;
|
||||||
|
|
||||||
|
private static Program Subdivide(Program program)
|
||||||
|
{
|
||||||
|
// Flatten executed frames through the existing native reader, not tessellation.
|
||||||
|
var copy = new Program();
|
||||||
|
foreach (var motion in Read(program).Motions)
|
||||||
|
{
|
||||||
|
if (motion.Rapid) { copy.MoveTo(motion.End); continue; }
|
||||||
|
var midpoint = motion.Curve.Midpoint;
|
||||||
|
if (motion.Curve.ToEntity() is Arc arc)
|
||||||
|
{
|
||||||
|
copy.Codes.Add(new ArcMove(midpoint, arc.Center, arc.Rotation) { Layer = motion.Layer });
|
||||||
|
copy.Codes.Add(new ArcMove(motion.End, arc.Center, arc.Rotation) { Layer = motion.Layer });
|
||||||
|
}
|
||||||
|
else if (motion.Curve.ToEntity() is Circle circle)
|
||||||
|
{
|
||||||
|
var clockwise = PostVerificationGeometry.Curve.Create(motion.Start!.Value, motion.End, circle.Center, true);
|
||||||
|
var original = motion.Curve.SameDirection(clockwise) ? RotationType.CW : RotationType.CCW;
|
||||||
|
copy.Codes.Add(new ArcMove(midpoint, circle.Center, original) { Layer = motion.Layer });
|
||||||
|
copy.Codes.Add(new ArcMove(motion.End, circle.Center, original) { Layer = motion.Layer });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
copy.Codes.Add(new LinearMove(midpoint) { Layer = motion.Layer });
|
||||||
|
copy.Codes.Add(new LinearMove(motion.End) { Layer = motion.Layer });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class ExplicitCircleIdentityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Emit_DistinctNearRoundedNormalsPreserveActualEntry()
|
||||||
|
{
|
||||||
|
var source = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||||
|
foreach (var x in new[] { 3, 7 })
|
||||||
|
{
|
||||||
|
source.MoveTo(x + 1, 3);
|
||||||
|
source.ArcTo(x + 1, 3, x, 3, RotationType.CCW);
|
||||||
|
}
|
||||||
|
var prepared = PreparedContours.Capture(source, new CuttingParameters());
|
||||||
|
var angle = 0.0000001;
|
||||||
|
var choices = new[] { prepared.Entry(0, 0, new Vector(4, 3)),
|
||||||
|
prepared.Entry(1, 0, new Vector(7 + System.Math.Cos(angle), 3 + System.Math.Sin(angle))),
|
||||||
|
prepared.ClosestEntry(2, Vector.Zero) };
|
||||||
|
var emitted = prepared.Emit(choices);
|
||||||
|
var circles = ExecutionMotionReader.Read(emitted, Vector.Zero, null, default).Motions
|
||||||
|
.Where(m => !m.Rapid && m.Curve.ToEntity() is Circle).ToArray();
|
||||||
|
Assert.Equal(choices[1].Point.Y, circles[1].Start!.Value.Y);
|
||||||
|
var calls = emitted.Codes.OfType<SubProgramCall>().ToArray();
|
||||||
|
Assert.NotSame(calls[0].Program, calls[1].Program);
|
||||||
|
Assert.Equal(new[] { 1, 2 }, calls.Select(c => c.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1.0000004, false, false)]
|
||||||
|
[InlineData(1, true, false)]
|
||||||
|
[InlineData(1, false, true)]
|
||||||
|
public void Emit_SharesOnlyExactlyEquivalentCirclePrograms(double secondRadius, bool reverse, bool shared)
|
||||||
|
{
|
||||||
|
var source = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||||
|
source.MoveTo(4, 3);
|
||||||
|
source.ArcTo(4, 3, 3, 3, RotationType.CCW);
|
||||||
|
source.MoveTo(7 + secondRadius, 3);
|
||||||
|
source.ArcTo(7 + secondRadius, 3, 7, 3, reverse ? RotationType.CW : RotationType.CCW);
|
||||||
|
var prepared = PreparedContours.Capture(source, new CuttingParameters());
|
||||||
|
var choices = Enumerable.Range(0, prepared.Count)
|
||||||
|
.Select(i => prepared.ClosestEntry(i, new Vector(20, 3))).ToArray();
|
||||||
|
var emitted = prepared.Emit(choices);
|
||||||
|
var calls = emitted.Codes.OfType<SubProgramCall>().ToArray();
|
||||||
|
Assert.Equal(2, calls.Length);
|
||||||
|
var cuts = ExecutionMotionReader.Read(emitted, Vector.Zero, null, default).Motions
|
||||||
|
.Where(m => !m.Rapid && m.Curve.ToEntity() is Circle).ToArray();
|
||||||
|
Assert.Equal(2, cuts.Length);
|
||||||
|
Assert.Equal(choices[0].Point, cuts[0].Start);
|
||||||
|
Assert.Equal(choices[1].Point, cuts[1].Start);
|
||||||
|
Assert.Equal(1, ((Circle)cuts[0].Curve.ToEntity()).Radius, 12);
|
||||||
|
Assert.Equal(secondRadius, ((Circle)cuts[1].Curve.ToEntity()).Radius, 12);
|
||||||
|
Assert.Equal(RotationType.CCW, Assert.Single(calls[0].Program.Codes.OfType<ArcMove>()).Rotation);
|
||||||
|
Assert.Equal(reverse ? RotationType.CW : RotationType.CCW,
|
||||||
|
Assert.Single(calls[1].Program.Codes.OfType<ArcMove>()).Rotation);
|
||||||
|
Assert.Equal(shared, ReferenceEquals(calls[0].Program, calls[1].Program));
|
||||||
|
Assert.Equal(shared, calls[0].Id == calls[1].Id);
|
||||||
|
Assert.Equal(new[] { 1, shared ? 1 : 2 }, calls.Select(c => c.Id));
|
||||||
|
Assert.All(calls, c => Assert.Same(c.Program, emitted.SubPrograms[c.Id]));
|
||||||
|
var fresh = prepared.Emit(choices);
|
||||||
|
var freshCalls = fresh.Codes.OfType<SubProgramCall>().ToArray();
|
||||||
|
Assert.Equal(calls.Select(c => c.Id), freshCalls.Select(c => c.Id));
|
||||||
|
Assert.All(freshCalls, c => Assert.DoesNotContain(calls, old => ReferenceEquals(old.Program, c.Program)));
|
||||||
|
calls[0].Program.Codes.Clear();
|
||||||
|
Assert.NotEmpty(freshCalls[0].Program.Codes);
|
||||||
|
Assert.Equal(ExplicitContourTests.Fingerprint(fresh), ExplicitContourTests.Fingerprint(prepared.Emit(choices)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class ExplicitContourTests
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> RetainedStyles()
|
||||||
|
{
|
||||||
|
foreach (var style in new[] { "line", "arc", "linearc", "lineline", "clean" })
|
||||||
|
foreach (var reversed in new[] { false, true })
|
||||||
|
foreach (var internalContour in new[] { false, true })
|
||||||
|
yield return [style, reversed, internalContour];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(RetainedStyles))]
|
||||||
|
public void LegacyEmission_RetainsStylesWindingAndCornerFallback(string style, bool reversed, bool internalContour)
|
||||||
|
{
|
||||||
|
var program = Square(reversed);
|
||||||
|
var entities = program.ToGeometry().Where(e => e.Layer != SpecialLayers.Rapid).ToArray();
|
||||||
|
var parameters = Parameters(style);
|
||||||
|
if (internalContour)
|
||||||
|
{
|
||||||
|
program.MoveTo(-5, -5); program.LineTo(-5, 15); program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, -5); program.LineTo(-5, -5);
|
||||||
|
}
|
||||||
|
var result = new ContourCuttingStrategy { Parameters = parameters }.ApplySingle(program,
|
||||||
|
new Vector(0, 0), entities[0], internalContour ? ContourType.Internal : ContourType.External);
|
||||||
|
var expected = $"{style}:{reversed}:{internalContour}" switch
|
||||||
|
{
|
||||||
|
"arc:False:False" => "4F89EDF317A545A0F496A1C7F9C633755663D98AFCF71F783B4AABF700D1711C",
|
||||||
|
"arc:False:True" => "478018E0A86BA1FF0F08204EF5449D4484ADD1B58A40EA0936069DE6B6002E3F",
|
||||||
|
"arc:True:False" => "CDC68828AD39D4B88C205347FDC06227C03A947F6AB3A19376F4BD670987C1A6",
|
||||||
|
"arc:True:True" => "83CAD7BDCF4A655730D779966355F535E36429B5AAFA4423CB58E3B3FAAD4376",
|
||||||
|
"clean:False:False" => "E3A6BE74CC4B72EC5DEFD9E53743DD080F6AF6854A0E7143860E5246745D1C12",
|
||||||
|
"clean:False:True" => "196CC9BE57E9740AB8E35A77A48473DCD8B62312DE2903B2E66F62BB39818DDA",
|
||||||
|
"clean:True:False" => "6E51376D40D93BD0DAD2E51FFAC732A9BE1717C252964BA2285A8440A23C3480",
|
||||||
|
"clean:True:True" => "80DB265B4E9846169421B3DDCB8DAE003D720EEE8EF9DAB64C40EE00CC322DE6",
|
||||||
|
"line:False:False" => "A9DAB8F802CDE0A89A0D26B4C5AC5546E121E71AF9EBA2283F307ADF88866347",
|
||||||
|
"line:False:True" => "61EE8CF9A30B18C72DDACD314886709692CA8E50E7DC61170A900429562A45B5",
|
||||||
|
"line:True:False" => "AB6E00410C3AB8592B9DD7C5EC1E9E8ABD39A2DCA9486FCEAE28593D9BA7504C",
|
||||||
|
"line:True:True" => "23E2404046438ECF67BAC46AC7692B6DF60EFA674C286D72B25F98FE77431588",
|
||||||
|
"linearc:False:False" => "E3A6BE74CC4B72EC5DEFD9E53743DD080F6AF6854A0E7143860E5246745D1C12",
|
||||||
|
"linearc:False:True" => "196CC9BE57E9740AB8E35A77A48473DCD8B62312DE2903B2E66F62BB39818DDA",
|
||||||
|
"linearc:True:False" => "6E51376D40D93BD0DAD2E51FFAC732A9BE1717C252964BA2285A8440A23C3480",
|
||||||
|
"linearc:True:True" => "80DB265B4E9846169421B3DDCB8DAE003D720EEE8EF9DAB64C40EE00CC322DE6",
|
||||||
|
"lineline:False:False" => "F25E4FA39713A53D04FC5DA22BD3FD1EB15330462153356906AD4DAA1C1A23C2",
|
||||||
|
"lineline:False:True" => "911C85F1C6A053F9C94F63A40B520C30B00AA43B804634E0203DD406BB98BF6E",
|
||||||
|
"lineline:True:False" => "AE8E6CD1B641D33A39DAE07A6FAF539668BD794DC8105F4D41C1BE33927FFC65",
|
||||||
|
"lineline:True:True" => "54E1F71B8420ACAF1C57E124A0F927BA00F04F52CDBAB48EDFC27BAB8C346EA5",
|
||||||
|
_ => throw new InvalidOperationException()
|
||||||
|
};
|
||||||
|
Assert.Equal(expected, Fingerprint(result.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyCircle_RoundsActualEntryClampsAndSharesSubprograms()
|
||||||
|
{
|
||||||
|
var program = Square(false);
|
||||||
|
foreach (var center in new[] { new Vector(3, 3), new Vector(7, 7) })
|
||||||
|
{
|
||||||
|
program.MoveTo(center.X + 0.5, center.Y);
|
||||||
|
program.ArcTo(center.X + 0.5, center.Y, center.X, center.Y, RotationType.CCW);
|
||||||
|
}
|
||||||
|
var parameters = Parameters("line");
|
||||||
|
parameters.RoundLeadInAngles = true;
|
||||||
|
parameters.LeadInAngleIncrement = 90;
|
||||||
|
parameters.ArcCircleLeadIn = new LineLeadIn { Length = 2 };
|
||||||
|
var result = new ContourCuttingStrategy { Parameters = parameters }.Apply(program, new Vector(20, 20));
|
||||||
|
var calls = result.Program.Codes.OfType<SubProgramCall>().ToArray();
|
||||||
|
Assert.Equal(2, calls.Length);
|
||||||
|
Assert.Same(calls[0].Program, calls[1].Program);
|
||||||
|
var moves = ExecutionMotionReader.Read(result.Program, Vector.Zero, null, default).Motions;
|
||||||
|
Assert.All(moves.Where(m => m.Layer == LayerType.Leadin).Take(2), m => Assert.True(m.Length < 1));
|
||||||
|
Assert.Equal("9BB777CDD1F7407EB0D4B0FE1D1A760370A094A606E61B7E3875DCB5EF051F20", Fingerprint(result.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyTab_LeavesGenuineGap()
|
||||||
|
{
|
||||||
|
var parameters = Parameters("line");
|
||||||
|
parameters.TabsEnabled = true;
|
||||||
|
parameters.TabConfig = new NormalTab { Size = 0.2 };
|
||||||
|
var result = new ContourCuttingStrategy { Parameters = parameters }.Apply(Square(false), new Vector(-1, 5));
|
||||||
|
var moves = ExecutionMotionReader.Read(result.Program, Vector.Zero, null, default).Motions;
|
||||||
|
var cuts = moves.Where(m => !m.Rapid && m.Layer == LayerType.Display).ToArray();
|
||||||
|
Assert.True(cuts[0].Start!.Value.DistanceTo(cuts[^1].End) > 0.1);
|
||||||
|
Assert.Equal("C5CDFACDE4019AEBFCD4AA304D149DD603313D341311F27C9ECBB4ED940D2204", Fingerprint(result.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Program Square(bool reversed)
|
||||||
|
{
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(0, 0);
|
||||||
|
if (reversed)
|
||||||
|
{
|
||||||
|
p.LineTo(10, 0); p.LineTo(10, 10); p.LineTo(0, 10);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.LineTo(0, 10); p.LineTo(10, 10); p.LineTo(10, 0);
|
||||||
|
}
|
||||||
|
p.LineTo(0, 0);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static CuttingParameters Parameters(string style = "line")
|
||||||
|
{
|
||||||
|
LeadIn lead = style switch
|
||||||
|
{
|
||||||
|
"arc" => new ArcLeadIn { Radius = 0.2 },
|
||||||
|
"linearc" => new LineArcLeadIn { LineLength = 0.1, ArcRadius = 0.2 },
|
||||||
|
"lineline" => new LineLineLeadIn { Length1 = 0.1, Length2 = 0.2 },
|
||||||
|
"clean" => new CleanHoleLeadIn { LineLength = 0.1, ArcRadius = 0.2, Kerf = 0.01 },
|
||||||
|
_ => new LineLeadIn { Length = 0.3, ApproachAngle = 45 }
|
||||||
|
};
|
||||||
|
return new CuttingParameters
|
||||||
|
{
|
||||||
|
ExternalLeadIn = lead,
|
||||||
|
InternalLeadIn = lead,
|
||||||
|
ArcCircleLeadIn = lead,
|
||||||
|
ExternalLeadOut = new NoLeadOut(),
|
||||||
|
InternalLeadOut = new NoLeadOut(),
|
||||||
|
PierceClearance = 0.05
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string Fingerprint(Program program)
|
||||||
|
{
|
||||||
|
var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, default);
|
||||||
|
var text = string.Join("\n", execution.Motions.Select(m => string.Join("|", m.Rapid, m.Layer,
|
||||||
|
m.Start?.X.ToString("R", CultureInfo.InvariantCulture), m.Start?.Y.ToString("R", CultureInfo.InvariantCulture),
|
||||||
|
m.End.X.ToString("R", CultureInfo.InvariantCulture), m.End.Y.ToString("R", CultureInfo.InvariantCulture),
|
||||||
|
m.Length.ToString("R", CultureInfo.InvariantCulture))));
|
||||||
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
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 JointCuttingPlanTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void JointSearch_RepairsActualCompletedHoleCrossing_WithExactReplayAndNoMutation()
|
||||||
|
{
|
||||||
|
var (part, parameters) = Crossing();
|
||||||
|
var unchanged = Unchanged(part, parameters);
|
||||||
|
Assert.Contains(new ReleasedContourState().Check(Read(part.Program, part.Location), Vector.Zero, 1),
|
||||||
|
f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||||
|
Assert.Equal(CuttingPlanStatus.ConstraintConflict,
|
||||||
|
CuttingPlanService.Plan(new CuttingPlanRequest([part])).Status);
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters));
|
||||||
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||||
|
Assert.True(result.IndependentlyReplayed);
|
||||||
|
var proposal = Assert.Single(result.ProposedOrder);
|
||||||
|
Assert.True(proposal.IsRegenerated);
|
||||||
|
Assert.Equal(new[] { 0, 1, 2 }, proposal.ContourChoices.Select(c => c.ContourOrdinal).Order());
|
||||||
|
Assert.Equal(2, proposal.ContourChoices[^1].ContourOrdinal);
|
||||||
|
Assert.NotEqual(ExplicitContourTests.Fingerprint(part.Program), ExplicitContourTests.Fingerprint(proposal.CopyProgram()));
|
||||||
|
var actual = Read(proposal.CopyProgram(), proposal.Location);
|
||||||
|
Assert.Empty(new ReleasedContourState().Check(actual, Vector.Zero, 1));
|
||||||
|
var material = LeadMaterialSnapshot.Capture(part.BaseDrawing.Program, part.Location);
|
||||||
|
var leads = LeadPathValidator.Check(actual, material, []);
|
||||||
|
Assert.True(leads.IsComplete && leads.IsClear, leads.Reason);
|
||||||
|
var nest = new Nest();
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
var copy = new Part(new Drawing("replay", (Program)part.BaseDrawing.Program.Clone()), proposal.Location);
|
||||||
|
Assert.True(copy.RestoreLeadInProgram(proposal.CopyProgram(), false));
|
||||||
|
plate.Parts.Add(copy);
|
||||||
|
var report = PostVerificationAnalyzer.Analyze(nest, Vector.Zero);
|
||||||
|
Assert.Empty(report.Findings);
|
||||||
|
var detached = proposal.CopyProgram(); detached.Codes.Clear(); detached.SubPrograms.Clear();
|
||||||
|
Assert.NotEmpty(proposal.CopyProgram().Codes);
|
||||||
|
unchanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("locked")]
|
||||||
|
[InlineData("ineligible")]
|
||||||
|
[InlineData("no-valid-entry")]
|
||||||
|
[InlineData("unsupported")]
|
||||||
|
public void JointSearch_RefusesWithoutInstallingFallbackOrMutating(string fault)
|
||||||
|
{
|
||||||
|
var (part, parameters) = Crossing();
|
||||||
|
Part[]? eligible = null;
|
||||||
|
var expected = CuttingPlanStatus.ConstraintConflict;
|
||||||
|
if (fault == "locked") part.LeadInsLocked = true;
|
||||||
|
if (fault == "ineligible") eligible = [];
|
||||||
|
if (fault == "no-valid-entry")
|
||||||
|
{
|
||||||
|
parameters.ArcCircleLeadIn = new NoLeadIn();
|
||||||
|
parameters.InternalLeadIn = parameters.ArcCircleLeadIn;
|
||||||
|
expected = CuttingPlanStatus.NoSolutionWithinBudget;
|
||||||
|
}
|
||||||
|
if (fault == "unsupported")
|
||||||
|
{
|
||||||
|
part.BaseDrawing.Program.Codes.AddRange(LeadPathValidationTests.Rectangle(30, 30, 40, 40).Codes);
|
||||||
|
expected = CuttingPlanStatus.UnsupportedGeometry;
|
||||||
|
}
|
||||||
|
var unchanged = Unchanged(part, parameters);
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters, eligibleParts: eligible));
|
||||||
|
Assert.Equal(expected, result.Status);
|
||||||
|
Assert.Empty(result.ProposedOrder);
|
||||||
|
Assert.False(result.IndependentlyReplayed);
|
||||||
|
unchanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JointSearch_BacktracksInternalEntryWhenLaterWholePartFails()
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var first = SimplePart(Vector.Zero, parameters);
|
||||||
|
var second = SimplePart(new Vector(20, 0), parameters);
|
||||||
|
second.LeadInsLocked = true;
|
||||||
|
var start = new Vector(-2, 5);
|
||||||
|
var solo = CuttingPlanService.Plan(new CuttingPlanRequest([first], start, confirmedParameters: parameters));
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, solo.Status);
|
||||||
|
var fixedLater = CuttingPlanService.Capture(new CuttingPlanRequest([second])).Placements[0];
|
||||||
|
var state = new ReleasedContourState();
|
||||||
|
Assert.Empty(state.Check(solo.ProposedOrder[0].Execution, start, 1));
|
||||||
|
Assert.Contains(state.Check(fixedLater.Execution, solo.ProposedOrder[0].Execution.DeparturePoint, 2),
|
||||||
|
f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||||
|
var joint = CuttingPlanService.Plan(new CuttingPlanRequest([first, second], start,
|
||||||
|
confirmedParameters: parameters, preservePartOrder: true));
|
||||||
|
Assert.True(joint.Status == CuttingPlanStatus.Ready, Describe(joint));
|
||||||
|
Assert.Equal(new[] { first, second }, joint.ProposedOrder.Select(p => p.SourcePart));
|
||||||
|
Assert.NotEqual(solo.ProposedOrder[0].ContourChoices[0].Point, joint.ProposedOrder[0].ContourChoices[0].Point);
|
||||||
|
Assert.True(joint.IndependentlyReplayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Snapshot_CallerMutationAndDuplicateNames_DoNotChangeDeterministicOwnedProposals()
|
||||||
|
{
|
||||||
|
var (first, parameters) = Crossing();
|
||||||
|
var (second, _) = Crossing();
|
||||||
|
second.Location = new Vector(30, 0);
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, second], confirmedParameters: parameters));
|
||||||
|
var baseline = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.True(baseline.Status == CuttingPlanStatus.Ready, Describe(baseline));
|
||||||
|
var fingerprints = baseline.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram())).ToArray();
|
||||||
|
foreach (var part in new[] { first, second })
|
||||||
|
{
|
||||||
|
foreach (var call in part.Program.Codes.OfType<SubProgramCall>()) call.Program.Codes.Clear();
|
||||||
|
part.Program.Codes.Clear(); part.BaseDrawing.Program.Codes.Clear();
|
||||||
|
part.Location = new Vector(999, 999); part.LeadInsLocked = true;
|
||||||
|
part.CuttingParameters = new CuttingParameters();
|
||||||
|
}
|
||||||
|
((LineLeadIn)parameters.ExternalLeadIn).Length = 900;
|
||||||
|
parameters.TabsEnabled = true;
|
||||||
|
for (var repeat = 0; repeat < 3; repeat++)
|
||||||
|
{
|
||||||
|
var result = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.Equal(baseline.Expansions, result.Expansions);
|
||||||
|
Assert.Equal(baseline.RapidDistance, result.RapidDistance);
|
||||||
|
Assert.Equal(baseline.ProposedOrder.Select(p => p.SourcePart), result.ProposedOrder.Select(p => p.SourcePart));
|
||||||
|
Assert.Equal(fingerprints, result.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram())));
|
||||||
|
}
|
||||||
|
Assert.Equal(new[] { first, second }, baseline.ProposedOrder.Select(p => p.SourcePart));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BudgetOneAndCancellationDuringExpansion_HaveExactCountsNoFallbackNoMutation()
|
||||||
|
{
|
||||||
|
var (part, parameters) = Crossing();
|
||||||
|
var unchanged = Unchanged(part, parameters);
|
||||||
|
var observed = 0;
|
||||||
|
var budget = CuttingPlanService.Plan(new CuttingPlanRequest([part], expansionBudget: 1,
|
||||||
|
confirmedParameters: parameters)
|
||||||
|
{ ExpansionObserver = n => observed = n });
|
||||||
|
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, budget.Status);
|
||||||
|
Assert.Equal(1, budget.Expansions); Assert.Equal(1, observed); Assert.Empty(budget.ProposedOrder);
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters)
|
||||||
|
{
|
||||||
|
ExpansionObserver = n => { if (n == 3) cancellation.Cancel(); }
|
||||||
|
}, cancellation.Token);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status);
|
||||||
|
Assert.Equal(3, cancelled.Expansions); Assert.Empty(cancelled.ProposedOrder);
|
||||||
|
unchanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("foreign")]
|
||||||
|
[InlineData("duplicate")]
|
||||||
|
[InlineData("without-settings")]
|
||||||
|
[InlineData("zero-cap")]
|
||||||
|
public void EligibilityAndEntryBounds_InvalidInputIsNotIgnored(string fault)
|
||||||
|
{
|
||||||
|
var (part, parameters) = Crossing();
|
||||||
|
var other = SimplePart(new Vector(30, 0), parameters);
|
||||||
|
var request = new CuttingPlanRequest([part], confirmedParameters: fault == "without-settings" ? null : parameters,
|
||||||
|
eligibleParts: fault == "foreign" ? [other] : fault == "duplicate" ? [part, part] : [part],
|
||||||
|
maxEntries: fault == "zero-cap" ? 0 : 16);
|
||||||
|
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(request).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.0, 1.5707963267948966)]
|
||||||
|
[InlineData(0.37, 0.91)]
|
||||||
|
public void RotatedCapture_UsesDeltaFromAlreadyRotatedBase_AndLocationOnce(double baseAngle, double delta)
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var clean = PreparedContourTests.Holes(); clean.Rotate(baseAngle);
|
||||||
|
var part = new Part(new Drawing("rotated", clean)); part.Rotate(delta);
|
||||||
|
part.Location = new Vector(30, 40);
|
||||||
|
var expected = (Program)clean.Clone(); expected.Rotate(delta);
|
||||||
|
var target = LeadMaterialSnapshot.Capture(expected, part.Location);
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(20, 30), confirmedParameters: parameters));
|
||||||
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||||
|
var proposal = Assert.Single(result.ProposedOrder);
|
||||||
|
Assert.Equal(part.Rotation, proposal.Rotation);
|
||||||
|
Assert.Equal(part.Location, proposal.Location);
|
||||||
|
var lead = LeadPathValidator.Check(Read(proposal.CopyProgram(), part.Location), target, []);
|
||||||
|
Assert.True(lead.IsComplete && lead.IsClear, lead.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GeneratedLeadoutDeparture_IsUsedForArrivalAndReplayDistance()
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
parameters.ExternalLeadOut = new LineLeadOut { Length = 0.2, ApproachAngle = 45 };
|
||||||
|
var parts = new[] { SimplePart(Vector.Zero, parameters), SimplePart(new Vector(20, 0), parameters) };
|
||||||
|
var start = new Vector(-2, 5);
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, start,
|
||||||
|
confirmedParameters: parameters, preservePartOrder: true));
|
||||||
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||||
|
var distance = 0.0; var arrival = start;
|
||||||
|
foreach (var p in result.ProposedOrder)
|
||||||
|
{
|
||||||
|
var actual = Read(p.CopyProgram(), p.Location);
|
||||||
|
Assert.Equal(LayerType.Leadout, actual.Motions[^1].Layer);
|
||||||
|
Assert.NotEqual(actual.Motions[^1].Start, actual.DeparturePoint);
|
||||||
|
Assert.Equal(actual.DeparturePoint, p.Execution.DeparturePoint);
|
||||||
|
distance += actual.RapidDistanceFrom(arrival); arrival = actual.DeparturePoint;
|
||||||
|
}
|
||||||
|
Assert.Equal(distance, result.RapidDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("unsafe")]
|
||||||
|
[InlineData("duplicate-choice")]
|
||||||
|
[InlineData("omitted-contour")]
|
||||||
|
[InlineData("duplicate-placement")]
|
||||||
|
[InlineData("omitted-placement")]
|
||||||
|
[InlineData("wrong-pose")]
|
||||||
|
public void ExactReplay_RejectsUnsafeOrIncompleteSelectedPrograms(string fault)
|
||||||
|
{
|
||||||
|
var (part, parameters) = Crossing();
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], confirmedParameters: parameters));
|
||||||
|
var ready = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, ready.Status);
|
||||||
|
var proposal = ready.ProposedOrder[0];
|
||||||
|
var source = snapshot.Placements[0];
|
||||||
|
IReadOnlyList<FixedProgramPlacement> order = [proposal];
|
||||||
|
if (fault == "unsafe") order = [source.Propose((Program)part.Program.Clone(), Read(part.Program, part.Location), proposal.ContourChoices)];
|
||||||
|
if (fault == "duplicate-choice") order = [source.Propose(proposal.CopyProgram(), proposal.Execution,
|
||||||
|
[proposal.ContourChoices[0], proposal.ContourChoices[0], proposal.ContourChoices[2]])];
|
||||||
|
if (fault == "omitted-contour")
|
||||||
|
{
|
||||||
|
var prefix = source.Prepared.EmitPrefix(proposal.ContourChoices.Take(2).ToArray());
|
||||||
|
order = [source.Propose(prefix, Read(prefix, source.Location), proposal.ContourChoices)];
|
||||||
|
}
|
||||||
|
if (fault == "duplicate-placement") order = [proposal, proposal];
|
||||||
|
if (fault == "omitted-placement") order = [];
|
||||||
|
if (fault == "wrong-pose") order = [new FixedProgramPlacement(part, 0, new Vector(99, 99), source.Rotation,
|
||||||
|
false, proposal.Execution, proposal.CopyProgram(), source.Prepared, source.Material, proposal.ContourChoices)];
|
||||||
|
var replay = CuttingPlanService.ReplayPrograms(snapshot, order, 0, default);
|
||||||
|
Assert.Equal(fault == "unsafe" ? CuttingPlanStatus.ConstraintConflict : CuttingPlanStatus.InvalidInput, replay.Status);
|
||||||
|
Assert.Empty(replay.ProposedOrder); Assert.False(replay.IndependentlyReplayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedLeads_SearchAndReplayRejectNativeTargetMaterialCrossing()
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||||
|
var part = new Part(new Drawing("fixed", clean));
|
||||||
|
var unsafeProgram = new Program(); unsafeProgram.MoveTo(5, 5);
|
||||||
|
unsafeProgram.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
|
||||||
|
unsafeProgram.LineTo(0, 10); unsafeProgram.LineTo(10, 10); unsafeProgram.LineTo(10, 0);
|
||||||
|
unsafeProgram.LineTo(0, 0); unsafeProgram.LineTo(0, 5);
|
||||||
|
Assert.True(part.RestoreLeadInProgram(unsafeProgram, true));
|
||||||
|
Assert.Empty(new ReleasedContourState().Check(Read(unsafeProgram, Vector.Zero), Vector.Zero, 1));
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], confirmedParameters: parameters));
|
||||||
|
var search = JointCuttingPlanSearch.Run(snapshot, default);
|
||||||
|
Assert.Equal(CuttingPlanStatus.ConstraintConflict, search.Status);
|
||||||
|
Assert.Empty(search.Order);
|
||||||
|
var replay = CuttingPlanService.ReplayPrograms(snapshot, snapshot.Placements, 0, default);
|
||||||
|
Assert.Equal(CuttingPlanStatus.ConstraintConflict, replay.Status);
|
||||||
|
Assert.Contains(replay.Findings, f => f.Message.Contains("target material"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TabbedArcCandidate_UncertifiedTabGap_IsRefusedWithoutRepairOrException()
|
||||||
|
{
|
||||||
|
// The arc lead-out is now well formed; the open tab gap is still not certified.
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
parameters.TabsEnabled = true; parameters.TabConfig = new NormalTab { Size = 0.2 };
|
||||||
|
parameters.ExternalLeadOut = new ArcLeadOut { Radius = 0.2 };
|
||||||
|
var part = new Part(new Drawing("tabbed", LeadPathValidationTests.Rectangle(0, 0, 10, 10)));
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(-2, 5), confirmedParameters: parameters));
|
||||||
|
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
|
||||||
|
Assert.Empty(result.ProposedOrder); Assert.True(result.Expansions > 0);
|
||||||
|
Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.Incomplete);
|
||||||
|
Assert.Contains(result.Findings, f => f.Message.Contains("retention gap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedLead_SearchChecksOtherUncutHoleAwareMaterial_NotOnlyReleasedContours()
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var first = SimplePart(Vector.Zero, parameters); first.LeadInsLocked = true;
|
||||||
|
var clean = LeadPathValidationTests.Rectangle(-0.3, 4.5, -0.1, 5.5);
|
||||||
|
var obstacle = new Part(new Drawing("uncut", clean));
|
||||||
|
var prepared = PreparedContours.Capture(clean, parameters);
|
||||||
|
Assert.True(obstacle.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(-1, 5))]), true));
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, obstacle],
|
||||||
|
new Vector(-2, 5), confirmedParameters: parameters, preservePartOrder: true));
|
||||||
|
var direct = LeadPathValidator.Check(snapshot.Placements[0].Execution, snapshot.Placements[0].Material,
|
||||||
|
snapshot.Placements.Select(p => p.Material).ToArray());
|
||||||
|
Assert.True(direct.IsComplete); Assert.False(direct.IsClear);
|
||||||
|
Assert.Contains("another placed material", direct.Reason);
|
||||||
|
var search = JointCuttingPlanSearch.Run(snapshot, default);
|
||||||
|
Assert.Equal(CuttingPlanStatus.ConstraintConflict, search.Status);
|
||||||
|
Assert.Contains(search.Findings, f => f.Message.Contains("another placed material"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsupportedMaterialOfLockedIneligiblePlacement_BlocksCompleteRegeneration()
|
||||||
|
{
|
||||||
|
var (part, parameters) = Crossing();
|
||||||
|
var locked = SimplePart(new Vector(30, 0), parameters); locked.LeadInsLocked = true;
|
||||||
|
locked.BaseDrawing.Program.Codes.RemoveAt(locked.BaseDrawing.Program.Codes.Count - 1);
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, locked], confirmedParameters: parameters,
|
||||||
|
eligibleParts: [part]));
|
||||||
|
var result = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
|
||||||
|
Assert.Empty(result.ProposedOrder);
|
||||||
|
Assert.Contains(result.Findings, f => ReferenceEquals(f.SourcePart, locked) && f.SourceOrdinal == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JointSearch_SelectsWholePartOrderAndCanPreserveCallerOrder()
|
||||||
|
{
|
||||||
|
var (first, parameters) = Crossing(); first.Location = new Vector(30, 0);
|
||||||
|
var (second, _) = Crossing();
|
||||||
|
var free = CuttingPlanService.Plan(new CuttingPlanRequest([first, second], confirmedParameters: parameters));
|
||||||
|
var preserved = CuttingPlanService.Plan(new CuttingPlanRequest([first, second], confirmedParameters: parameters,
|
||||||
|
preservePartOrder: true));
|
||||||
|
Assert.True(free.Status == CuttingPlanStatus.Ready, Describe(free));
|
||||||
|
Assert.True(preserved.Status == CuttingPlanStatus.Ready, Describe(preserved));
|
||||||
|
Assert.Equal(new[] { second, first }, free.ProposedOrder.Select(p => p.SourcePart));
|
||||||
|
Assert.Equal(new[] { first, second }, preserved.ProposedOrder.Select(p => p.SourcePart));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedProgramCopy_PreservesSignedSubcallsCommentsAndVariables_WithoutMutableAliases()
|
||||||
|
{
|
||||||
|
var (part, _) = Crossing();
|
||||||
|
part.Program.Codes.Insert(0, new Comment("fixed text"));
|
||||||
|
part.Program.Variables["value"] = new VariableDefinition("value", "2+3", 5);
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part]));
|
||||||
|
var placement = snapshot.Placements[0];
|
||||||
|
var copy = placement.CopyProgram();
|
||||||
|
Assert.Equal(part.Program.ToString(), copy.ToString());
|
||||||
|
Assert.Equal(part.Program.Variables["value"].Expression, copy.Variables["value"].Expression);
|
||||||
|
Assert.Equal(part.Program.Codes.OfType<SubProgramCall>().Select(c => (c.Id, c.Offset, c.Rotation)),
|
||||||
|
copy.Codes.OfType<SubProgramCall>().Select(c => (c.Id, c.Offset, c.Rotation)));
|
||||||
|
Assert.Equal(part.Program.Codes.OfType<Comment>().Select(c => c.ToString()), copy.Codes.OfType<Comment>().Select(c => c.ToString()));
|
||||||
|
foreach (var call in copy.Codes.OfType<SubProgramCall>()) call.Program.Codes.Clear();
|
||||||
|
copy.Codes.Clear(); copy.Variables.Clear();
|
||||||
|
Assert.NotEmpty(placement.CopyProgram().Codes);
|
||||||
|
Assert.NotEmpty(part.Program.Codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Regeneration_CompletesOriginallyOmittedHoles_AndPrefixesKeepScribesOnce()
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var clean = PreparedContourTests.Holes(); clean.MoveTo(-2, -2);
|
||||||
|
clean.Codes.Add(new LinearMove(-1, -1) { Layer = LayerType.Scribe });
|
||||||
|
var part = new Part(new Drawing("incomplete old program", clean));
|
||||||
|
var original = SimplePart(Vector.Zero, parameters).Program;
|
||||||
|
Assert.True(part.RestoreLeadInProgram((Program)original.Clone(), false));
|
||||||
|
part.CuttingParameters = parameters;
|
||||||
|
var unchanged = Unchanged(part, parameters);
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters));
|
||||||
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||||
|
var proposal = Assert.Single(result.ProposedOrder);
|
||||||
|
Assert.Equal(new[] { 0, 1, 2 }, proposal.ContourChoices.Select(c => c.ContourOrdinal).Order());
|
||||||
|
var actual = Read(proposal.CopyProgram(), Vector.Zero);
|
||||||
|
Assert.Single(actual.Motions.Where(m => m.Layer == LayerType.Scribe));
|
||||||
|
Assert.Empty(new ReleasedContourState().Check(actual, Vector.Zero, 1));
|
||||||
|
unchanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedProgramCapture_PreservesInactiveMotionlessRegisteredSubprogram()
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var part = SimplePart(Vector.Zero, parameters);
|
||||||
|
part.Program.SubPrograms[-27] = new Program();
|
||||||
|
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.Empty(result.ProposedOrder[0].CopyProgram().SubPrograms[-27].Codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part SimplePart(Vector location, CuttingParameters parameters)
|
||||||
|
{
|
||||||
|
var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||||
|
var part = new Part(new Drawing("same", clean), location);
|
||||||
|
var prepared = PreparedContours.Capture(clean, parameters);
|
||||||
|
var emitted = prepared.Emit([prepared.ClosestEntry(0, new Vector(-2, 5))]);
|
||||||
|
Assert.True(part.RestoreLeadInProgram(emitted, false)); part.CuttingParameters = parameters;
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Part, CuttingParameters) Crossing()
|
||||||
|
{
|
||||||
|
var clean = PreparedContourTests.Holes();
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var prepared = PreparedContours.Capture(clean, parameters);
|
||||||
|
var choices = new[]
|
||||||
|
{
|
||||||
|
prepared.Entry(0, 0, new Vector(2, 3)),
|
||||||
|
prepared.Entry(1, 0, new Vector(8, 3)),
|
||||||
|
prepared.ClosestEntry(2, new Vector(-2, 3))
|
||||||
|
};
|
||||||
|
var original = prepared.Emit(choices);
|
||||||
|
var part = new Part(new Drawing("same", clean));
|
||||||
|
Assert.True(part.RestoreLeadInProgram(original, false));
|
||||||
|
part.CuttingParameters = parameters;
|
||||||
|
return (part, parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Action Unchanged(Part part, CuttingParameters parameters)
|
||||||
|
{
|
||||||
|
var program = part.Program;
|
||||||
|
var clean = part.BaseDrawing.Program;
|
||||||
|
var fingerprint = ExplicitContourTests.Fingerprint(program);
|
||||||
|
var cleanText = clean.ToString();
|
||||||
|
var codes = program.Codes.ToArray();
|
||||||
|
var subs = program.Codes.OfType<SubProgramCall>()
|
||||||
|
.Select(c => (c, c.Program, text: c.Program.ToString(), c.Id, c.Offset, c.Rotation)).ToArray();
|
||||||
|
var location = part.Location;
|
||||||
|
var rotation = part.Rotation;
|
||||||
|
var locked = part.LeadInsLocked;
|
||||||
|
var manual = part.HasManualLeadIns;
|
||||||
|
var bounds = part.BoundingBox;
|
||||||
|
var quantity = part.BaseDrawing.Quantity.Nested;
|
||||||
|
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
|
||||||
|
return () =>
|
||||||
|
{
|
||||||
|
Assert.Same(program, part.Program); Assert.Same(clean, part.BaseDrawing.Program);
|
||||||
|
Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(program)); Assert.Equal(cleanText, clean.ToString());
|
||||||
|
Assert.Equal(codes, program.Codes); Assert.Equal(location, part.Location); Assert.Equal(rotation, part.Rotation);
|
||||||
|
Assert.Equal(locked, part.LeadInsLocked); Assert.Equal(manual, part.HasManualLeadIns);
|
||||||
|
Assert.Same(bounds, part.BoundingBox); Assert.Same(parameters, part.CuttingParameters);
|
||||||
|
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
|
||||||
|
Assert.Equal(length, ((LineLeadIn)parameters.ExternalLeadIn).Length);
|
||||||
|
foreach (var sub in subs)
|
||||||
|
{
|
||||||
|
Assert.Same(sub.Program, sub.c.Program); Assert.Equal(sub.text, sub.c.Program.ToString());
|
||||||
|
Assert.Equal(sub.Id, sub.c.Id); Assert.Equal(sub.Offset, sub.c.Offset); Assert.Equal(sub.Rotation, sub.c.Rotation);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OwnedExecution Read(Program p, Vector location) => ExecutionMotionReader.Read(p, location, null, default);
|
||||||
|
private static string Describe(CuttingPlanResult r) => $"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message));
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class LeadPathValidationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExternalStraightLead_SafeButRejectsCrossingPlacedMaterial()
|
||||||
|
{
|
||||||
|
var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero);
|
||||||
|
Assert.True(target.IsComplete, target.Reason);
|
||||||
|
var execution = Read(ExternalLead());
|
||||||
|
Assert.True(LeadPathValidator.Check(execution, target, [target]).IsClear);
|
||||||
|
var obstacle = LeadMaterialSnapshot.Capture(Rectangle(-2, 4, -1, 6), Vector.Zero);
|
||||||
|
var result = LeadPathValidator.Check(execution, target, [obstacle]);
|
||||||
|
Assert.True(result.IsComplete, result.Reason);
|
||||||
|
Assert.False(result.IsClear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HoleScrap_SafeStraightAndArcButRejectsBulgingArc()
|
||||||
|
{
|
||||||
|
var clean = Rectangle(0, 0, 10, 10);
|
||||||
|
clean.Codes.AddRange(Rectangle(2, 2, 8, 8).Codes);
|
||||||
|
var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
|
||||||
|
Assert.True(target.IsComplete, target.Reason);
|
||||||
|
var straight = new Program();
|
||||||
|
straight.MoveTo(5, 5);
|
||||||
|
straight.Codes.Add(new LinearMove(2, 5) { Layer = LayerType.Leadin });
|
||||||
|
straight.LineTo(2, 8);
|
||||||
|
AssertClear(straight, target);
|
||||||
|
var curved = new Program();
|
||||||
|
curved.MoveTo(3, 4);
|
||||||
|
curved.Codes.Add(new ArcMove(new Vector(2, 5), new Vector(3, 5), RotationType.CW) { Layer = LayerType.Leadin });
|
||||||
|
curved.LineTo(2, 8);
|
||||||
|
AssertClear(curved, target);
|
||||||
|
var bulge = new Program();
|
||||||
|
bulge.MoveTo(4, 3);
|
||||||
|
bulge.Codes.Add(new ArcMove(new Vector(2, 5), new Vector(3, 4), RotationType.CW) { Layer = LayerType.Leadin });
|
||||||
|
bulge.LineTo(2, 8);
|
||||||
|
AssertUnsafe(bulge, target);
|
||||||
|
straight.Codes[0] = new RapidMove(1, 5); // Begins in material, not the hole.
|
||||||
|
AssertUnsafe(straight, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OtherHoleIsScrap_AndLineArcTangenciesReject()
|
||||||
|
{
|
||||||
|
var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero);
|
||||||
|
var ring = Rectangle(-4, 2, 2, 8);
|
||||||
|
ring.Codes.AddRange(Rectangle(-3.5, 3, 1, 7).Codes);
|
||||||
|
var hole = LeadMaterialSnapshot.Capture(ring, Vector.Zero);
|
||||||
|
Assert.True(hole.IsComplete, hole.Reason);
|
||||||
|
AssertClear(ExternalLead(), target, hole);
|
||||||
|
var tangent = LeadMaterialSnapshot.Capture(Circle(-1.5, 6, 1), Vector.Zero);
|
||||||
|
Assert.True(tangent.IsComplete, tangent.Reason);
|
||||||
|
AssertUnsafe(ExternalLead(), target, tangent);
|
||||||
|
var arc = new Program();
|
||||||
|
arc.MoveTo(-2, 5);
|
||||||
|
arc.Codes.Add(new ArcMove(new Vector(0, 5), new Vector(-1, 5), RotationType.CW) { Layer = LayerType.Leadin });
|
||||||
|
arc.LineTo(0, 10);
|
||||||
|
AssertClear(arc, target);
|
||||||
|
AssertUnsafe(arc, target, LeadMaterialSnapshot.Capture(Circle(-1, 7, 1), Vector.Zero));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CoincidentBoundariesAndArbitraryJointsAreNotClear()
|
||||||
|
{
|
||||||
|
var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero);
|
||||||
|
var along = new Program();
|
||||||
|
along.MoveTo(0, 2);
|
||||||
|
along.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
|
||||||
|
along.LineTo(0, 10);
|
||||||
|
AssertUnsafe(along, target);
|
||||||
|
var arbitrary = ExternalLead();
|
||||||
|
arbitrary.Codes[2] = new LinearMove(5, 5); // Adjacent move is NOT nominal contour.
|
||||||
|
AssertUnsafe(arbitrary, target);
|
||||||
|
var circular = LeadMaterialSnapshot.Capture(Circle(0, 0, 2), Vector.Zero);
|
||||||
|
var coincident = new Program();
|
||||||
|
coincident.MoveTo(2, 0);
|
||||||
|
coincident.Codes.Add(new ArcMove(new Vector(-2, 0), Vector.Zero) { Layer = LayerType.Leadin });
|
||||||
|
coincident.Codes.Add(new ArcMove(new Vector(2, 0), Vector.Zero));
|
||||||
|
AssertUnsafe(coincident, circular);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullCircleLeadCannotReuseItsJointAsAnArbitraryPierceEndpoint()
|
||||||
|
{
|
||||||
|
var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero);
|
||||||
|
var p = new Program(); p.MoveTo(0, 5);
|
||||||
|
p.Codes.Add(new ArcMove(new Vector(0, 5), new Vector(-1, 5)) { Layer = LayerType.Leadin });
|
||||||
|
p.LineTo(0, 10);
|
||||||
|
AssertUnsafe(p, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TinyNativeUncertainContactRefusesRatherThanApproves()
|
||||||
|
{
|
||||||
|
var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero);
|
||||||
|
var obstacle = LeadMaterialSnapshot.Capture(Circle(-0.000005, 5, 0.000001), Vector.Zero);
|
||||||
|
Assert.True(obstacle.IsComplete, obstacle.Reason);
|
||||||
|
var p = ExternalLead(); p.Codes[0] = new RapidMove(-0.00001, 5);
|
||||||
|
var check = LeadPathValidator.Check(Read(p), target, [obstacle]);
|
||||||
|
Assert.False(check.IsComplete); Assert.False(check.IsClear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeadoutUsesActualTabbedCutEndpoint_NotNominalClosure()
|
||||||
|
{
|
||||||
|
var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero);
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(0, 5); p.LineTo(0, 10); p.LineTo(10, 10); p.LineTo(10, 0); p.LineTo(0, 0); p.LineTo(0, 4.8);
|
||||||
|
p.Codes.Add(new LinearMove(-2, 4.8) { Layer = LayerType.Leadout });
|
||||||
|
AssertClear(p, target);
|
||||||
|
p.Codes[^1] = new LinearMove(-2, 5) { Layer = LayerType.Leadout };
|
||||||
|
AssertClear(p, target); // Departure is still (0,4.8), not the entry (0,5).
|
||||||
|
p.Codes.Insert(p.Codes.Count - 1, new RapidMove(0, 5));
|
||||||
|
AssertUnsafe(p, target); // No adjacent cut: a rapid cannot bridge the tab.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActualArcLeadoutChecksFullSweep_NotOnlyScrapEndpoints()
|
||||||
|
{
|
||||||
|
var target = LeadMaterialSnapshot.Capture(Rectangle(0, 0, 10, 10), Vector.Zero);
|
||||||
|
var p = new Program(); p.MoveTo(0, 10); p.LineTo(0, 5);
|
||||||
|
p.Codes.Add(new ArcMove(new Vector(-1, 4), new Vector(-1, 5), RotationType.CW) { Layer = LayerType.Leadout });
|
||||||
|
AssertClear(p, target);
|
||||||
|
AssertUnsafe(p, target, LeadMaterialSnapshot.Capture(Circle(-1, 3, 1), Vector.Zero));
|
||||||
|
p.Codes[^1] = new ArcMove(new Vector(-1, 4), new Vector(0, 4), RotationType.CW) { Layer = LayerType.Leadout };
|
||||||
|
AssertUnsafe(p, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false)]
|
||||||
|
[InlineData(false, true)]
|
||||||
|
[InlineData(true, false)]
|
||||||
|
[InlineData(true, true)]
|
||||||
|
public void RealStrategyLeadouts_SafePathsKeepTheTabGap(bool tabs, bool arc)
|
||||||
|
{
|
||||||
|
var clean = ExplicitContourTests.Square(false);
|
||||||
|
var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
parameters.TabsEnabled = tabs;
|
||||||
|
parameters.TabConfig = new NormalTab { Size = 0.2 };
|
||||||
|
parameters.ExternalLeadOut = arc ? new ArcLeadOut { Radius = 0.2 } : new LineLeadOut { Length = 0.2 };
|
||||||
|
var emitted = new ContourCuttingStrategy { Parameters = parameters }.Apply(clean, new Vector(-2, 5));
|
||||||
|
var execution = Read(emitted.Program);
|
||||||
|
Assert.NotEmpty(execution.Motions.Where(m => m.Layer == LayerType.Leadout));
|
||||||
|
var result = LeadPathValidator.Check(execution, target, []);
|
||||||
|
Assert.True(result.IsComplete, result.Reason); Assert.True(result.IsClear, result.Reason);
|
||||||
|
if (tabs)
|
||||||
|
{
|
||||||
|
var cuts = execution.Motions.Where(m => m.Layer == LayerType.Display && !m.Rapid).ToArray();
|
||||||
|
var leadout = Assert.Single(execution.Motions.Where(m => m.Layer == LayerType.Leadout));
|
||||||
|
Assert.True(cuts[0].Start!.Value.DistanceTo(cuts[^1].End) > 0.1);
|
||||||
|
Assert.Equal(cuts[^1].End, leadout.Start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyTabbedArcFromNominalClosure_IsRefusedNotRefit()
|
||||||
|
{
|
||||||
|
// Former emitter output: the cut stops at the tab (0,4.8), but the arc lead-out was
|
||||||
|
// generated about the nominal entry (0,5). Never recentre the arc or bridge the gap.
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(-1, 5);
|
||||||
|
p.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
|
||||||
|
p.LineTo(0, 10); p.LineTo(10, 10); p.LineTo(10, 0); p.LineTo(0, 0); p.LineTo(0, 4.8);
|
||||||
|
p.Codes.Add(new ArcMove(new Vector(-0.2, 4.8), new Vector(-0.2, 5), RotationType.CW) { Layer = LayerType.Leadout });
|
||||||
|
Assert.Throws<ArgumentException>(() => Read(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SourceMutationDoesNotAffectSnapshotOrExecution()
|
||||||
|
{
|
||||||
|
var clean = Rectangle(0, 0, 10, 10);
|
||||||
|
var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
|
||||||
|
var emitted = ExternalLead();
|
||||||
|
var execution = Read(emitted);
|
||||||
|
clean.Codes.Clear(); emitted.Codes.Clear();
|
||||||
|
Assert.True(LeadPathValidator.Check(execution, target, []).IsClear);
|
||||||
|
Assert.Empty(typeof(LeadMaterialSnapshot).GetProperties().Where(p => p.PropertyType == typeof(Program)
|
||||||
|
|| typeof(Entity).IsAssignableFrom(p.PropertyType)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RotatedIncrementalSubprogramActualEmission_AppliesLocationOnce()
|
||||||
|
{
|
||||||
|
var clean = Rectangle(0, 0, 10, 10);
|
||||||
|
var emitted = ExternalLead();
|
||||||
|
clean.Rotate(System.Math.PI / 2); emitted.Rotate(System.Math.PI / 2);
|
||||||
|
clean.Mode = Mode.Incremental; emitted.Mode = Mode.Incremental;
|
||||||
|
var cleanRoot = new Program();
|
||||||
|
cleanRoot.Codes.Add(new SubProgramCall(clean, 90) { Offset = new Vector(4, 6) });
|
||||||
|
var emittedRoot = new Program();
|
||||||
|
emittedRoot.Codes.Add(new SubProgramCall(emitted, 90) { Offset = new Vector(4, 6) });
|
||||||
|
var location = new Vector(20, 30);
|
||||||
|
var target = LeadMaterialSnapshot.Capture(cleanRoot, location);
|
||||||
|
Assert.True(target.IsComplete, target.Reason);
|
||||||
|
var execution = Read(emittedRoot, location);
|
||||||
|
var lead = Assert.Single(execution.Motions.Where(m => m.Layer == LayerType.Leadin));
|
||||||
|
Assert.Equal(19, lead.End.X, 8); Assert.Equal(36, lead.End.Y, 8);
|
||||||
|
Assert.True(LeadPathValidator.Check(execution, target, []).IsClear);
|
||||||
|
var obstacle = LeadMaterialSnapshot.Capture(Rectangle(18, 33.5, 20, 34.5), Vector.Zero);
|
||||||
|
Assert.False(LeadPathValidator.Check(execution, target, [obstacle]).IsClear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("open")]
|
||||||
|
[InlineData("self")]
|
||||||
|
[InlineData("disjoint")]
|
||||||
|
[InlineData("nested")]
|
||||||
|
[InlineData("nonfinite")]
|
||||||
|
[InlineData("zero")]
|
||||||
|
[InlineData("suppressed")]
|
||||||
|
[InlineData("lead")]
|
||||||
|
[InlineData("recursive")]
|
||||||
|
public void MalformedMaterialRefuses(string kind)
|
||||||
|
{
|
||||||
|
var p = Rectangle(0, 0, 10, 10);
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case "open": p.Codes.RemoveAt(p.Codes.Count - 1); break;
|
||||||
|
case "self": p = new Program(); p.MoveTo(0, 0); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(10, 0); p.LineTo(0, 0); break;
|
||||||
|
case "disjoint": p.Codes.AddRange(Rectangle(20, 20, 30, 30).Codes); break;
|
||||||
|
case "nested": p.Codes.AddRange(Rectangle(2, 2, 8, 8).Codes); p.Codes.AddRange(Rectangle(3, 3, 7, 7).Codes); break;
|
||||||
|
case "nonfinite": p.Codes[1] = new LinearMove(double.NaN, 2); break;
|
||||||
|
case "zero": p.Codes.Insert(1, new LinearMove(0, 0)); break;
|
||||||
|
case "suppressed": ((Motion)p.Codes[1]).Suppressed = true; break;
|
||||||
|
case "lead": ((LinearMove)p.Codes[1]).Layer = LayerType.Leadin; break;
|
||||||
|
case "recursive": p.Codes.Add(new SubProgramCall(p, 0)); break;
|
||||||
|
}
|
||||||
|
var target = LeadMaterialSnapshot.Capture(p, Vector.Zero);
|
||||||
|
Assert.False(target.IsComplete);
|
||||||
|
var result = LeadPathValidator.Check(Read(ExternalLead()), target, []);
|
||||||
|
Assert.False(result.IsComplete); Assert.False(result.IsClear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroLeadRefuses_MissingLeadIsDelegated_MarksAreNotMaterial_CancellationThrows()
|
||||||
|
{
|
||||||
|
var clean = Rectangle(0, 0, 10, 10);
|
||||||
|
clean.MoveTo(20, 20);
|
||||||
|
clean.Codes.Add(new LinearMove(30, 30) { Layer = LayerType.Scribe });
|
||||||
|
var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
|
||||||
|
Assert.True(target.IsComplete, target.Reason);
|
||||||
|
AssertClear(Rectangle(0, 0, 10, 10), target);
|
||||||
|
var p = ExternalLead(); p.Codes[0] = new RapidMove(0, 5);
|
||||||
|
var result = LeadPathValidator.Check(Read(p), target, []);
|
||||||
|
Assert.False(result.IsComplete); Assert.False(result.IsClear);
|
||||||
|
var token = new CancellationToken(true);
|
||||||
|
Assert.Throws<OperationCanceledException>(() => LeadMaterialSnapshot.Capture(clean, Vector.Zero, token));
|
||||||
|
Assert.Throws<OperationCanceledException>(() => LeadPathValidator.Check(Read(ExternalLead()), target, [], token));
|
||||||
|
p.Codes[1] = new ArcMove(new Vector(0, 5), new Vector(0, 5)) { Layer = LayerType.Leadin };
|
||||||
|
Assert.Throws<ArgumentException>(() => Read(p));
|
||||||
|
p.Codes[1] = new LinearMove(double.PositiveInfinity, 5) { Layer = LayerType.Leadin };
|
||||||
|
Assert.Throws<ArgumentException>(() => Read(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("line")]
|
||||||
|
[InlineData("arc")]
|
||||||
|
[InlineData("lineline")]
|
||||||
|
[InlineData("linearc")]
|
||||||
|
public void RealStrategyEmission_ClearExternalAndInternalLeads(string style)
|
||||||
|
{
|
||||||
|
var clean = ExplicitContourTests.Square(false);
|
||||||
|
clean.Codes.AddRange(Circle(5, 5, 2).Codes);
|
||||||
|
var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
|
||||||
|
Assert.True(target.IsComplete, target.Reason);
|
||||||
|
var parameters = ExplicitContourTests.Parameters(style);
|
||||||
|
if (style == "linearc")
|
||||||
|
((LineArcLeadIn)parameters.ExternalLeadIn).ApproachAngle = 90;
|
||||||
|
var result = new ContourCuttingStrategy { Parameters = parameters }.Apply(clean, new Vector(-2, 5));
|
||||||
|
var execution = Read(result.Program);
|
||||||
|
Assert.NotEmpty(execution.Motions.Where(m => m.Layer == LayerType.Leadin));
|
||||||
|
var check = LeadPathValidator.Check(execution, target, []);
|
||||||
|
Assert.True(check.IsComplete, check.Reason);
|
||||||
|
Assert.True(check.IsClear, check.Reason + "\n" + string.Join("\n", execution.Motions.Select(m => $"{m.Layer} {m.Start} -> {m.End}, {m.Length}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RealStrategyEmission_UnsafeCompositePierceRefusesWithoutRepair()
|
||||||
|
{
|
||||||
|
var clean = ExplicitContourTests.Square(false);
|
||||||
|
clean.Codes.AddRange(Circle(5, 5, 2).Codes);
|
||||||
|
var target = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
|
||||||
|
var emitted = new ContourCuttingStrategy { Parameters = ExplicitContourTests.Parameters("linearc") }.Apply(clean, new Vector(-2, 5));
|
||||||
|
AssertUnsafe(emitted.Program, target);
|
||||||
|
var orphan = new Program(); orphan.MoveTo(-5, 5);
|
||||||
|
orphan.Codes.Add(new LinearMove(-3, 5) { Layer = LayerType.Leadin });
|
||||||
|
AssertUnsafe(orphan, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program Circle(double x, double y, double radius)
|
||||||
|
{
|
||||||
|
var p = new Program(); p.MoveTo(x + radius, y); p.ArcTo(x + radius, y, x, y, RotationType.CCW); return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertClear(Program p, LeadMaterialSnapshot target, params LeadMaterialSnapshot[] others)
|
||||||
|
{
|
||||||
|
var result = LeadPathValidator.Check(Read(p), target, others);
|
||||||
|
Assert.True(result.IsComplete, result.Reason); Assert.True(result.IsClear, result.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertUnsafe(Program p, LeadMaterialSnapshot target, params LeadMaterialSnapshot[] others)
|
||||||
|
{
|
||||||
|
var result = LeadPathValidator.Check(Read(p), target, others);
|
||||||
|
Assert.True(result.IsComplete, result.Reason); Assert.False(result.IsClear);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Program Rectangle(double x1, double y1, double x2, double y2)
|
||||||
|
{
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(x1, y1); p.LineTo(x1, y2); p.LineTo(x2, y2);
|
||||||
|
p.LineTo(x2, y1); p.LineTo(x1, y1);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program ExternalLead()
|
||||||
|
{
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(-3, 5);
|
||||||
|
p.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
|
||||||
|
p.LineTo(0, 10);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OwnedExecution Read(Program p, Vector? location = null) =>
|
||||||
|
ExecutionMotionReader.Read(p, location ?? Vector.Zero, null, default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class MaterialRuntimeTypeTests
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> UnsupportedTypes()
|
||||||
|
{
|
||||||
|
foreach (var kind in new[] { "program", "rapid", "linear", "arc", "call", "comment", "feedrate", "kerf", "mode" })
|
||||||
|
foreach (var nested in new[] { false, true })
|
||||||
|
yield return new object[] { kind, nested };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(UnsupportedTypes))]
|
||||||
|
public void Capture_RefusesUnknownRuntimeSemanticsThroughoutExecutedGraph(string kind, bool nested)
|
||||||
|
{
|
||||||
|
var source = Unsupported(kind);
|
||||||
|
if (nested)
|
||||||
|
{
|
||||||
|
var root = new Program();
|
||||||
|
root.Codes.Add(new SubProgramCall(source, 0));
|
||||||
|
source = root;
|
||||||
|
}
|
||||||
|
var snapshot = LeadMaterialSnapshot.Capture(source, Vector.Zero);
|
||||||
|
Assert.False(snapshot.IsComplete);
|
||||||
|
Assert.NotNull(snapshot.Reason);
|
||||||
|
Assert.Throws<NotSupportedException>(() => PreparedContours.Capture(source, new CuttingParameters()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("null")]
|
||||||
|
[InlineData("codes")]
|
||||||
|
[InlineData("instruction")]
|
||||||
|
[InlineData("missing-call")]
|
||||||
|
[InlineData("recursive")]
|
||||||
|
[InlineData("depth")]
|
||||||
|
[InlineData("budget")]
|
||||||
|
public void Capture_MalformedGraphReturnsIncompleteWithoutCrashing(string kind)
|
||||||
|
{
|
||||||
|
var source = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case "null": source = null!; break;
|
||||||
|
case "codes": source.Codes = null!; break;
|
||||||
|
case "instruction": source.Codes.Insert(0, null!); break;
|
||||||
|
case "missing-call": source.Codes.Insert(0, new SubProgramCall()); break;
|
||||||
|
case "recursive": source.Codes.Add(new SubProgramCall(source, 0)); break;
|
||||||
|
case "depth":
|
||||||
|
for (var i = 0; i < 65; i++)
|
||||||
|
{
|
||||||
|
var parent = new Program();
|
||||||
|
parent.Codes.Add(new SubProgramCall(source, 0));
|
||||||
|
source = parent;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "budget": source.Codes.InsertRange(0, Enumerable.Repeat<ICode>(new Comment(), 1000001)); break;
|
||||||
|
}
|
||||||
|
Assert.False(LeadMaterialSnapshot.Capture(source, Vector.Zero).IsComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Capture_CancellationStillThrows()
|
||||||
|
{
|
||||||
|
Assert.Throws<OperationCanceledException>(() => LeadMaterialSnapshot.Capture(
|
||||||
|
Unsupported("linear"), Vector.Zero, new CancellationToken(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SupportedRead_MustPrecedeClone_WithoutChangingLegacyReader()
|
||||||
|
{
|
||||||
|
var source = Unsupported("linear");
|
||||||
|
Assert.NotEmpty(ExecutionMotionReader.Read(source, Vector.Zero, null, default).Motions);
|
||||||
|
Assert.Throws<NotSupportedException>(() => ExecutionMotionReader.ReadSupported(source, Vector.Zero, null));
|
||||||
|
// Built-in Clone erases the subclass; consumers must guard the original graph.
|
||||||
|
Assert.True(LeadMaterialSnapshot.Capture((Program)source.Clone(), Vector.Zero).IsComplete);
|
||||||
|
Assert.False(LeadMaterialSnapshot.Capture(source, Vector.Zero).IsComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program Unsupported(string kind)
|
||||||
|
{
|
||||||
|
var source = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case "program":
|
||||||
|
var custom = new CustomProgram();
|
||||||
|
custom.Codes.AddRange(source.Codes);
|
||||||
|
return custom;
|
||||||
|
case "rapid": source.Codes[0] = new CustomRapid { EndPoint = ((Motion)source.Codes[0]).EndPoint }; break;
|
||||||
|
case "linear": source.Codes[1] = new CustomLinear { EndPoint = ((Motion)source.Codes[1]).EndPoint }; break;
|
||||||
|
case "arc":
|
||||||
|
source = new Program();
|
||||||
|
source.MoveTo(1, 0);
|
||||||
|
source.Codes.Add(new CustomArc { EndPoint = new Vector(1, 0), CenterPoint = Vector.Zero });
|
||||||
|
break;
|
||||||
|
case "call":
|
||||||
|
var caller = new Program();
|
||||||
|
caller.Codes.Add(new CustomCall { Program = source });
|
||||||
|
return caller;
|
||||||
|
case "comment": source.Codes.Insert(0, new CustomComment()); break;
|
||||||
|
case "feedrate": source.Codes.Insert(0, new CustomFeedrate()); break;
|
||||||
|
case "kerf": source.Codes.Insert(0, new CustomKerf()); break;
|
||||||
|
case "mode": typeof(Program).GetField("mode", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(source, (Mode)100); break;
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CustomProgram : Program { }
|
||||||
|
private sealed class CustomRapid : RapidMove { }
|
||||||
|
private sealed class CustomLinear : LinearMove { }
|
||||||
|
private sealed class CustomArc : ArcMove { }
|
||||||
|
private sealed class CustomCall : SubProgramCall { }
|
||||||
|
private sealed class CustomComment : Comment { }
|
||||||
|
private sealed class CustomFeedrate : Feedrate { }
|
||||||
|
private sealed class CustomKerf : Kerf { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class NativeContactRegressionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TangentArcLead_RefusesFilteredSupportingCircleNaNs()
|
||||||
|
{
|
||||||
|
var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0, -1, 1, 1), Vector.Zero);
|
||||||
|
var obstacle = new Program();
|
||||||
|
obstacle.MoveTo(-0.22, 0.26);
|
||||||
|
obstacle.ArcTo(-0.22, 0.26, -0.22, 0.16, RotationType.CCW);
|
||||||
|
var material = LeadMaterialSnapshot.Capture(obstacle, Vector.Zero);
|
||||||
|
Assert.True(target.IsComplete, target.Reason);
|
||||||
|
Assert.True(material.IsComplete, material.Reason);
|
||||||
|
var lead = new Program();
|
||||||
|
lead.MoveTo(-0.2, 0);
|
||||||
|
lead.Codes.Add(new ArcMove(Vector.Zero, new Vector(-0.1, 0), RotationType.CW) { Layer = LayerType.Leadin });
|
||||||
|
lead.LineTo(0, 1);
|
||||||
|
var execution = ExecutionMotionReader.Read(lead, Vector.Zero, null, default);
|
||||||
|
var witness = new Vector(-0.16, 0.08);
|
||||||
|
Assert.True(execution.Motions[1].Curve.Contains(witness));
|
||||||
|
Assert.True(material.Rings[0][0].Contains(witness));
|
||||||
|
var result = LeadPathValidator.Check(execution, target, [material]);
|
||||||
|
Assert.False(result.IsComplete);
|
||||||
|
Assert.False(result.IsClear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.1, -0.22, 0.16)]
|
||||||
|
[InlineData(0.2, -0.28, 0.24)]
|
||||||
|
[InlineData(0.05, -0.19, 0.12)]
|
||||||
|
public void CircularNearTangency_BothDirectionsNeverCertifyNoContact(double radius, double x, double y)
|
||||||
|
{
|
||||||
|
var a = PostVerificationGeometry.Curve.Create(new Vector(-0.2, 0), Vector.Zero, new Vector(-0.1, 0), true);
|
||||||
|
var center = new Vector(x, y);
|
||||||
|
var b = PostVerificationGeometry.Curve.Create(center + new Vector(0, radius), center + new Vector(0, -radius), center, true);
|
||||||
|
foreach (var reverse in new[] { false, true })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contacts = (reverse ? b : a).Contacts(reverse ? a : b, out var overlap);
|
||||||
|
Assert.True(overlap || contacts.Count > 0, "Near-tangent circular query silently certified no contact.");
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||||
|
{
|
||||||
|
// Numerical uncertainty is a refusal, never a disjoint certificate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TouchingArcSplitHoles_RefuseMaterialCertificate()
|
||||||
|
{
|
||||||
|
var source = LeadPathValidationTests.Rectangle(-1, -1, 1, 1);
|
||||||
|
foreach (var center in new[] { new Vector(-0.1, 0), new Vector(-0.22, 0.16) })
|
||||||
|
{
|
||||||
|
source.MoveTo(center.X, center.Y + 0.1);
|
||||||
|
source.ArcTo(center.X, center.Y - 0.1, center.X, center.Y, RotationType.CCW);
|
||||||
|
source.ArcTo(center.X, center.Y + 0.1, center.X, center.Y, RotationType.CCW);
|
||||||
|
}
|
||||||
|
var snapshot = LeadMaterialSnapshot.Capture(source, Vector.Zero);
|
||||||
|
Assert.False(snapshot.IsComplete);
|
||||||
|
Assert.NotNull(snapshot.Reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class OwnedCuttingParameterTests
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> Styles()
|
||||||
|
{
|
||||||
|
foreach (var leadIn in new LeadIn[] { new NoLeadIn(), new LineLeadIn { Length = 0.3, ApproachAngle = 45 },
|
||||||
|
new ArcLeadIn { Radius = 0.2 }, new LineArcLeadIn { LineLength = 0.1, ArcRadius = 0.2, ApproachAngle = 120 },
|
||||||
|
new LineLineLeadIn { Length1 = 0.1, Length2 = 0.2, ApproachAngle1 = 40, ApproachAngle2 = -30 },
|
||||||
|
new CleanHoleLeadIn { LineLength = 0.1, ArcRadius = 0.2, Kerf = 0.01 } })
|
||||||
|
foreach (var leadOut in new LeadOut[] { new NoLeadOut(), new LineLeadOut { Length = 0.4, ApproachAngle = 80 }, new ArcLeadOut { Radius = 0.3 } })
|
||||||
|
foreach (var tab in new Tab[] { new NormalTab { Size = 0.2, CutoutMinWidth = 1, CutoutMaxWidth = 5, CutoutMinHeight = 2, CutoutMaxHeight = 6 },
|
||||||
|
new BreakerTab { Size = 0.3, BreakerDepth = 0.1, BreakerLeadInLength = 0.2, BreakerAngle = 30 },
|
||||||
|
new MachineTab { Size = 0.4, MachineTabId = 7 } })
|
||||||
|
yield return [leadIn, leadOut, tab];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Styles))]
|
||||||
|
public void Copy_PreservesEveryMemberAndBreaksEveryMutableAlias(LeadIn leadIn, LeadOut leadOut, Tab tab)
|
||||||
|
{
|
||||||
|
tab.TabLeadIn = leadIn; tab.TabLeadOut = leadOut;
|
||||||
|
var source = new CuttingParameters
|
||||||
|
{
|
||||||
|
Id = 17,
|
||||||
|
MachineName = "machine",
|
||||||
|
MaterialName = "material",
|
||||||
|
Grade = "grade",
|
||||||
|
Thickness = 0.4,
|
||||||
|
Kerf = 0.01,
|
||||||
|
PartSpacing = 0.1,
|
||||||
|
PierceClearance = 0.05,
|
||||||
|
ExternalLeadIn = leadIn,
|
||||||
|
InternalLeadIn = leadIn,
|
||||||
|
ArcCircleLeadIn = leadIn,
|
||||||
|
ExternalLeadOut = leadOut,
|
||||||
|
InternalLeadOut = leadOut,
|
||||||
|
ArcCircleLeadOut = leadOut,
|
||||||
|
TabConfig = tab,
|
||||||
|
TabsEnabled = true,
|
||||||
|
AutoTabMinSize = 0.1,
|
||||||
|
AutoTabMaxSize = 0.5,
|
||||||
|
RoundLeadInAngles = true,
|
||||||
|
LeadInAngleIncrement = 30,
|
||||||
|
Sequencing = new SequenceParameters
|
||||||
|
{
|
||||||
|
Method = SequenceMethod.LeftSide,
|
||||||
|
SmallCutoutWidth = 2,
|
||||||
|
SmallCutoutHeight = 3,
|
||||||
|
MediumCutoutWidth = 9,
|
||||||
|
MediumCutoutHeight = 10,
|
||||||
|
DistanceMediumSmall = 4,
|
||||||
|
AlternateRowsColumns = false,
|
||||||
|
AlternateCutoutsWithinRowColumn = false,
|
||||||
|
MinDistanceBetweenRowsColumns = 0.3
|
||||||
|
},
|
||||||
|
Assignment = new AssignmentParameters { Method = SequenceMethod.EdgeStart, Preference = "test", MinGeometryLength = 0.04 }
|
||||||
|
};
|
||||||
|
var copy = OwnedCuttingParameters.Copy(source);
|
||||||
|
AssertOwnedEqual(source, copy);
|
||||||
|
var secondCopy = OwnedCuttingParameters.Copy(copy);
|
||||||
|
Mutate(source, new HashSet<object>(ReferenceEqualityComparer.Instance));
|
||||||
|
AssertOwnedEqual(secondCopy, copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertOwnedEqual(object? source, object? copy)
|
||||||
|
{
|
||||||
|
Assert.Equal(source?.GetType(), copy?.GetType());
|
||||||
|
if (source == null) return;
|
||||||
|
Assert.NotSame(source, copy);
|
||||||
|
foreach (var property in source.GetType().GetProperties())
|
||||||
|
{
|
||||||
|
var a = property.GetValue(source)!; var b = property.GetValue(copy);
|
||||||
|
if (property.PropertyType.IsValueType || property.PropertyType == typeof(string))
|
||||||
|
Assert.Equal(a, b);
|
||||||
|
else
|
||||||
|
AssertOwnedEqual(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Mutate(object? source, HashSet<object> visited)
|
||||||
|
{
|
||||||
|
if (source == null || !visited.Add(source)) return;
|
||||||
|
foreach (var property in source.GetType().GetProperties())
|
||||||
|
{
|
||||||
|
var value = property.GetValue(source)!;
|
||||||
|
if (property.PropertyType == typeof(double)) property.SetValue(source, (double)value + 100);
|
||||||
|
else if (property.PropertyType == typeof(bool)) property.SetValue(source, !(bool)value);
|
||||||
|
else if (property.PropertyType == typeof(int)) property.SetValue(source, (int)value + 10);
|
||||||
|
else if (property.PropertyType == typeof(string)) property.SetValue(source, "mutated");
|
||||||
|
else if (property.PropertyType.IsEnum) property.SetValue(source, Enum.ToObject(property.PropertyType, 100));
|
||||||
|
else Mutate(value, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("null-settings")]
|
||||||
|
[InlineData("null-sequence")]
|
||||||
|
[InlineData("null-assignment")]
|
||||||
|
[InlineData("null-lead")]
|
||||||
|
[InlineData("null-leadout")]
|
||||||
|
[InlineData("null-tab")]
|
||||||
|
[InlineData("dimension")]
|
||||||
|
[InlineData("angle")]
|
||||||
|
[InlineData("increment")]
|
||||||
|
[InlineData("enum")]
|
||||||
|
[InlineData("tab-lead")]
|
||||||
|
[InlineData("custom-in")]
|
||||||
|
[InlineData("custom-out")]
|
||||||
|
[InlineData("custom-tab")]
|
||||||
|
[InlineData("custom-settings")]
|
||||||
|
[InlineData("custom-sequence")]
|
||||||
|
[InlineData("custom-assignment")]
|
||||||
|
public void Copy_RejectsInvalidOrUnsupportedSettings(string fault)
|
||||||
|
{
|
||||||
|
var source = new CuttingParameters();
|
||||||
|
switch (fault)
|
||||||
|
{
|
||||||
|
case "null-settings": source = null; break;
|
||||||
|
case "null-sequence": source.Sequencing = null; break;
|
||||||
|
case "null-assignment": source.Assignment = null; break;
|
||||||
|
case "null-lead": source.ArcCircleLeadIn = null; break;
|
||||||
|
case "null-leadout": source.InternalLeadOut = null; break;
|
||||||
|
case "null-tab": source.TabsEnabled = true; break;
|
||||||
|
case "dimension": source.PartSpacing = -1; break;
|
||||||
|
case "angle": source.ExternalLeadIn = new LineLeadIn { ApproachAngle = double.NaN }; break;
|
||||||
|
case "increment": source.LeadInAngleIncrement = 0; break;
|
||||||
|
case "enum": source.Assignment.Method = (SequenceMethod)6; break;
|
||||||
|
case "tab-lead": source.TabConfig = new NormalTab { TabLeadIn = new ArcLeadIn { Radius = double.PositiveInfinity } }; break;
|
||||||
|
case "custom-in": source.InternalLeadIn = new CustomLeadIn(); break;
|
||||||
|
case "custom-out": source.ArcCircleLeadOut = new CustomLeadOut(); break;
|
||||||
|
case "custom-tab": source.TabConfig = new CustomTab(); break;
|
||||||
|
case "custom-settings": source = new CustomParameters(); break;
|
||||||
|
case "custom-sequence": source.Sequencing = new CustomSequence(); break;
|
||||||
|
case "custom-assignment": source.Assignment = new CustomAssignment(); break;
|
||||||
|
}
|
||||||
|
Assert.ThrowsAny<Exception>(() => OwnedCuttingParameters.Copy(source));
|
||||||
|
Assert.ThrowsAny<Exception>(() => PreparedContours.Capture(ExplicitContourTests.Square(false), source));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(double.NaN)]
|
||||||
|
[InlineData(double.PositiveInfinity)]
|
||||||
|
public void Copy_RejectsEveryInvalidDimension(double bad)
|
||||||
|
{
|
||||||
|
foreach (var sample in Styles())
|
||||||
|
{
|
||||||
|
var source = new CuttingParameters { ExternalLeadIn = (LeadIn)sample[0], ExternalLeadOut = (LeadOut)sample[1], TabConfig = (Tab)sample[2] };
|
||||||
|
foreach (var owner in new object[] { source, source.Sequencing, source.Assignment, source.ExternalLeadIn, source.ExternalLeadOut, source.TabConfig })
|
||||||
|
foreach (var property in owner.GetType().GetProperties().Where(p => p.PropertyType == typeof(double) && !p.Name.Contains("Angle")))
|
||||||
|
{
|
||||||
|
var original = property.GetValue(owner);
|
||||||
|
property.SetValue(owner, bad);
|
||||||
|
Assert.Throws<ArgumentException>(() => OwnedCuttingParameters.Copy(source));
|
||||||
|
property.SetValue(owner, original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CustomLeadIn : LineLeadIn { }
|
||||||
|
private sealed class CustomLeadOut : ArcLeadOut { }
|
||||||
|
private sealed class CustomTab : NormalTab { }
|
||||||
|
private sealed class CustomParameters : CuttingParameters { }
|
||||||
|
private sealed class CustomSequence : SequenceParameters { }
|
||||||
|
private sealed class CustomAssignment : AssignmentParameters { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Engine.CuttingPlanning;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class OwnedProgramCopyTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false)]
|
||||||
|
[InlineData(false, true)]
|
||||||
|
[InlineData(true, false)]
|
||||||
|
[InlineData(true, true)]
|
||||||
|
public void FixedPayload_PreservesEveryAuthoredFieldAndGraphAlias(bool confirmed, bool locked)
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||||
|
var prepared = PreparedContours.Capture(clean, parameters);
|
||||||
|
var emitted = prepared.Emit([prepared.ClosestEntry(0, new Vector(-2, 5))]);
|
||||||
|
var part = new Part(new Drawing("metadata", clean));
|
||||||
|
Assert.True(part.RestoreLeadInProgram(emitted, locked));
|
||||||
|
part.Rotate(0.2);
|
||||||
|
AddMetadata(part.Program);
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], new Vector(-2, 5),
|
||||||
|
confirmedParameters: confirmed ? parameters : null, eligibleParts: confirmed ? [] : null));
|
||||||
|
var captured = Assert.Single(snapshot.Placements);
|
||||||
|
AssertGraph(part.Program, captured.CopyProgram());
|
||||||
|
var result = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
var proposal = Assert.Single(result.ProposedOrder);
|
||||||
|
AssertGraph(part.Program, proposal.CopyProgram());
|
||||||
|
var detached = proposal.CopyProgram();
|
||||||
|
var expectedFeed = ((Motion)part.Program.Codes.First(c => c is Motion)).Feedrate;
|
||||||
|
((Motion)detached.Codes.First(c => c is Motion)).Feedrate = 999;
|
||||||
|
detached.SubPrograms[-40].Codes.Clear();
|
||||||
|
detached.Variables.Clear();
|
||||||
|
AssertGraph(part.Program, proposal.CopyProgram());
|
||||||
|
part.Program.Codes.Clear();
|
||||||
|
part.Program.SubPrograms[-40].Codes.Clear();
|
||||||
|
part.Program.Variables.Clear();
|
||||||
|
var retained = proposal.CopyProgram();
|
||||||
|
Assert.Equal(expectedFeed, ((Motion)retained.Codes.First(c => c is Motion)).Feedrate);
|
||||||
|
Assert.NotEmpty(retained.SubPrograms[-40].Codes);
|
||||||
|
Assert.NotEmpty(retained.Variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Propose_OwnsLosslessStorageRatherThanRetainingCallerProgram()
|
||||||
|
{
|
||||||
|
var part = new Part(new Drawing("proposal", LeadPathValidationTests.Rectangle(0, 0, 10, 10)));
|
||||||
|
var captured = Assert.Single(CuttingPlanService.Capture(new CuttingPlanRequest([part])).Placements);
|
||||||
|
var authored = captured.CopyProgram();
|
||||||
|
AddMetadata(authored);
|
||||||
|
var proposed = captured.Propose(authored, ExecutionMotionReader.ReadSupported(authored, Vector.Zero, null), []);
|
||||||
|
AssertGraph(authored, proposed.CopyProgram());
|
||||||
|
authored.Codes.Clear(); authored.SubPrograms[-40].Codes.Clear(); authored.Variables.Clear();
|
||||||
|
var retained = proposed.CopyProgram();
|
||||||
|
Assert.NotEmpty(retained.Codes);
|
||||||
|
Assert.NotEmpty(retained.SubPrograms[-40].Codes);
|
||||||
|
Assert.NotEmpty(retained.Variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("cycle")]
|
||||||
|
[InlineData("depth")]
|
||||||
|
[InlineData("cached-depth")]
|
||||||
|
[InlineData("expansion")]
|
||||||
|
public void OwnedCopy_RefusesUnsafeCloneTraversalBeforeInvokingClone(string kind)
|
||||||
|
{
|
||||||
|
var root = new Program();
|
||||||
|
if (kind == "cycle") root.SubPrograms[-1] = root;
|
||||||
|
if (kind is "depth" or "cached-depth")
|
||||||
|
{
|
||||||
|
var child = new Program();
|
||||||
|
if (kind == "cached-depth") root.SubPrograms[-2] = child;
|
||||||
|
var parent = child;
|
||||||
|
for (var i = 0; i < 64; i++)
|
||||||
|
{
|
||||||
|
var next = new Program(); next.SubPrograms[-1] = parent; parent = next;
|
||||||
|
}
|
||||||
|
root.SubPrograms[-1] = parent;
|
||||||
|
}
|
||||||
|
if (kind == "expansion")
|
||||||
|
{
|
||||||
|
var child = new Program();
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var left = new Program(); left.SubPrograms[-1] = child;
|
||||||
|
var right = new Program(); right.SubPrograms[-1] = child;
|
||||||
|
var parent = new Program(); parent.SubPrograms[-1] = left; parent.SubPrograms[-2] = right;
|
||||||
|
child = parent;
|
||||||
|
}
|
||||||
|
root.SubPrograms[-1] = child;
|
||||||
|
}
|
||||||
|
Assert.Throws<ArgumentException>(() => OwnedProgramCopy.Copy(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OwnedCopy_HonorsCancellationBeforeClone()
|
||||||
|
{
|
||||||
|
Assert.Throws<OperationCanceledException>(() => OwnedProgramCopy.Copy(new Program(), new CancellationToken(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMetadata(Program root)
|
||||||
|
{
|
||||||
|
var leaf = new Program();
|
||||||
|
leaf.MoveTo(-1, 0);
|
||||||
|
leaf.Codes.Add(new LinearMove(-2, 0) { Layer = LayerType.Scribe });
|
||||||
|
leaf.Codes.Add(new ArcMove(-1, 1, -1, 0, RotationType.CW) { Layer = LayerType.Scribe });
|
||||||
|
leaf.Rotate(0.37);
|
||||||
|
leaf.Mode = Mode.Incremental;
|
||||||
|
var degrees = 0.37 * 180 / System.Math.PI;
|
||||||
|
var middle = new Program();
|
||||||
|
middle.Codes.Add(new SubProgramCall(leaf, degrees) { Id = -7, Offset = new Vector(-1, 0) });
|
||||||
|
middle.SubPrograms[-7] = leaf;
|
||||||
|
root.Codes.Add(new SubProgramCall(middle, 0) { Id = -9, Offset = new Vector(-5, -5) });
|
||||||
|
root.Codes.Add(new SubProgramCall(leaf, degrees) { Id = -7, Offset = new Vector(-10, -10) });
|
||||||
|
root.SubPrograms[-9] = middle;
|
||||||
|
root.SubPrograms[-7] = leaf;
|
||||||
|
var inactive = new Program(Mode.Incremental);
|
||||||
|
inactive.Codes.Add(new LinearMove(4, 5) { Suppressed = true, Layer = LayerType.Display });
|
||||||
|
inactive.SubPrograms[-7] = leaf;
|
||||||
|
root.SubPrograms[-40] = inactive;
|
||||||
|
root.SubPrograms[-41] = new Program(); // Accepted motionless inactive registration.
|
||||||
|
foreach (var p in new[] { root, middle, leaf, inactive })
|
||||||
|
{
|
||||||
|
p.Codes.Insert(0, new Comment("literal, : #value"));
|
||||||
|
p.Codes.Insert(1, new Feedrate(123.5) { VariableRef = "speed" });
|
||||||
|
p.Codes.Insert(2, new Kerf(KerfType.Right));
|
||||||
|
p.Variables["value"] = new VariableDefinition("value", "2+3", 5, inline: true, global: true);
|
||||||
|
foreach (var motion in p.Codes.OfType<Motion>())
|
||||||
|
{
|
||||||
|
motion.UseExactStop = true;
|
||||||
|
motion.Feedrate = 123;
|
||||||
|
motion.VariableRefs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["X"] = "value" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertGraph(Program source, Program copy)
|
||||||
|
{
|
||||||
|
var mapped = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
var reverse = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||||
|
Visit(source, copy);
|
||||||
|
void Visit(Program original, Program owned)
|
||||||
|
{
|
||||||
|
Assert.NotSame(original, owned);
|
||||||
|
if (mapped.TryGetValue(original, out var previous))
|
||||||
|
{
|
||||||
|
Assert.Same(previous, owned);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Assert.True(reverse.Add(owned));
|
||||||
|
mapped.Add(original, owned);
|
||||||
|
Assert.Equal(original.Mode, owned.Mode);
|
||||||
|
Bits(original.Rotation, owned.Rotation);
|
||||||
|
Assert.NotSame(original.Codes, owned.Codes);
|
||||||
|
Assert.NotSame(original.Variables, owned.Variables);
|
||||||
|
Assert.NotSame(original.SubPrograms, owned.SubPrograms);
|
||||||
|
Assert.Equal(original.Variables.Keys, owned.Variables.Keys);
|
||||||
|
foreach (var (key, value) in original.Variables)
|
||||||
|
{
|
||||||
|
var actual = owned.Variables[key];
|
||||||
|
Assert.Equal(value.Name, actual.Name); Assert.Equal(value.Expression, actual.Expression);
|
||||||
|
Bits(value.Value, actual.Value); Assert.Equal(value.Inline, actual.Inline); Assert.Equal(value.Global, actual.Global);
|
||||||
|
}
|
||||||
|
Assert.Equal(original.Codes.Count, owned.Codes.Count);
|
||||||
|
for (var i = 0; i < original.Codes.Count; i++)
|
||||||
|
{
|
||||||
|
var code = original.Codes[i]; var actual = owned.Codes[i];
|
||||||
|
Assert.NotSame(code, actual); Assert.Equal(code.GetType(), actual.GetType());
|
||||||
|
if (code is Motion motion)
|
||||||
|
{
|
||||||
|
var m = Assert.IsAssignableFrom<Motion>(actual);
|
||||||
|
Point(motion.EndPoint, m.EndPoint);
|
||||||
|
Assert.Equal(motion.UseExactStop, m.UseExactStop); Assert.Equal(motion.Feedrate, m.Feedrate);
|
||||||
|
Assert.Equal(motion.Suppressed, m.Suppressed);
|
||||||
|
if (motion.VariableRefs == null) Assert.Null(m.VariableRefs);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.NotSame(motion.VariableRefs, m.VariableRefs);
|
||||||
|
Assert.Equal(motion.VariableRefs, m.VariableRefs);
|
||||||
|
Assert.Equal(motion.VariableRefs.ContainsKey("x"), m.VariableRefs!.ContainsKey("x"));
|
||||||
|
}
|
||||||
|
if (motion is LinearMove line) Assert.Equal(line.Layer, ((LinearMove)m).Layer);
|
||||||
|
if (motion is ArcMove arc)
|
||||||
|
{
|
||||||
|
var a = Assert.IsType<ArcMove>(m);
|
||||||
|
Point(arc.CenterPoint, a.CenterPoint); Assert.Equal(arc.Rotation, a.Rotation); Assert.Equal(arc.Layer, a.Layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (code is Comment comment) Assert.Equal(comment.Value, Assert.IsType<Comment>(actual).Value);
|
||||||
|
if (code is Feedrate feed)
|
||||||
|
{
|
||||||
|
var f = Assert.IsType<Feedrate>(actual); Bits(feed.Value, f.Value); Assert.Equal(feed.VariableRef, f.VariableRef);
|
||||||
|
}
|
||||||
|
if (code is Kerf kerf) Assert.Equal(kerf.Value, Assert.IsType<Kerf>(actual).Value);
|
||||||
|
if (code is SubProgramCall call)
|
||||||
|
{
|
||||||
|
var c = Assert.IsType<SubProgramCall>(actual);
|
||||||
|
Assert.Equal(call.Id, c.Id); Point(call.Offset, c.Offset); Bits(call.Rotation, c.Rotation);
|
||||||
|
Visit(call.Program, c.Program);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.Equal(original.SubPrograms.Keys, owned.SubPrograms.Keys);
|
||||||
|
foreach (var (key, child) in original.SubPrograms) Visit(child, owned.SubPrograms[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Point(Vector expected, Vector actual) { Bits(expected.X, actual.X); Bits(expected.Y, actual.Y); }
|
||||||
|
private static void Bits(double expected, double actual) => Assert.Equal(BitConverter.DoubleToInt64Bits(expected), BitConverter.DoubleToInt64Bits(actual));
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class PreparedContourTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Emit_RespectsEveryExplicitEntryAndContourOrder()
|
||||||
|
{
|
||||||
|
var source = Holes();
|
||||||
|
var prepared = PreparedContours.Capture(source, ExplicitContourTests.Parameters());
|
||||||
|
var choices = new[]
|
||||||
|
{
|
||||||
|
prepared.ClosestEntry(1, new Vector(20, 3)),
|
||||||
|
prepared.ClosestEntry(0, new Vector(20, 3)),
|
||||||
|
prepared.ClosestEntry(2, new Vector(20, 5))
|
||||||
|
};
|
||||||
|
var emitted = prepared.Emit(choices);
|
||||||
|
var calls = emitted.Codes.OfType<SubProgramCall>().ToArray();
|
||||||
|
Assert.Equal(new[] { new Vector(7, 3), new Vector(3, 3) }, calls.Select(c => c.Offset));
|
||||||
|
var cuts = ExecutionMotionReader.Read(emitted, Vector.Zero, null, default).Motions
|
||||||
|
.Where(m => m.Layer == LayerType.Display && !m.Rapid).ToArray();
|
||||||
|
Assert.Equal(new Vector(8, 3), cuts[0].Start);
|
||||||
|
Assert.Equal(new Vector(4, 3), cuts[1].Start);
|
||||||
|
Assert.Equal(source.ToString(), Holes().ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("duplicate")]
|
||||||
|
[InlineData("omitted")]
|
||||||
|
[InlineData("foreign")]
|
||||||
|
[InlineData("perimeter-first")]
|
||||||
|
public void Emit_RejectsIncompleteOrForeignAccounting(string fault)
|
||||||
|
{
|
||||||
|
var prepared = PreparedContours.Capture(Holes(), ExplicitContourTests.Parameters());
|
||||||
|
var choices = Enumerable.Range(0, 3).Select(i => prepared.ClosestEntry(i, new Vector(20, 5))).ToArray();
|
||||||
|
switch (fault)
|
||||||
|
{
|
||||||
|
case "duplicate": choices[1] = choices[0]; break;
|
||||||
|
case "omitted": choices = choices[..2]; break;
|
||||||
|
case "foreign": choices[0] = PreparedContours.Capture(Holes(), ExplicitContourTests.Parameters()).ClosestEntry(0, Vector.Zero); break;
|
||||||
|
case "perimeter-first": Array.Reverse(choices); break;
|
||||||
|
}
|
||||||
|
Assert.Throws<ArgumentException>(() => prepared.Emit(choices));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prepared_OwnsGeometryAndDeepSettings()
|
||||||
|
{
|
||||||
|
var source = Holes();
|
||||||
|
var settings = ExplicitContourTests.Parameters();
|
||||||
|
var prepared = PreparedContours.Capture(source, settings);
|
||||||
|
var choices = Enumerable.Range(0, 3).Select(i => prepared.ClosestEntry(i, new Vector(20, 5))).ToArray();
|
||||||
|
var before = ExplicitContourTests.Fingerprint(prepared.Emit(choices));
|
||||||
|
source.Codes.Clear();
|
||||||
|
((LineLeadIn)settings.ExternalLeadIn).Length = 100;
|
||||||
|
settings.TabsEnabled = true;
|
||||||
|
Assert.Equal(before, ExplicitContourTests.Fingerprint(prepared.Emit(choices)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(ExplicitContourTests.RetainedStyles), MemberType = typeof(ExplicitContourTests))]
|
||||||
|
public void ExplicitEmission_OrdinarySingleContourMatchesLegacy(string style, bool reverse, bool internalContour)
|
||||||
|
{
|
||||||
|
var source = ExplicitContourTests.Square(reverse);
|
||||||
|
var target = source.ToGeometry().First(e => e.Layer != SpecialLayers.Rapid);
|
||||||
|
var parameters = ExplicitContourTests.Parameters(style);
|
||||||
|
if (internalContour)
|
||||||
|
{
|
||||||
|
source.MoveTo(-5, -5); source.LineTo(-5, 15); source.LineTo(15, 15);
|
||||||
|
source.LineTo(15, -5); source.LineTo(-5, -5);
|
||||||
|
parameters.ExternalLeadIn = new NoLeadIn();
|
||||||
|
}
|
||||||
|
var prepared = PreparedContours.Capture(source, parameters);
|
||||||
|
var choice = prepared.ClosestEntry(0, Vector.Zero);
|
||||||
|
var choices = internalContour
|
||||||
|
? new[] { choice, prepared.Entry(1, 0, new Vector(-5, -5)) }
|
||||||
|
: new[] { choice };
|
||||||
|
var legacy = new ContourCuttingStrategy { Parameters = parameters }.ApplySingle(source,
|
||||||
|
Vector.Zero, target, internalContour ? ContourType.Internal : ContourType.External).Program;
|
||||||
|
Assert.Equal(ExplicitContourTests.Fingerprint(legacy), ExplicitContourTests.Fingerprint(prepared.Emit(choices)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Entries_AreStableBoundedNativeAndGeometricallyUnique()
|
||||||
|
{
|
||||||
|
var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), new CuttingParameters());
|
||||||
|
var approach = new Vector(-1, 5);
|
||||||
|
var entries = prepared.Entries(0, approach);
|
||||||
|
Assert.Equal(prepared.ClosestEntry(0, approach), entries[0]);
|
||||||
|
Assert.Equal(new[] { new Vector(0, 5), new Vector(0, 0), new Vector(0, 10),
|
||||||
|
new Vector(5, 10), new Vector(10, 10), new Vector(10, 5), new Vector(10, 0), new Vector(5, 0) },
|
||||||
|
entries.Select(e => e.Point));
|
||||||
|
Assert.Equal(entries, prepared.Entries(0, approach));
|
||||||
|
Assert.Equal(entries.Take(3), prepared.Entries(0, approach, 3));
|
||||||
|
Assert.Single(prepared.Entries(0, approach, 1));
|
||||||
|
Assert.Throws<NotSupportedException>(() => ((IList<ContourChoice>)entries).Clear());
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => prepared.Entries(0, approach, 0));
|
||||||
|
Assert.Throws<OperationCanceledException>(() => prepared.Entries(0, approach,
|
||||||
|
token: new CancellationToken(true)));
|
||||||
|
Assert.Throws<OperationCanceledException>(() => PreparedContours.Capture(
|
||||||
|
ExplicitContourTests.Square(false), new CuttingParameters(), new CancellationToken(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Entries_IncludeNativeArcMidpointAndCircleAngles()
|
||||||
|
{
|
||||||
|
var source = new Program();
|
||||||
|
source.MoveTo(1, 0); source.ArcTo(-1, 0, 0, 0, RotationType.CCW); source.LineTo(1, 0);
|
||||||
|
var prepared = PreparedContours.Capture(source, new CuttingParameters());
|
||||||
|
var entries = prepared.Entries(0, new Vector(0, -2));
|
||||||
|
Assert.Contains(entries, e => e.Point.DistanceTo(new Vector(0, 1)) < 1e-8);
|
||||||
|
Assert.Contains(entries, e => e.Point.DistanceTo(new Vector(-1, 0)) < 1e-8);
|
||||||
|
prepared = PreparedContours.Capture(Holes(), new CuttingParameters());
|
||||||
|
var circles = prepared.Entries(0, new Vector(5, 5));
|
||||||
|
Assert.InRange(circles.Count, 8, 9);
|
||||||
|
Assert.Equal(circles, prepared.Entries(0, new Vector(5, 5)));
|
||||||
|
Assert.All(circles, e => Assert.Equal(1, e.Point.DistanceTo(new Vector(3, 3)), 8));
|
||||||
|
Assert.Equal(circles.Count, circles.Select(e => e.Point).Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-1, 0, 0, 0)]
|
||||||
|
[InlineData(3, 0, 0, 0)]
|
||||||
|
[InlineData(0, -1, 0, 0)]
|
||||||
|
[InlineData(0, 1, 3, 3)]
|
||||||
|
[InlineData(0, 0, 3, 3)]
|
||||||
|
[InlineData(0, 0, double.NaN, 3)]
|
||||||
|
[InlineData(0, 0, double.PositiveInfinity, 3)]
|
||||||
|
public void Entry_RejectsInvalidNativeChoices(int contour, int entity, double x, double y)
|
||||||
|
{
|
||||||
|
var prepared = PreparedContours.Capture(Holes(), new CuttingParameters());
|
||||||
|
Assert.Throws<ArgumentException>(() => prepared.Entry(contour, entity, new Vector(x, y)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Emit_RevalidatesRecordCopiesAndRejectsUnownedRecords()
|
||||||
|
{
|
||||||
|
var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), new CuttingParameters());
|
||||||
|
var choice = prepared.ClosestEntry(0, Vector.Zero);
|
||||||
|
Assert.Throws<ArgumentException>(() => prepared.Emit([choice with { EntityOrdinal = 100 }]));
|
||||||
|
Assert.Throws<ArgumentException>(() => prepared.Emit([choice with { Point = new Vector(5, 5) }]));
|
||||||
|
Assert.Throws<ArgumentException>(() => prepared.Emit([new ContourChoice(0, 0, Vector.Zero)]));
|
||||||
|
Assert.Throws<ArgumentException>(() => prepared.Emit([null]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Capture_AccountsForDuplicateContoursWithoutJoiningOrDroppingThem()
|
||||||
|
{
|
||||||
|
var source = ExplicitContourTests.Square(false);
|
||||||
|
foreach (var ignored in new[] { 0, 1 })
|
||||||
|
{
|
||||||
|
source.MoveTo(2, 2); source.LineTo(2, 4); source.LineTo(4, 4);
|
||||||
|
source.LineTo(4, 2); source.LineTo(2, 2);
|
||||||
|
source.MoveTo(7, 7); source.ArcTo(7, 7, 6, 7, RotationType.CCW);
|
||||||
|
}
|
||||||
|
var prepared = PreparedContours.Capture(source, new CuttingParameters());
|
||||||
|
Assert.Equal(5, prepared.Count);
|
||||||
|
var choices = Enumerable.Range(0, 5).Select(i => prepared.ClosestEntry(i, Vector.Zero)).ToArray();
|
||||||
|
var motions = ExecutionMotionReader.Read(prepared.Emit(choices), Vector.Zero, null, default).Motions;
|
||||||
|
Assert.Equal(14, motions.Count(m => !m.Rapid && m.Layer == LayerType.Display));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("open")]
|
||||||
|
[InlineData("lead")]
|
||||||
|
[InlineData("layer")]
|
||||||
|
[InlineData("derived")]
|
||||||
|
[InlineData("mode")]
|
||||||
|
public void Capture_RefusesUnsupportedOrUncleanExecution(string fault)
|
||||||
|
{
|
||||||
|
var source = ExplicitContourTests.Square(false);
|
||||||
|
if (fault == "open") source.Codes.RemoveAt(source.Codes.Count - 1);
|
||||||
|
if (fault == "lead") ((LinearMove)source.Codes[1]).Layer = LayerType.Leadin;
|
||||||
|
if (fault == "layer") ((LinearMove)source.Codes[1]).Layer = (LayerType)100;
|
||||||
|
if (fault == "derived") source.Codes[1] = new CustomLinearMove();
|
||||||
|
if (fault == "mode") typeof(Program).GetField("mode", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.SetValue(source, (Mode)100);
|
||||||
|
Assert.ThrowsAny<Exception>(() => PreparedContours.Capture(source, new CuttingParameters()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CustomLinearMove : LinearMove { }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitCircle_RoundsActualEntryClampsAndSharesOwnedSubprograms()
|
||||||
|
{
|
||||||
|
var source = Holes();
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
parameters.ArcCircleLeadIn = new LineLeadIn { Length = 10 };
|
||||||
|
parameters.RoundLeadInAngles = true;
|
||||||
|
parameters.LeadInAngleIncrement = 90;
|
||||||
|
var prepared = PreparedContours.Capture(source, parameters);
|
||||||
|
var choices = new[] { prepared.ClosestEntry(0, new Vector(5, 5)),
|
||||||
|
prepared.ClosestEntry(1, new Vector(9, 5)), prepared.ClosestEntry(2, Vector.Zero) };
|
||||||
|
var result = prepared.Emit(choices);
|
||||||
|
var calls = result.Codes.OfType<SubProgramCall>().ToArray();
|
||||||
|
Assert.Equal(2, calls.Length);
|
||||||
|
Assert.Same(calls[0].Program, calls[1].Program);
|
||||||
|
var motions = ExecutionMotionReader.Read(result, Vector.Zero, null, default).Motions;
|
||||||
|
var leads = motions.Where(m => m.Layer == LayerType.Leadin).Take(2).ToArray();
|
||||||
|
Assert.All(leads, m => Assert.True(m.Length < 2));
|
||||||
|
Assert.True(choices[0].Point.DistanceTo(leads[0].End) > 0.1);
|
||||||
|
Assert.Equal(new Vector(4, 3), leads[0].End);
|
||||||
|
Assert.True(leads[0].Start!.Value.DistanceTo(new Vector(3, 3)) <= 0.95 + 1e-8);
|
||||||
|
var before = ExplicitContourTests.Fingerprint(prepared.Emit(choices));
|
||||||
|
calls[0].Program.Codes.Clear(); result.Codes.Clear(); source.Codes.Clear();
|
||||||
|
Assert.Equal(before, ExplicitContourTests.Fingerprint(prepared.Emit(choices)));
|
||||||
|
Assert.NotSame(calls[0].Program, prepared.Emit(choices).Codes.OfType<SubProgramCall>().First().Program);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("normal")]
|
||||||
|
[InlineData("breaker")]
|
||||||
|
[InlineData("machine")]
|
||||||
|
public void ExplicitTab_RetainsLegacyGapAndOwnsTabSize(string style)
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
parameters.TabsEnabled = true;
|
||||||
|
parameters.TabConfig = style switch
|
||||||
|
{
|
||||||
|
"breaker" => new BreakerTab { Size = 0.2 },
|
||||||
|
"machine" => new MachineTab { Size = 0.2 },
|
||||||
|
_ => new NormalTab { Size = 0.2 }
|
||||||
|
};
|
||||||
|
var source = ExplicitContourTests.Square(false);
|
||||||
|
var approach = new Vector(-1, 5);
|
||||||
|
var prepared = PreparedContours.Capture(source, parameters);
|
||||||
|
var choice = prepared.ClosestEntry(0, approach);
|
||||||
|
var result = prepared.Emit([choice]);
|
||||||
|
var legacy = new ContourCuttingStrategy { Parameters = parameters }.Apply(source, approach).Program;
|
||||||
|
Assert.Equal(ExplicitContourTests.Fingerprint(legacy), ExplicitContourTests.Fingerprint(result));
|
||||||
|
var cuts = ExecutionMotionReader.Read(result, Vector.Zero, null, default).Motions
|
||||||
|
.Where(m => !m.Rapid && m.Layer == LayerType.Display).ToArray();
|
||||||
|
Assert.True(cuts[0].Start!.Value.DistanceTo(cuts[^1].End) > 0.1);
|
||||||
|
parameters.TabConfig.Size = 8; parameters.TabsEnabled = false;
|
||||||
|
Assert.Equal(ExplicitContourTests.Fingerprint(result), ExplicitContourTests.Fingerprint(prepared.Emit([choice])));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrefixAndCompletePrograms_EmitScribesExactlyOnceAndOwnThem()
|
||||||
|
{
|
||||||
|
var source = Holes();
|
||||||
|
source.MoveTo(1, 1); source.Codes.Add(new LinearMove(new Vector(2, 1)) { Layer = LayerType.Scribe });
|
||||||
|
var prepared = PreparedContours.Capture(source, new CuttingParameters());
|
||||||
|
var choices = Enumerable.Range(0, 3).Select(i => prepared.ClosestEntry(i, Vector.Zero)).ToArray();
|
||||||
|
Assert.Throws<ArgumentException>(() => prepared.EmitPrefix([choices[2]]));
|
||||||
|
foreach (var count in new[] { 0, 1, 2, 3 })
|
||||||
|
{
|
||||||
|
var result = prepared.EmitPrefix(choices.Take(count).ToArray());
|
||||||
|
var motions = ExecutionMotionReader.Read(result, Vector.Zero, null, default).Motions;
|
||||||
|
Assert.Single(motions.Where(m => m.Layer == LayerType.Scribe));
|
||||||
|
result.Codes.Clear();
|
||||||
|
}
|
||||||
|
Assert.Single(ExecutionMotionReader.Read(prepared.Emit(choices), Vector.Zero, null, default).Motions
|
||||||
|
.Where(m => m.Layer == LayerType.Scribe));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Capture_ExpandsSharedSubprogramFramesAndOwnsTheirMotion(bool incremental)
|
||||||
|
{
|
||||||
|
var hole = new Program();
|
||||||
|
hole.MoveTo(1, 0); hole.ArcTo(1, 0, 0, 0, RotationType.CCW);
|
||||||
|
if (incremental) hole.Mode = Mode.Incremental;
|
||||||
|
var source = ExplicitContourTests.Square(false);
|
||||||
|
source.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(3, 3) });
|
||||||
|
source.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(7, 3) });
|
||||||
|
var prepared = PreparedContours.Capture(source, ExplicitContourTests.Parameters());
|
||||||
|
Assert.Equal(3, prepared.Count);
|
||||||
|
var choices = Enumerable.Range(0, 3).Select(i => prepared.ClosestEntry(i, new Vector(20, 3))).ToArray();
|
||||||
|
Assert.Equal(new Vector(4, 3), choices[0].Point);
|
||||||
|
Assert.Equal(new Vector(8, 3), choices[1].Point);
|
||||||
|
var before = ExplicitContourTests.Fingerprint(prepared.Emit(choices));
|
||||||
|
hole.Codes.Clear(); source.Codes.Clear();
|
||||||
|
Assert.Equal(before, ExplicitContourTests.Fingerprint(prepared.Emit(choices)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Prefix_RejectsDuplicateContoursEvenBeforeThePerimeter()
|
||||||
|
{
|
||||||
|
var prepared = PreparedContours.Capture(Holes(), new CuttingParameters());
|
||||||
|
var choice = prepared.ClosestEntry(0, Vector.Zero);
|
||||||
|
Assert.Throws<ArgumentException>(() => prepared.EmitPrefix([choice, choice]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("none", false)]
|
||||||
|
[InlineData("none", true)]
|
||||||
|
[InlineData("line", false)]
|
||||||
|
[InlineData("line", true)]
|
||||||
|
[InlineData("arc", false)]
|
||||||
|
[InlineData("arc", true)]
|
||||||
|
public void ExplicitLeadOut_RetainsLegacyGeometryAndOwnership(string style, bool reversed)
|
||||||
|
{
|
||||||
|
var source = ExplicitContourTests.Square(reversed);
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
parameters.ExternalLeadOut = style switch
|
||||||
|
{
|
||||||
|
"line" => new LineLeadOut { Length = 0.2, ApproachAngle = 45 },
|
||||||
|
"arc" => new ArcLeadOut { Radius = 0.2 },
|
||||||
|
_ => new NoLeadOut()
|
||||||
|
};
|
||||||
|
var approach = new Vector(-1, 5);
|
||||||
|
var prepared = PreparedContours.Capture(source, parameters);
|
||||||
|
var choice = prepared.ClosestEntry(0, approach);
|
||||||
|
var emitted = prepared.Emit([choice]);
|
||||||
|
var legacy = new ContourCuttingStrategy { Parameters = parameters }.Apply(source, approach).Program;
|
||||||
|
Assert.Equal(ExplicitContourTests.Fingerprint(legacy), ExplicitContourTests.Fingerprint(emitted));
|
||||||
|
if (parameters.ExternalLeadOut is LineLeadOut line) line.Length = 9;
|
||||||
|
if (parameters.ExternalLeadOut is ArcLeadOut arc) arc.Radius = 9;
|
||||||
|
Assert.Equal(ExplicitContourTests.Fingerprint(emitted), ExplicitContourTests.Fingerprint(prepared.Emit([choice])));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Program Holes()
|
||||||
|
{
|
||||||
|
var p = ExplicitContourTests.Square(false);
|
||||||
|
foreach (var x in new[] { 3.0, 7.0 })
|
||||||
|
{
|
||||||
|
p.MoveTo(x + 1, 3); p.ArcTo(x + 1, 3, x, 3, RotationType.CCW);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Engine.CuttingPlanning;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class ReplayPoseBindingTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("near-x")]
|
||||||
|
[InlineData("near-y")]
|
||||||
|
[InlineData("zero-x")]
|
||||||
|
[InlineData("zero-y")]
|
||||||
|
[InlineData("zero-rotation")]
|
||||||
|
[InlineData("matching-bits")]
|
||||||
|
public void ExactReplay_BindsEveryPoseScalarByBits(string fault)
|
||||||
|
{
|
||||||
|
var part = new Part(new Drawing("pose", LeadPathValidationTests.Rectangle(0, 0, 10, 10)));
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], new Vector(-2, 5),
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||||
|
var ready = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, ready.Status);
|
||||||
|
var source = Assert.Single(snapshot.Placements);
|
||||||
|
var proposal = Assert.Single(ready.ProposedOrder);
|
||||||
|
var x = source.Location.X; var y = source.Location.Y; var rotation = source.Rotation;
|
||||||
|
var negativeZero = BitConverter.Int64BitsToDouble(long.MinValue);
|
||||||
|
if (fault == "near-x") x += 1e-10;
|
||||||
|
if (fault == "near-y") y += 1e-10;
|
||||||
|
if (fault == "zero-x") x = negativeZero;
|
||||||
|
if (fault == "zero-y") y = negativeZero;
|
||||||
|
if (fault == "zero-rotation") rotation = negativeZero;
|
||||||
|
// These deliberately remain equal under the old geometric/numeric operators.
|
||||||
|
Assert.True(source.Location == new Vector(x, y));
|
||||||
|
Assert.True(source.Rotation == rotation);
|
||||||
|
var sameBits = BitConverter.DoubleToInt64Bits(source.Location.X) == BitConverter.DoubleToInt64Bits(x)
|
||||||
|
&& BitConverter.DoubleToInt64Bits(source.Location.Y) == BitConverter.DoubleToInt64Bits(y)
|
||||||
|
&& BitConverter.DoubleToInt64Bits(source.Rotation) == BitConverter.DoubleToInt64Bits(rotation);
|
||||||
|
Assert.Equal(fault == "matching-bits", sameBits);
|
||||||
|
var selected = new FixedProgramPlacement(source.SourcePart, source.SourceOrdinal, new Vector(x, y), rotation,
|
||||||
|
source.LeadInsLocked, proposal.Execution, proposal.CopyProgram(), source.Prepared, source.Material, proposal.ContourChoices)
|
||||||
|
.Propose(proposal.CopyProgram(), proposal.Execution, proposal.ContourChoices);
|
||||||
|
var replay = CuttingPlanService.ReplayPrograms(snapshot, [selected], 0, default);
|
||||||
|
Assert.Equal(sameBits ? CuttingPlanStatus.Ready : CuttingPlanStatus.InvalidInput, replay.Status);
|
||||||
|
Assert.Equal(sameBits, replay.IndependentlyReplayed);
|
||||||
|
if (sameBits) Assert.Single(replay.ProposedOrder);
|
||||||
|
else Assert.Empty(replay.ProposedOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingStrategy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A tab trims the perimeter short of its entry, so the lead-out leaves from where the
|
||||||
|
/// trimmed cut actually ends. Generating it from the nominal entry emitted an arc whose
|
||||||
|
/// start was off its own radius.
|
||||||
|
/// </summary>
|
||||||
|
public class TabbedLeadOutTests
|
||||||
|
{
|
||||||
|
private const double TabSize = 0.2;
|
||||||
|
private const double LeadRadius = 0.2;
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void ArcLeadOut_LeavesTangentlyFromTheTrimmedCutEnd(bool reversed)
|
||||||
|
{
|
||||||
|
var execution = Emit(ExplicitContourTests.Square(reversed), new Vector(-2, 5),
|
||||||
|
new ArcLeadOut { Radius = LeadRadius }, tabs: true);
|
||||||
|
var (cuts, leadout) = Split(execution);
|
||||||
|
|
||||||
|
var end = cuts[^1].End;
|
||||||
|
Assert.Equal(0, end.X, 9);
|
||||||
|
Assert.Equal(TabSize, System.Math.Abs(end.Y - 5), 9);
|
||||||
|
Assert.Equal(end, leadout.Start!.Value);
|
||||||
|
var arc = Assert.IsType<Arc>(leadout.Curve.ToEntity());
|
||||||
|
Assert.Equal(LeadRadius, arc.Radius, 9);
|
||||||
|
// Centred on the actual end's outward normal, so it touches the edge only where it starts.
|
||||||
|
Assert.Equal(-LeadRadius, arc.Center.X, 9);
|
||||||
|
Assert.Equal(end.Y, arc.Center.Y, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ArcLeadOut_OnCurvedPerimeter_IsTangentAtTheTrimmedEnd()
|
||||||
|
{
|
||||||
|
// Half disc: the entry is on the arc, so the trimmed end's radial differs from the entry's.
|
||||||
|
var clean = new Program();
|
||||||
|
clean.MoveTo(0, 5); clean.LineTo(10, 5); clean.ArcTo(0, 5, 5, 5, RotationType.CW);
|
||||||
|
var execution = Emit(clean, new Vector(5, -2), new ArcLeadOut { Radius = LeadRadius }, tabs: true);
|
||||||
|
var (cuts, leadout) = Split(execution);
|
||||||
|
|
||||||
|
var centre = new Vector(5, 5);
|
||||||
|
var end = cuts[^1].End;
|
||||||
|
Assert.Equal(5, end.DistanceTo(centre), 9);
|
||||||
|
Assert.Equal(TabSize, end.DistanceTo(new Vector(5, 0)), 9);
|
||||||
|
Assert.Equal(end, leadout.Start!.Value);
|
||||||
|
var arc = Assert.IsType<Arc>(leadout.Curve.ToEntity());
|
||||||
|
Assert.Equal(LeadRadius, arc.Radius, 9);
|
||||||
|
// Externally tangent to the perimeter at the actual end: centre on its radial line.
|
||||||
|
Assert.Equal(5 + LeadRadius, arc.Center.DistanceTo(centre), 9);
|
||||||
|
var radial = end - centre;
|
||||||
|
var offset = arc.Center - end;
|
||||||
|
Assert.Equal(0, radial.X * offset.Y - radial.Y * offset.X, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LineLeadOut_RunsAlongTheEndNormalFromTheTrimmedCutEnd()
|
||||||
|
{
|
||||||
|
var execution = Emit(ExplicitContourTests.Square(false), new Vector(-2, 5),
|
||||||
|
new LineLeadOut { Length = 0.3 }, tabs: true);
|
||||||
|
var (cuts, leadout) = Split(execution);
|
||||||
|
|
||||||
|
Assert.Equal(cuts[^1].End, leadout.Start!.Value);
|
||||||
|
Assert.Equal(-0.3, leadout.End.X, 9);
|
||||||
|
Assert.Equal(cuts[^1].End.Y, leadout.End.Y, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntabbedArcLeadOut_StillLeavesFromTheEntry()
|
||||||
|
{
|
||||||
|
var execution = Emit(ExplicitContourTests.Square(false), new Vector(-2, 5),
|
||||||
|
new ArcLeadOut { Radius = LeadRadius }, tabs: false);
|
||||||
|
var (cuts, leadout) = Split(execution);
|
||||||
|
|
||||||
|
Assert.Equal(new Vector(0, 5), cuts[^1].End);
|
||||||
|
var arc = Assert.IsType<Arc>(leadout.Curve.ToEntity());
|
||||||
|
Assert.Equal(-LeadRadius, arc.Center.X, 9);
|
||||||
|
Assert.Equal(5, arc.Center.Y, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OwnedExecution Emit(Program clean, Vector approach, LeadOut leadOut, bool tabs)
|
||||||
|
{
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
parameters.TabsEnabled = tabs;
|
||||||
|
parameters.TabConfig = new NormalTab { Size = TabSize };
|
||||||
|
parameters.ExternalLeadOut = leadOut;
|
||||||
|
var emitted = new ContourCuttingStrategy { Parameters = parameters }.Apply(clean, approach);
|
||||||
|
return ExecutionMotionReader.Read(emitted.Program, Vector.Zero, null, default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ExecutionMotion[] Cuts, ExecutionMotion Leadout) Split(OwnedExecution execution)
|
||||||
|
{
|
||||||
|
var cuts = execution.Motions.Where(m => m.Layer == LayerType.Display && !m.Rapid).ToArray();
|
||||||
|
Assert.NotEmpty(cuts);
|
||||||
|
return (cuts, Assert.Single(execution.Motions.Where(m => m.Layer == LayerType.Leadout)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
using OpenNest.Data;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Data;
|
||||||
|
|
||||||
|
public class NestBrowseSessionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_RequestsTheFirstBoundedPageNewestSavedFirst()
|
||||||
|
{
|
||||||
|
var repository = new PagedRepository(25);
|
||||||
|
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||||
|
|
||||||
|
Assert.True(await session.RefreshAsync());
|
||||||
|
|
||||||
|
var query = Assert.Single(repository.Queries);
|
||||||
|
Assert.Equal("", query.Search);
|
||||||
|
Assert.Equal(NestSortField.SavedAt, query.Sort);
|
||||||
|
Assert.True(query.Descending);
|
||||||
|
Assert.Equal(0, query.Offset);
|
||||||
|
Assert.Equal(10, query.Limit);
|
||||||
|
Assert.Equal(10, session.Page!.Items.Count);
|
||||||
|
Assert.Equal("Showing 1-10 of 25 nests", session.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NextAndPrevious_StayWithinTheMatches()
|
||||||
|
{
|
||||||
|
var repository = new PagedRepository(25);
|
||||||
|
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||||
|
await session.RefreshAsync();
|
||||||
|
Assert.False(session.CanGoPrevious);
|
||||||
|
Assert.True(session.CanGoNext);
|
||||||
|
|
||||||
|
Assert.True(await session.NextPageAsync());
|
||||||
|
Assert.Equal("Showing 11-20 of 25 nests", session.Summary);
|
||||||
|
Assert.True(await session.NextPageAsync());
|
||||||
|
Assert.Equal("Showing 21-25 of 25 nests", session.Summary);
|
||||||
|
Assert.False(session.CanGoNext);
|
||||||
|
Assert.True(session.CanGoPrevious);
|
||||||
|
|
||||||
|
var sent = repository.Queries.Count;
|
||||||
|
Assert.False(await session.NextPageAsync());
|
||||||
|
Assert.Equal(sent, repository.Queries.Count);
|
||||||
|
|
||||||
|
Assert.True(await session.PreviousPageAsync());
|
||||||
|
Assert.Equal(10, repository.Queries[^1].Offset);
|
||||||
|
Assert.Equal("Showing 11-20 of 25 nests", session.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetSearch_TrimsTextAndReturnsToTheFirstPage()
|
||||||
|
{
|
||||||
|
var repository = new PagedRepository(25);
|
||||||
|
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||||
|
await session.RefreshAsync();
|
||||||
|
await session.NextPageAsync();
|
||||||
|
|
||||||
|
await session.SetSearchAsync(" beta works ");
|
||||||
|
|
||||||
|
var query = repository.Queries[^1];
|
||||||
|
Assert.Equal("beta works", query.Search);
|
||||||
|
Assert.Equal(0, query.Offset);
|
||||||
|
Assert.Equal("beta works", session.Search);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SortBy_NewColumnAscends_SameColumnReverses_AndResetsTheOffset()
|
||||||
|
{
|
||||||
|
var repository = new PagedRepository(25);
|
||||||
|
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||||
|
await session.RefreshAsync();
|
||||||
|
await session.NextPageAsync();
|
||||||
|
|
||||||
|
await session.SortByAsync(NestSortField.Customer);
|
||||||
|
Assert.Equal((NestSortField.Customer, false, 0), Last(repository));
|
||||||
|
|
||||||
|
await session.SortByAsync(NestSortField.Customer);
|
||||||
|
Assert.Equal((NestSortField.Customer, true, 0), Last(repository));
|
||||||
|
|
||||||
|
await session.SortByAsync(NestSortField.Thickness);
|
||||||
|
Assert.Equal((NestSortField.Thickness, false, 0), Last(repository));
|
||||||
|
|
||||||
|
await session.SortByAsync(NestSortField.SavedAt);
|
||||||
|
await session.SortByAsync(NestSortField.SavedAt);
|
||||||
|
Assert.Equal((NestSortField.SavedAt, true, 0), Last(repository));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OlderResponse_ArrivingLast_NeverReplacesTheNewerResult()
|
||||||
|
{
|
||||||
|
var repository = new ControlledRepository();
|
||||||
|
using var session = new NestBrowseSession(repository);
|
||||||
|
var older = session.SetSearchAsync("a");
|
||||||
|
var newer = session.SetSearchAsync("ab");
|
||||||
|
var newerPage = Page("ab result");
|
||||||
|
|
||||||
|
repository.Requests[1].Reply.SetResult(newerPage);
|
||||||
|
Assert.True(await newer);
|
||||||
|
repository.Requests[0].Reply.SetResult(Page("a result"));
|
||||||
|
Assert.False(await older);
|
||||||
|
|
||||||
|
Assert.Same(newerPage, session.Page);
|
||||||
|
Assert.True(repository.Requests[0].Token.IsCancellationRequested);
|
||||||
|
Assert.False(repository.Requests[1].Token.IsCancellationRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SupersededFailure_IsIgnored_LatestFailure_ClearsThePageAndPropagates()
|
||||||
|
{
|
||||||
|
var repository = new ControlledRepository();
|
||||||
|
using var session = new NestBrowseSession(repository);
|
||||||
|
var older = session.SetSearchAsync("a");
|
||||||
|
var newer = session.SetSearchAsync("ab");
|
||||||
|
|
||||||
|
repository.Requests[0].Reply.SetException(new IOException("superseded failure"));
|
||||||
|
Assert.False(await older);
|
||||||
|
repository.Requests[1].Reply.SetResult(Page("ab result"));
|
||||||
|
Assert.True(await newer);
|
||||||
|
Assert.NotNull(session.Page);
|
||||||
|
|
||||||
|
var failing = session.RefreshAsync();
|
||||||
|
repository.Requests[2].Reply.SetException(new IOException("server offline"));
|
||||||
|
var error = await Assert.ThrowsAsync<IOException>(() => failing);
|
||||||
|
|
||||||
|
Assert.Equal("server offline", error.Message);
|
||||||
|
Assert.Null(session.Page);
|
||||||
|
Assert.Equal("", session.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_AfterTheLastPageWasEmptied_StepsBackToTheNewLastPage()
|
||||||
|
{
|
||||||
|
var repository = new PagedRepository(21);
|
||||||
|
using var session = new NestBrowseSession(repository, pageSize: 10);
|
||||||
|
await session.RefreshAsync();
|
||||||
|
await session.NextPageAsync();
|
||||||
|
await session.NextPageAsync();
|
||||||
|
Assert.Equal("Showing 21-21 of 21 nests", session.Summary);
|
||||||
|
|
||||||
|
repository.RemoveLast();
|
||||||
|
Assert.True(await session.RefreshAsync());
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 20, 10 }, repository.Queries.TakeLast(2).Select(q => q.Offset));
|
||||||
|
Assert.Equal(10, session.Offset);
|
||||||
|
Assert.Equal("Showing 11-20 of 20 nests", session.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SupersededRequest_CompletingSynchronouslyOnCancellation_IsNotReportedAsTheLatestFailure()
|
||||||
|
{
|
||||||
|
var repository = new ControlledRepository { CompleteOnCancellation = true };
|
||||||
|
using var session = new NestBrowseSession(repository);
|
||||||
|
var older = session.SetSearchAsync("a");
|
||||||
|
var newer = session.SetSearchAsync("ab");
|
||||||
|
|
||||||
|
Assert.False(await older);
|
||||||
|
repository.Requests[1].Reply.SetResult(Page("ab result"));
|
||||||
|
Assert.True(await newer);
|
||||||
|
|
||||||
|
Assert.Equal("ab result", Assert.Single(session.Page!.Items).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IsLoading_FollowsOnlyTheLatestRequest()
|
||||||
|
{
|
||||||
|
var repository = new ControlledRepository();
|
||||||
|
using var session = new NestBrowseSession(repository, pageSize: 1);
|
||||||
|
Assert.False(session.IsLoading);
|
||||||
|
|
||||||
|
// A superseded request finishing first leaves the latest one loading.
|
||||||
|
var first = session.SetSearchAsync("a");
|
||||||
|
var second = session.SetSearchAsync("ab");
|
||||||
|
repository.Requests[0].Reply.SetResult(Page("a result"));
|
||||||
|
Assert.False(await first);
|
||||||
|
Assert.True(session.IsLoading);
|
||||||
|
repository.Requests[1].Reply.SetResult(Page("ab result"));
|
||||||
|
Assert.True(await second);
|
||||||
|
Assert.False(session.IsLoading);
|
||||||
|
|
||||||
|
// The latest request finishing first ends loading although a superseded one is still out.
|
||||||
|
var third = session.SetSearchAsync("abc");
|
||||||
|
var fourth = session.SetSearchAsync("abcd");
|
||||||
|
var record = new NestRecord { Id = Guid.NewGuid(), Name = "abcd result" };
|
||||||
|
repository.Requests[3].Reply.SetResult(new NestPage { Items = new[] { record }, Total = 3, Limit = 1 });
|
||||||
|
Assert.True(await fourth);
|
||||||
|
Assert.False(session.IsLoading);
|
||||||
|
Assert.True(session.CanGoNext);
|
||||||
|
repository.Requests[2].Reply.SetResult(Page("abc result"));
|
||||||
|
Assert.False(await third);
|
||||||
|
Assert.False(session.IsLoading);
|
||||||
|
|
||||||
|
var failing = session.RefreshAsync();
|
||||||
|
Assert.True(session.IsLoading);
|
||||||
|
repository.Requests[4].Reply.SetException(new IOException("offline"));
|
||||||
|
await Assert.ThrowsAsync<IOException>(() => failing);
|
||||||
|
Assert.False(session.IsLoading);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EachRequestsCancellationSource_IsReleasedWhenThatRequestEnds()
|
||||||
|
{
|
||||||
|
var repository = new ControlledRepository();
|
||||||
|
var session = new NestBrowseSession(repository);
|
||||||
|
var older = session.SetSearchAsync("a");
|
||||||
|
var newer = session.SetSearchAsync("ab");
|
||||||
|
|
||||||
|
repository.Requests[1].Reply.SetResult(Page("ab"));
|
||||||
|
await newer;
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => repository.Requests[1].Token.WaitHandle);
|
||||||
|
repository.Requests[0].Reply.SetResult(Page("a"));
|
||||||
|
await older;
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => repository.Requests[0].Token.WaitHandle);
|
||||||
|
|
||||||
|
var pending = session.RefreshAsync();
|
||||||
|
session.Dispose();
|
||||||
|
repository.Requests[2].Reply.SetResult(Page("late"));
|
||||||
|
await pending;
|
||||||
|
Assert.Throws<ObjectDisposedException>(() => repository.Requests[2].Token.WaitHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "No nests on the server.")]
|
||||||
|
[InlineData("no such text", "No nests match the filter.")]
|
||||||
|
public async Task Summary_DistinguishesAnEmptyServerFromNoMatches(string search, string expected)
|
||||||
|
{
|
||||||
|
using var session = new NestBrowseSession(new PagedRepository(0));
|
||||||
|
|
||||||
|
await session.SetSearchAsync(search);
|
||||||
|
|
||||||
|
Assert.Equal(expected, session.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_CancelsTheRequestAndDiscardsItsLateResponse()
|
||||||
|
{
|
||||||
|
var repository = new ControlledRepository();
|
||||||
|
var session = new NestBrowseSession(repository);
|
||||||
|
var pending = session.SetSearchAsync("a");
|
||||||
|
|
||||||
|
session.Dispose();
|
||||||
|
repository.Requests[0].Reply.SetResult(Page("late"));
|
||||||
|
|
||||||
|
Assert.True(repository.Requests[0].Token.IsCancellationRequested);
|
||||||
|
Assert.False(await pending);
|
||||||
|
Assert.Null(session.Page);
|
||||||
|
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.RefreshAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(NestQuery.MaxLimit + 1)]
|
||||||
|
public void Constructor_RejectsPageSizesTheServerWouldRefuse(int pageSize)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => new NestBrowseSession(new PagedRepository(0), pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (NestSortField, bool, int) Last(PagedRepository repository)
|
||||||
|
{
|
||||||
|
var query = repository.Queries[^1];
|
||||||
|
return (query.Sort, query.Descending, query.Offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestPage Page(string name) =>
|
||||||
|
new() { Items = new[] { new NestRecord { Id = Guid.NewGuid(), Name = name } }, Total = 1, Limit = 100 };
|
||||||
|
|
||||||
|
/// <summary>Serves pages of an in-memory set and records each query.</summary>
|
||||||
|
private sealed class PagedRepository : RepositoryBase
|
||||||
|
{
|
||||||
|
private readonly List<NestRecord> _records;
|
||||||
|
|
||||||
|
public PagedRepository(int count) =>
|
||||||
|
_records = Enumerable.Range(0, count).Select(i => new NestRecord { Id = Guid.NewGuid(), Name = $"Nest {i}" }).ToList();
|
||||||
|
|
||||||
|
public List<NestQuery> Queries { get; } = new();
|
||||||
|
|
||||||
|
public void RemoveLast() => _records.RemoveAt(_records.Count - 1);
|
||||||
|
|
||||||
|
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Queries.Add(query);
|
||||||
|
var matches = query.NormalizedSearch.Length == 0 ? _records : new List<NestRecord>();
|
||||||
|
return Task.FromResult(new NestPage
|
||||||
|
{
|
||||||
|
Items = matches.Skip(query.Offset).Take(query.Limit).ToArray(),
|
||||||
|
Total = matches.Count,
|
||||||
|
Offset = query.Offset,
|
||||||
|
Limit = query.Limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Leaves every query pending until the test completes it. With
|
||||||
|
/// <see cref="CompleteOnCancellation"/>, cancellation completes the query synchronously
|
||||||
|
/// inside the canceller's call, as some HTTP handlers do.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ControlledRepository : RepositoryBase
|
||||||
|
{
|
||||||
|
public bool CompleteOnCancellation { get; init; }
|
||||||
|
|
||||||
|
public List<(NestQuery Query, TaskCompletionSource<NestPage> Reply, CancellationToken Token)> Requests { get; } = new();
|
||||||
|
|
||||||
|
public override Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var reply = new TaskCompletionSource<NestPage>();
|
||||||
|
if (CompleteOnCancellation)
|
||||||
|
cancellationToken.Register(() => reply.TrySetCanceled(cancellationToken));
|
||||||
|
Requests.Add((query, reply, cancellationToken));
|
||||||
|
return reply.Task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private abstract class RepositoryBase : INestRepository
|
||||||
|
{
|
||||||
|
public abstract Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<NestRecord> UploadAsync(byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] file, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,24 @@ public class NestDefaultsTests : IDisposable
|
|||||||
AssertFallback(loaded);
|
AssertFallback(loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Load_ReadDeniedFile_ReturnsFallbackButReportsInvalid()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
throw new SkipException("Unix file modes deny the read on Linux/macOS");
|
||||||
|
|
||||||
|
new NestDefaults { PartSpacing = 4 }.Save(_path);
|
||||||
|
File.SetUnixFileMode(_path, UnixFileMode.None);
|
||||||
|
Skip.If(CanRead(_path), "Process can read a mode-000 file (running as root)");
|
||||||
|
|
||||||
|
// The file is visible to File.Exists, but reading it is denied.
|
||||||
|
Assert.True(File.Exists(_path));
|
||||||
|
var loaded = NestDefaults.Load(_path, out var status);
|
||||||
|
|
||||||
|
Assert.Equal(NestDefaultsStatus.Invalid, status);
|
||||||
|
AssertFallback(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_PartialFile_MergesPerField()
|
public void Load_PartialFile_MergesPerField()
|
||||||
{
|
{
|
||||||
@@ -129,6 +147,74 @@ public class NestDefaultsTests : IDisposable
|
|||||||
Assert.Equal(1, loaded.PartSpacing);
|
Assert.Equal(1, loaded.PartSpacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("\"7\"")]
|
||||||
|
[InlineData("\"1\"")]
|
||||||
|
[InlineData("\"-1\"")]
|
||||||
|
[InlineData("\"inches, millimeters\"")]
|
||||||
|
[InlineData("1")]
|
||||||
|
public void Load_UndefinedOrNumericUnits_FallBackPerField(string unitsJson)
|
||||||
|
{
|
||||||
|
File.WriteAllText(_path, $$"""{ "units": {{unitsJson}}, "partSpacing": 4 }""");
|
||||||
|
|
||||||
|
var loaded = NestDefaults.Load(_path, out var status);
|
||||||
|
|
||||||
|
// Only a defined unit name is accepted; the other fields still load.
|
||||||
|
Assert.Equal(NestDefaultsStatus.Ok, status);
|
||||||
|
Assert.Equal(Units.Inches, loaded.Units);
|
||||||
|
Assert.Equal(4, loaded.PartSpacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{ "partSpacing": 4 }""")]
|
||||||
|
[InlineData("""{ "units": "7", "partSpacing": 4 }""")]
|
||||||
|
[InlineData("""{ "units": "furlongs", "partSpacing": 4 }""")]
|
||||||
|
[InlineData("""{ "units": null, "partSpacing": 4 }""")]
|
||||||
|
public void Load_MissingOrInvalidUnits_UseCallerFallbackUnits(string json)
|
||||||
|
{
|
||||||
|
File.WriteAllText(_path, json);
|
||||||
|
|
||||||
|
var loaded = NestDefaults.Load(_path, Units.Millimeters, out var status);
|
||||||
|
|
||||||
|
// The caller's existing unit preference survives a readable file
|
||||||
|
// that lacks a usable unit, and the valid fields still load.
|
||||||
|
Assert.Equal(NestDefaultsStatus.Ok, status);
|
||||||
|
Assert.Equal(Units.Millimeters, loaded.Units);
|
||||||
|
Assert.Equal(4, loaded.PartSpacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_SavedUnits_OverrideCallerFallbackUnits()
|
||||||
|
{
|
||||||
|
new NestDefaults { Units = Units.Inches }.Save(_path);
|
||||||
|
|
||||||
|
var loaded = NestDefaults.Load(_path, Units.Millimeters, out var status);
|
||||||
|
|
||||||
|
Assert.Equal(NestDefaultsStatus.Ok, status);
|
||||||
|
Assert.Equal(Units.Inches, loaded.Units);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_MissingOrCorruptFile_UsesCallerFallbackUnits()
|
||||||
|
{
|
||||||
|
var missing = NestDefaults.Load(_path, Units.Millimeters, out var missingStatus);
|
||||||
|
File.WriteAllText(_path, "{ this is not json");
|
||||||
|
var corrupt = NestDefaults.Load(_path, Units.Millimeters, out var corruptStatus);
|
||||||
|
|
||||||
|
Assert.Equal(NestDefaultsStatus.Missing, missingStatus);
|
||||||
|
Assert.Equal(Units.Millimeters, missing.Units);
|
||||||
|
Assert.Equal(NestDefaultsStatus.Invalid, corruptStatus);
|
||||||
|
Assert.Equal(Units.Millimeters, corrupt.Units);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_UndefinedCallerFallbackUnits_UseBuiltInUnits()
|
||||||
|
{
|
||||||
|
var loaded = NestDefaults.Load(_path, (Units)7, out _);
|
||||||
|
|
||||||
|
Assert.Equal(Units.Inches, loaded.Units);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_UnknownFieldsAndFutureVersion_Ignored()
|
public void Load_UnknownFieldsAndFutureVersion_Ignored()
|
||||||
{
|
{
|
||||||
@@ -175,7 +261,7 @@ public class NestDefaultsTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ApplyTo_SetsUnitsAndPlateDefaults_AndDoesNotAliasSourceNest()
|
public void ApplyTo_SetsUnitsAndPlateDefaults_AndDoesNotAliasSourceNest()
|
||||||
{
|
{
|
||||||
var source = new Nest();
|
var source = new Nest { Units = Units.Millimeters };
|
||||||
source.PlateDefaults.Size = new Size(48, 96);
|
source.PlateDefaults.Size = new Size(48, 96);
|
||||||
source.PlateDefaults.EdgeSpacing = new Spacing(1.25, 1.25, 1.25, 1.25);
|
source.PlateDefaults.EdgeSpacing = new Spacing(1.25, 1.25, 1.25, 1.25);
|
||||||
var defaults = NestDefaults.FromNest(source);
|
var defaults = NestDefaults.FromNest(source);
|
||||||
@@ -187,6 +273,8 @@ public class NestDefaultsTests : IDisposable
|
|||||||
var target = new Nest();
|
var target = new Nest();
|
||||||
defaults.ApplyTo(target);
|
defaults.ApplyTo(target);
|
||||||
|
|
||||||
|
// New and BOM-created nests both take their units from ApplyTo.
|
||||||
|
Assert.Equal(Units.Millimeters, target.Units);
|
||||||
Assert.Equal(new Size(48, 96), target.PlateDefaults.Size);
|
Assert.Equal(new Size(48, 96), target.PlateDefaults.Size);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new Spacing(1.25, 1.25, 1.25, 1.25),
|
new Spacing(1.25, 1.25, 1.25, 1.25),
|
||||||
@@ -198,6 +286,19 @@ public class NestDefaultsTests : IDisposable
|
|||||||
Assert.Equal(new Size(48, 96), defaults.Size);
|
Assert.Equal(new Size(48, 96), defaults.Size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool CanRead(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var _ = File.OpenRead(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void AssertFallback(NestDefaults loaded)
|
private static void AssertFallback(NestDefaults loaded)
|
||||||
{
|
{
|
||||||
Assert.Equal(Units.Inches, loaded.Units);
|
Assert.Equal(Units.Inches, loaded.Units);
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ public class NestSaveSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
|
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken ct = default) => throw new NotImplementedException();
|
||||||
|
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException();
|
||||||
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
|
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
|
||||||
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
|
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException();
|
||||||
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
|
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException();
|
||||||
|
|||||||
@@ -220,4 +220,77 @@ public class RemoteNestRepositoryTests
|
|||||||
Assert.Contains("500", ex.Message);
|
Assert.Contains("500", ex.Message);
|
||||||
Assert.Contains("disk on fire", ex.Message);
|
Assert.Contains("disk on fire", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_SendsEscapedBoundedQueryAndReadsPage()
|
||||||
|
{
|
||||||
|
var record = SampleRecord();
|
||||||
|
var handler = new StubHandler(_ => StubHandler.Json(new { items = new[] { record }, total = 41, offset = 20, limit = 20 }));
|
||||||
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://server:8090/base/");
|
||||||
|
|
||||||
|
var page = await repo.QueryAsync(new NestQuery { Search = " a&b=c %_\\ ", Offset = 20, Limit = 20 });
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
|
||||||
|
Assert.Equal(
|
||||||
|
"http://server:8090/base/api/nests/query?search=a%26b%3Dc%20%25_%5C&sort=savedAt&order=desc&offset=20&limit=20",
|
||||||
|
handler.Requests[0].RequestUri!.AbsoluteUri);
|
||||||
|
Assert.Equal(record.Id, Assert.Single(page.Items).Id);
|
||||||
|
Assert.Equal(41, page.Total);
|
||||||
|
Assert.Equal(20, page.Offset);
|
||||||
|
Assert.Equal(20, page.Limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<NestQuery> OutOfBoundsQueries() => new()
|
||||||
|
{
|
||||||
|
new NestQuery { Offset = -1 },
|
||||||
|
new NestQuery { Limit = 0 },
|
||||||
|
new NestQuery { Limit = NestQuery.MaxLimit + 1 },
|
||||||
|
new NestQuery { Search = new string('x', NestQuery.MaxSearchLength + 1) },
|
||||||
|
new NestQuery { Sort = (NestSortField)99 },
|
||||||
|
new NestQuery { Search = "Alpha\0not-present" },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_SendsSortColumnAndDirection()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty<NestRecord>() }));
|
||||||
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
||||||
|
|
||||||
|
await repo.QueryAsync(new NestQuery { Sort = NestSortField.PlateCount, Descending = false });
|
||||||
|
|
||||||
|
Assert.EndsWith(
|
||||||
|
"/api/nests/query?search=&sort=plateCount&order=asc&offset=0&limit=100",
|
||||||
|
handler.Requests[0].RequestUri!.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(OutOfBoundsQueries))]
|
||||||
|
public async Task QueryAsync_OutOfBounds_ThrowsWithoutSending(NestQuery query)
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty<NestRecord>() }));
|
||||||
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => repo.QueryAsync(query));
|
||||||
|
Assert.Empty(handler.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_OldServerWithoutRoute_ReportsServerUpdateNeeded()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||||
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<IOException>(() => repo.QueryAsync(new NestQuery()));
|
||||||
|
Assert.Contains("Update the nest server", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueryAsync_MoreItemsThanRequested_Throws()
|
||||||
|
{
|
||||||
|
var items = new[] { SampleRecord(), SampleRecord(), SampleRecord() };
|
||||||
|
var handler = new StubHandler(_ => StubHandler.Json(new { items, total = 3, offset = 0, limit = 2 }));
|
||||||
|
using var repo = new RemoteNestRepository(new HttpClient(handler), "http://s");
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<IOException>(() => repo.QueryAsync(new NestQuery { Limit = 2 }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +215,145 @@ public class OverlapReportStateTests
|
|||||||
Assert.Equal("No material overlaps detected", state.Message);
|
Assert.Equal("No material overlaps detected", state.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovingOnePartRetainsOnlyUnaffectedPairsThroughRecheck()
|
||||||
|
{
|
||||||
|
var plate = PlateWithTwoPairs();
|
||||||
|
var state = new OverlapReportState();
|
||||||
|
var report = Analyze(plate);
|
||||||
|
Assert.True(state.TryPublish(state.Begin(plate), plate, report));
|
||||||
|
var untouched = report.Pairs[0];
|
||||||
|
plate.Parts[3].Offset(1e-10, 0);
|
||||||
|
|
||||||
|
Assert.False(state.EnsureFresh(plate));
|
||||||
|
Assert.Null(state.Report);
|
||||||
|
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
|
||||||
|
Assert.Same(untouched, Assert.Single(state.DisplayPairs));
|
||||||
|
var display = state.DisplayPairs;
|
||||||
|
Assert.False(state.EnsureFresh(plate));
|
||||||
|
Assert.Same(display, state.DisplayPairs);
|
||||||
|
|
||||||
|
var request = state.Begin(plate, automatic: true);
|
||||||
|
Assert.Equal(OverlapCheckStatus.Checking, state.Status);
|
||||||
|
Assert.Null(state.Report);
|
||||||
|
Assert.Same(display, state.DisplayPairs);
|
||||||
|
Assert.True(state.TryPublish(request, plate, Analyze(plate)));
|
||||||
|
Assert.Equal(2, state.DisplayPairs.Count);
|
||||||
|
Assert.Same(Assert.IsType<PlateOverlapReport>(state.Report).Pairs, state.DisplayPairs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("move")]
|
||||||
|
[InlineData("rotate")]
|
||||||
|
[InlineData("placed-program")]
|
||||||
|
[InlineData("clean-program")]
|
||||||
|
[InlineData("cutoff")]
|
||||||
|
[InlineData("replace")]
|
||||||
|
[InlineData("remove")]
|
||||||
|
[InlineData("reorder")]
|
||||||
|
public void FurtherEditsDropAffectedPairsEvenBeforeTheNextCompletedCheck(string edit)
|
||||||
|
{
|
||||||
|
var plate = PlateWithTwoPairs();
|
||||||
|
var state = new OverlapReportState();
|
||||||
|
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||||
|
plate.Parts[3].Offset(0.5, 0);
|
||||||
|
Assert.False(state.EnsureFresh(plate));
|
||||||
|
Assert.Single(state.DisplayPairs);
|
||||||
|
var request = state.Begin(plate, automatic: true);
|
||||||
|
var pending = Analyze(plate);
|
||||||
|
var part = plate.Parts[0];
|
||||||
|
switch (edit)
|
||||||
|
{
|
||||||
|
case "move": part.Offset(0, 1e-10); break;
|
||||||
|
case "rotate": part.Rotate(0.01); break;
|
||||||
|
case "placed-program": part.Update(); break;
|
||||||
|
case "clean-program": part.BaseDrawing.Program = (Program)part.BaseDrawing.Program.Clone(); break;
|
||||||
|
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
|
||||||
|
case "replace": plate.Parts[0] = Rectangle(); break;
|
||||||
|
case "remove": plate.Parts.RemoveAt(0); break;
|
||||||
|
case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break;
|
||||||
|
}
|
||||||
|
Assert.False(state.EnsureFresh(plate));
|
||||||
|
Assert.Empty(state.DisplayPairs);
|
||||||
|
Assert.False(state.TryPublish(request, plate, pending));
|
||||||
|
Assert.Null(state.Report);
|
||||||
|
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
|
||||||
|
Assert.DoesNotContain("No material overlaps", state.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("invalidate")]
|
||||||
|
[InlineData("reset")]
|
||||||
|
[InlineData("plate")]
|
||||||
|
[InlineData("cancel")]
|
||||||
|
[InlineData("fail")]
|
||||||
|
public void HardInvalidationAndRequestFailureClearRetainedPairs(string edit)
|
||||||
|
{
|
||||||
|
var plate = PlateWithTwoPairs();
|
||||||
|
var state = new OverlapReportState();
|
||||||
|
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||||
|
plate.Parts[3].Offset(1, 0);
|
||||||
|
Assert.False(state.EnsureFresh(plate));
|
||||||
|
Assert.Single(state.DisplayPairs);
|
||||||
|
var request = state.Begin(plate);
|
||||||
|
switch (edit)
|
||||||
|
{
|
||||||
|
case "invalidate": state.Invalidate(); break;
|
||||||
|
case "reset": state.Reset(); break;
|
||||||
|
case "plate": Assert.False(state.EnsureFresh(new Plate())); break;
|
||||||
|
case "cancel": state.Cancel(); break;
|
||||||
|
case "fail": Assert.True(state.TryFail(request, plate)); break;
|
||||||
|
}
|
||||||
|
Assert.Empty(state.DisplayPairs);
|
||||||
|
Assert.Null(state.Report);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdditionalUnrelatedMotionDoesNotReplaceTheRetainedDisplayList()
|
||||||
|
{
|
||||||
|
var plate = PlateWithTwoPairs();
|
||||||
|
var state = new OverlapReportState();
|
||||||
|
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||||
|
plate.Parts[3].Offset(1, 0);
|
||||||
|
Assert.False(state.EnsureFresh(plate));
|
||||||
|
var display = state.DisplayPairs;
|
||||||
|
Assert.Single(display);
|
||||||
|
plate.Parts[3].Offset(1, 0);
|
||||||
|
Assert.False(state.EnsureFresh(plate));
|
||||||
|
Assert.Same(display, state.DisplayPairs);
|
||||||
|
state.Begin(plate);
|
||||||
|
Assert.Same(display, state.DisplayPairs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppendingPartInvalidatesTheFullReportWithoutReplacingUnchangedDisplayPairs()
|
||||||
|
{
|
||||||
|
var plate = PlateWithParts();
|
||||||
|
var state = new OverlapReportState();
|
||||||
|
Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
|
||||||
|
var display = state.DisplayPairs;
|
||||||
|
plate.Parts.Add(Rectangle());
|
||||||
|
state.Invalidate(plate); // Same entry point as the controller's collection event.
|
||||||
|
Assert.Null(state.Report);
|
||||||
|
Assert.Equal(OverlapCheckStatus.Stale, state.Status);
|
||||||
|
Assert.Same(display, state.DisplayPairs);
|
||||||
|
Assert.Single(state.DisplayPairs);
|
||||||
|
Assert.False(state.EnsureFresh(plate));
|
||||||
|
Assert.Same(display, state.DisplayPairs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Plate PlateWithTwoPairs()
|
||||||
|
{
|
||||||
|
var plate = PlateWithParts();
|
||||||
|
var a = Rectangle();
|
||||||
|
var b = Rectangle();
|
||||||
|
a.Offset(20, 0);
|
||||||
|
b.Offset(20, 0);
|
||||||
|
plate.Parts.Add(a);
|
||||||
|
plate.Parts.Add(b);
|
||||||
|
return plate;
|
||||||
|
}
|
||||||
|
|
||||||
private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray());
|
private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray());
|
||||||
|
|
||||||
private static Plate PlateWithParts()
|
private static Plate PlateWithParts()
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Geometry;
|
||||||
|
|
||||||
|
public class EntityIdentityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ConstructionAndClone_UseDistinctNonemptyIdsAcrossEntityTypes()
|
||||||
|
{
|
||||||
|
var entities = CreateEntities();
|
||||||
|
var clones = entities.Select(entity => entity.Clone()).ToArray();
|
||||||
|
var ids = entities.Concat(clones).Select(entity => entity.Id).ToArray();
|
||||||
|
|
||||||
|
Assert.DoesNotContain(Guid.Empty, ids);
|
||||||
|
Assert.Equal(ids.Length, ids.Distinct().Count());
|
||||||
|
for (var i = 0; i < entities.Length; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal(entities[i].Type, clones[i].Type);
|
||||||
|
Assert.Equal((entities[i].Left, entities[i].Right, entities[i].Bottom, entities[i].Top),
|
||||||
|
(clones[i].Left, clones[i].Right, clones[i].Bottom, clones[i].Top));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConcurrentConstruction_IdsAreUniqueAcrossSharedProcessSequence()
|
||||||
|
{
|
||||||
|
var ids = new Guid[20_000];
|
||||||
|
Parallel.For(0, ids.Length, i => ids[i] = CreateEntities()[i % 5].Id);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(Guid.Empty, ids);
|
||||||
|
Assert.Equal(ids.Length, ids.Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GeneratedIds_ShareProcessSaltButNotCounterBytes()
|
||||||
|
{
|
||||||
|
var ids = CreateEntities().Select(entity => entity.Id.ToByteArray()).ToArray();
|
||||||
|
|
||||||
|
foreach (var id in ids.Skip(1))
|
||||||
|
Assert.Equal(ids[0][..10], id[..10]);
|
||||||
|
Assert.Equal(ids.Length, ids.Select(id => Convert.ToHexString(id[10..])).Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitIdsAndSuppression_SurviveEntitySerialization()
|
||||||
|
{
|
||||||
|
var entities = CreateEntities().Take(3).ToList();
|
||||||
|
entities[0].Id = Guid.Parse("11111111-2222-3333-8444-555555555555");
|
||||||
|
entities[1].Id = Guid.Parse("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
|
||||||
|
var originalIds = entities.Select(entity => entity.Id).ToArray();
|
||||||
|
var suppressed = new HashSet<Guid> { entities[1].Id, entities[2].Id };
|
||||||
|
|
||||||
|
var dto = EntitySerializer.ToDto(entities, suppressed);
|
||||||
|
var (restored, restoredSuppressed) = EntitySerializer.FromDto(dto);
|
||||||
|
|
||||||
|
Assert.Equal(originalIds, restored.Select(entity => entity.Id));
|
||||||
|
Assert.True(suppressed.SetEquals(restoredSuppressed));
|
||||||
|
Assert.Equal(entities.Select(entity => entity.Type), restored.Select(entity => entity.Type));
|
||||||
|
Assert.NotEqual(restored[0].Id, restored[0].Clone().Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitIdsAndSuppression_SurviveNestFileRoundTrip()
|
||||||
|
{
|
||||||
|
var drawing = TestHelpers.MakeSquareDrawing();
|
||||||
|
drawing.SourceEntities = CreateEntities().Take(3).ToList();
|
||||||
|
drawing.SourceEntities[0].Id = Guid.Parse("11111111-2222-3333-8444-555555555555");
|
||||||
|
var ids = drawing.SourceEntities.Select(entity => entity.Id).ToArray();
|
||||||
|
drawing.SuppressedEntityIds.Add(ids[1]);
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
|
||||||
|
new NestWriter(nest).Write(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
var restored = Assert.Single(new NestReader(stream).Read().Drawings);
|
||||||
|
|
||||||
|
Assert.Equal(ids, restored.SourceEntities.Select(entity => entity.Id));
|
||||||
|
Assert.Equal(ids[1], Assert.Single(restored.SuppressedEntityIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entity[] CreateEntities() =>
|
||||||
|
[
|
||||||
|
new Line(0, 0, 2, 3),
|
||||||
|
new Arc(0, 0, 2, 0, System.Math.PI),
|
||||||
|
new Circle(new Vector(1, 2), 3),
|
||||||
|
new Polygon(),
|
||||||
|
new Shape(),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -54,7 +54,11 @@ public sealed class AtomicReportFileTests : IDisposable
|
|||||||
{
|
{
|
||||||
var path = Path.Combine(directory, "not-a-file.pdf");
|
var path = Path.Combine(directory, "not-a-file.pdf");
|
||||||
Directory.CreateDirectory(path);
|
Directory.CreateDirectory(path);
|
||||||
Assert.ThrowsAny<IOException>(() => AtomicReportFile.Write(path, stream => stream.Write("PDF"u8)));
|
// Moving onto a directory target raises IOException on some platforms and
|
||||||
|
// UnauthorizedAccessException on others (observed on Windows); callers treat both alike.
|
||||||
|
var error = Record.Exception(() => AtomicReportFile.Write(path, stream => stream.Write("PDF"u8)));
|
||||||
|
Assert.True(error is IOException or UnauthorizedAccessException,
|
||||||
|
$"Expected IOException or UnauthorizedAccessException, got {error?.GetType()}");
|
||||||
Assert.True(Directory.Exists(path));
|
Assert.True(Directory.Exists(path));
|
||||||
Assert.Empty(Directory.GetFiles(directory));
|
Assert.Empty(Directory.GetFiles(directory));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public class FillHelpersTests
|
|||||||
|
|
||||||
var actual = FillHelpers.FillPattern(engine, group, angles, workArea, comparer);
|
var actual = FillHelpers.FillPattern(engine, group, angles, workArea, comparer);
|
||||||
|
|
||||||
// One angle writes H then V on one worker's bag queue; enumeration is V then H.
|
// Within one angle V is considered before H.
|
||||||
// Pin both arguments and the call count, not just the eventual winning score.
|
// Pin both arguments and the call count, not just the eventual winning score.
|
||||||
var call = Assert.Single(comparer.Calls);
|
var call = Assert.Single(comparer.Calls);
|
||||||
AssertSameLayout(h, call.Candidate);
|
AssertSameLayout(h, call.Candidate);
|
||||||
@@ -173,7 +173,7 @@ public class FillHelpersTests
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(false)]
|
[InlineData(false)]
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
public void FillPattern_TiedScores_PreservesBagOrderAndStillCallsCustomComparer(bool acceptCandidate)
|
public void FillPattern_TiedScores_KeepsVerticalBeforeHorizontalAndStillCallsCustomComparer(bool acceptCandidate)
|
||||||
{
|
{
|
||||||
var drawing = new RectangleShape { Length = 2, Width = 1 }.GetDrawing();
|
var drawing = new RectangleShape { Length = 2, Width = 1 }.GetDrawing();
|
||||||
var group = new List<Part> { new(drawing, new Vector(11, 13)) };
|
var group = new List<Part> { new(drawing, new Vector(11, 13)) };
|
||||||
@@ -191,7 +191,7 @@ public class FillHelpersTests
|
|||||||
var byScore = FillHelpers.FillPattern(engine, group, angles, workArea);
|
var byScore = FillHelpers.FillPattern(engine, group, angles, workArea);
|
||||||
var byComparer = FillHelpers.FillPattern(engine, group, angles, workArea, comparer);
|
var byComparer = FillHelpers.FillPattern(engine, group, angles, workArea, comparer);
|
||||||
|
|
||||||
AssertSameLayout(v, byScore); // Strict > retains the first bag result on a tie.
|
AssertSameLayout(v, byScore); // Strict > retains the earlier (V) result on a tie.
|
||||||
var call = Assert.Single(comparer.Calls);
|
var call = Assert.Single(comparer.Calls);
|
||||||
AssertSameLayout(h, call.Candidate);
|
AssertSameLayout(h, call.Candidate);
|
||||||
AssertSameLayout(v, call.Current);
|
AssertSameLayout(v, call.Current);
|
||||||
@@ -202,6 +202,24 @@ public class FillHelpersTests
|
|||||||
AssertValidLayout(byComparer, workArea);
|
AssertValidLayout(byComparer, workArea);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FillPattern_ConsidersAnglesInInputOrderWhateverTheThreadOrder()
|
||||||
|
{
|
||||||
|
var drawing = new RectangleShape { Length = 2, Width = 1 }.GetDrawing();
|
||||||
|
var group = new List<Part> { new(drawing, new Vector(11, 13)) };
|
||||||
|
var workArea = new Box(3, 5, 5, 4);
|
||||||
|
var engine = new FillLinear(workArea, 0.25);
|
||||||
|
// Only the first angle's layouts keep rotation zero. A comparer that never prefers the
|
||||||
|
// candidate keeps whichever result is considered first: the first angle's V.
|
||||||
|
var angles = new List<double> { 0 };
|
||||||
|
angles.AddRange(Enumerable.Repeat(System.Math.PI, 15));
|
||||||
|
var expected = engine.Fill(FillHelpers.BuildRotatedPattern(group, 0), NestDirection.Vertical);
|
||||||
|
var comparer = new RecordingComparer((_, _, _) => false);
|
||||||
|
|
||||||
|
for (var run = 0; run < 50; run++)
|
||||||
|
AssertSameLayout(expected, FillHelpers.FillPattern(engine, group, angles, workArea, comparer));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FillPattern_RotatedGroup_PreservesDrawingIdentityPosesAndInputPrograms()
|
public void FillPattern_RotatedGroup_PreservesDrawingIdentityPosesAndInputPrograms()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Actions;
|
||||||
|
using OpenNest.Controls;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
public class ActionSelectAreaCutOffTests
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> Cases()
|
||||||
|
{
|
||||||
|
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
|
||||||
|
foreach (var quadrant in new[] { 1, 3 })
|
||||||
|
foreach (var farSide in new[] { false, true })
|
||||||
|
foreach (var alternate in new[] { false, true })
|
||||||
|
yield return new object[] { axis, quadrant, farSide, alternate };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Cases))]
|
||||||
|
public void SelectedAreaAndFillStayOnSelectedSideOfCutOff(
|
||||||
|
CutOffAxis axis, int quadrant, bool farSide, bool alternate) => RunSta(() =>
|
||||||
|
{
|
||||||
|
var plate = new Plate(new OpenNest.Geometry.Size(20, 20))
|
||||||
|
{
|
||||||
|
Quadrant = quadrant,
|
||||||
|
PartSpacing = 0.5,
|
||||||
|
};
|
||||||
|
var bounds = plate.WorkArea();
|
||||||
|
var position = new Vector(bounds.Left + 10, bounds.Bottom + 10);
|
||||||
|
// Definitions alone must constrain selection, before any cutoff parts exist.
|
||||||
|
plate.CutOffs.Add(new CutOff(position, axis));
|
||||||
|
using var view = new SelectionView { Plate = plate };
|
||||||
|
var action = new ActionSelectArea(view);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var offset = farSide ? 5 : -5;
|
||||||
|
view.SetPoint(axis == CutOffAxis.Vertical
|
||||||
|
? new Vector(position.X + offset, position.Y)
|
||||||
|
: new Vector(position.X, position.Y + offset));
|
||||||
|
if (alternate)
|
||||||
|
view.ToggleSelectionDirection();
|
||||||
|
// Invoke the same update used by mouse movement without pixel rounding.
|
||||||
|
typeof(ActionSelectArea).GetMethod("UpdateSelectedArea", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.Invoke(action, null);
|
||||||
|
var area = action.SelectedArea;
|
||||||
|
Assert.NotEqual(Box.Empty, area);
|
||||||
|
var limit = (axis == CutOffAxis.Vertical ? position.X : position.Y)
|
||||||
|
+ (farSide ? plate.PartSpacing : -plate.PartSpacing);
|
||||||
|
Assert.Equal(limit, axis == CutOffAxis.Vertical
|
||||||
|
? (farSide ? area.Left : area.Right)
|
||||||
|
: (farSide ? area.Bottom : area.Top), 8);
|
||||||
|
Assert.Equal(axis == CutOffAxis.Vertical ? bounds.Width : bounds.Length,
|
||||||
|
axis == CutOffAxis.Vertical ? area.Width : area.Length, 8);
|
||||||
|
|
||||||
|
var program = new OpenNest.CNC.Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(0, 3);
|
||||||
|
program.LineTo(3, 3);
|
||||||
|
program.LineTo(3, 0);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
// ActionFillArea passes SelectedArea directly to this service.
|
||||||
|
var parts = PlateFillService.FillItem("Default", plate,
|
||||||
|
new NestItem { Drawing = new Drawing("square", program) }, area, null, CancellationToken.None);
|
||||||
|
Assert.NotEmpty(parts);
|
||||||
|
Assert.All(parts, part =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
action.DisconnectEvents();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private sealed class SelectionView : PlateView
|
||||||
|
{
|
||||||
|
public void SetPoint(Vector point) => CurrentPoint = point;
|
||||||
|
public void ToggleSelectionDirection() => OnKeyUp(new KeyEventArgs(Keys.Space));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunSta(System.Action action) =>
|
||||||
|
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The selection/fill test did not complete.");
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
|
||||||
@@ -48,19 +47,6 @@ public class ArchUnitsTests
|
|||||||
|
|
||||||
// Actual timeout execution is covered by the shared Regex mechanism in FractionTests;
|
// Actual timeout execution is covered by the shared Regex mechanism in FractionTests;
|
||||||
// these desktop tests deliberately avoid timing-sensitive oversized-input fixtures.
|
// these desktop tests deliberately avoid timing-sensitive oversized-input fixtures.
|
||||||
internal static void RunSta(Action action)
|
internal static void RunSta(Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete.");
|
||||||
Exception? error = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try { action(); }
|
|
||||||
catch (Exception ex) { error = ex; }
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(15)), "The STA test did not complete.");
|
|
||||||
if (error != null)
|
|
||||||
ExceptionDispatchInfo.Capture(error).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
@@ -292,19 +291,6 @@ public class ProgramEditorControlTests
|
|||||||
private static void ApplyHighlighting(ProgramEditorControl control) => typeof(ProgramEditorControl)
|
private static void ApplyHighlighting(ProgramEditorControl control) => typeof(ProgramEditorControl)
|
||||||
.GetMethod("ApplyHighlighting", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(control, null);
|
.GetMethod("ApplyHighlighting", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(control, null);
|
||||||
|
|
||||||
private static void RunSta(Action action)
|
private static void RunSta(Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "The STA test did not complete.");
|
||||||
Exception? error = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try { action(); }
|
|
||||||
catch (Exception ex) { error = ex; }
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "The STA test did not complete.");
|
|
||||||
if (error != null)
|
|
||||||
ExceptionDispatchInfo.Capture(error).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.Controls;
|
using OpenNest.Controls;
|
||||||
|
|
||||||
@@ -37,8 +36,9 @@ public class SeparatorPenLifetimeTests
|
|||||||
|
|
||||||
control.Dispose();
|
control.Dispose();
|
||||||
|
|
||||||
|
// Control.Dispose(bool) re-raises Disposed on every call; only cleanup is idempotent.
|
||||||
Assert.True(control.IsDisposed);
|
Assert.True(control.IsDisposed);
|
||||||
Assert.Equal(1, disposedEvents);
|
Assert.Equal(2, disposedEvents);
|
||||||
Assert.Throws<ArgumentException>(() => graphics.DrawLine(lightPen, 0, 0, 3, 0));
|
Assert.Throws<ArgumentException>(() => graphics.DrawLine(lightPen, 0, 0, 3, 0));
|
||||||
Assert.Throws<ArgumentException>(() => graphics.DrawLine(darkPen, 0, 1, 3, 1));
|
Assert.Throws<ArgumentException>(() => graphics.DrawLine(darkPen, 0, 1, 3, 1));
|
||||||
});
|
});
|
||||||
@@ -108,19 +108,6 @@ public class SeparatorPenLifetimeTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RunSta(Action action)
|
private static void RunSta(Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "The STA test did not complete.");
|
||||||
Exception? error = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try { action(); }
|
|
||||||
catch (Exception ex) { error = ex; }
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "The STA test did not complete.");
|
|
||||||
if (error != null)
|
|
||||||
ExceptionDispatchInfo.Capture(error).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ using System.Collections;
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.Forms;
|
using OpenNest.Forms;
|
||||||
@@ -231,19 +230,6 @@ public class AutoNestFormStockOptionsTests
|
|||||||
private static void SetValue(object row, string property, object value) =>
|
private static void SetValue(object row, string property, object value) =>
|
||||||
TypeDescriptor.GetProperties(row)[property]!.SetValue(row, value);
|
TypeDescriptor.GetProperties(row)[property]!.SetValue(row, value);
|
||||||
|
|
||||||
private static void RunSta(Action action)
|
private static void RunSta(Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete.");
|
||||||
Exception? error = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try { action(); }
|
|
||||||
catch (Exception ex) { error = ex; }
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(15)), "The STA test did not complete.");
|
|
||||||
if (error != null)
|
|
||||||
ExceptionDispatchInfo.Capture(error).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Controls;
|
using OpenNest.Controls;
|
||||||
@@ -354,24 +353,6 @@ public class AutomaticCutOffFormTests
|
|||||||
typeof(AutomaticCutOffForm).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
typeof(AutomaticCutOffForm).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
.Invoke(form, arguments);
|
.Invoke(form, arguments);
|
||||||
|
|
||||||
private static void RunSta(System.Action action)
|
private static void RunSta(System.Action action) =>
|
||||||
{
|
StaTestThread.Run(action, Timeout.InfiniteTimeSpan, "The STA test did not complete.");
|
||||||
Exception? failure = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
failure = ex;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
thread.Join();
|
|
||||||
if (failure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
@@ -408,19 +407,6 @@ public class BestFitViewerOperationLifetimeTests
|
|||||||
private static void Invoke(BestFitViewerForm form, string name, params object[] args) =>
|
private static void Invoke(BestFitViewerForm form, string name, params object[] args) =>
|
||||||
typeof(BestFitViewerForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(form, args);
|
typeof(BestFitViewerForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(form, args);
|
||||||
|
|
||||||
private static void RunSta(System.Action action)
|
private static void RunSta(System.Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "STA test did not complete.");
|
||||||
Exception? failure = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try { action(); }
|
|
||||||
catch (Exception ex) { failure = ex; }
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "STA test did not complete.");
|
|
||||||
if (failure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.Forms;
|
using OpenNest.Forms;
|
||||||
|
|
||||||
@@ -83,6 +82,7 @@ public class EditNestFocusTraversalTests
|
|||||||
outer.Controls.Add(inner);
|
outer.Controls.Add(inner);
|
||||||
form.ShowSidePanel(outer, "Focus regression");
|
form.ShowSidePanel(outer, "Focus regression");
|
||||||
var plates = PlateList(form);
|
var plates = PlateList(form);
|
||||||
|
SelectPlatesTab(form, plates); // Drawings is the default tab; the list can't take focus while hidden.
|
||||||
Assert.True(original.Focus());
|
Assert.True(original.Focus());
|
||||||
Assert.True(form.ContainsFocus);
|
Assert.True(form.ContainsFocus);
|
||||||
Assert.Same(original, inner.ActiveControl);
|
Assert.Same(original, inner.ActiveControl);
|
||||||
@@ -112,6 +112,7 @@ public class EditNestFocusTraversalTests
|
|||||||
using var form = CreateForm();
|
using var form = CreateForm();
|
||||||
form.Show();
|
form.Show();
|
||||||
var plates = PlateList(form);
|
var plates = PlateList(form);
|
||||||
|
SelectPlatesTab(form, plates); // Drawings is the default tab; the list can't take focus while hidden.
|
||||||
Assert.True(plates.Focus());
|
Assert.True(plates.Focus());
|
||||||
|
|
||||||
form.UpdatePlateList();
|
form.UpdatePlateList();
|
||||||
@@ -138,25 +139,12 @@ public class EditNestFocusTraversalTests
|
|||||||
private static ListView PlateList(EditNestForm form) =>
|
private static ListView PlateList(EditNestForm form) =>
|
||||||
Assert.IsType<ListView>(Assert.Single(form.Controls.Find("platesListView", true)));
|
Assert.IsType<ListView>(Assert.Single(form.Controls.Find("platesListView", true)));
|
||||||
|
|
||||||
private static void RunSta(System.Action testBody)
|
private static void SelectPlatesTab(EditNestForm form, ListView plates)
|
||||||
{
|
{
|
||||||
Exception? testFailure = null;
|
var tabControl = Assert.IsType<TabControl>(Assert.Single(form.Controls.Find("tabControl1", true)));
|
||||||
var staThread = new Thread(() =>
|
tabControl.SelectedTab = Assert.IsType<TabPage>(plates.Parent);
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
testBody();
|
|
||||||
}
|
|
||||||
catch (Exception error)
|
|
||||||
{
|
|
||||||
testFailure = error;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
staThread.SetApartmentState(ApartmentState.STA);
|
|
||||||
staThread.Start();
|
|
||||||
Assert.True(staThread.Join(TimeSpan.FromSeconds(60)), "The focus traversal STA test did not complete.");
|
|
||||||
if (testFailure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(testFailure).Throw();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void RunSta(System.Action testBody) =>
|
||||||
|
StaTestThread.Run(testBody, TimeSpan.FromSeconds(60), "The focus traversal STA test did not complete.");
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.Forms;
|
using OpenNest.Forms;
|
||||||
|
|
||||||
@@ -40,25 +39,6 @@ public class LeadInSidePanelTests
|
|||||||
Assert.Equal("Select", form.PlateView.Status);
|
Assert.Equal("Select", form.PlateView.Status);
|
||||||
});
|
});
|
||||||
|
|
||||||
private static void RunSta(System.Action action)
|
private static void RunSta(System.Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The STA test did not complete.");
|
||||||
Exception? failure = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
failure = ex;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
|
|
||||||
if (failure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Controls;
|
using OpenNest.Controls;
|
||||||
@@ -148,6 +147,8 @@ public class NestReportExportTests
|
|||||||
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
|
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
|
||||||
form.Show();
|
form.Show();
|
||||||
form.PlateManager.LoadAt(1);
|
form.PlateManager.LoadAt(1);
|
||||||
|
// EnsureSentinel already appended a trailing empty plate behind the two loaded here.
|
||||||
|
var plateCountBeforeExport = nest.Plates.Count;
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"opennest-report-ok-{Guid.NewGuid():N}.pdf");
|
var path = Path.Combine(Path.GetTempPath(), $"opennest-report-ok-{Guid.NewGuid():N}.pdf");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -160,7 +161,7 @@ public class NestReportExportTests
|
|||||||
Assert.True(bytes.Length > 1000);
|
Assert.True(bytes.Length > 1000);
|
||||||
// Selected plate and job contents are untouched by reporting.
|
// Selected plate and job contents are untouched by reporting.
|
||||||
Assert.Equal(1, form.PlateManager.CurrentIndex);
|
Assert.Equal(1, form.PlateManager.CurrentIndex);
|
||||||
Assert.Equal(2, nest.Plates.Count);
|
Assert.Equal(plateCountBeforeExport, nest.Plates.Count);
|
||||||
Assert.Single(nest.Plates[0].Parts);
|
Assert.Single(nest.Plates[0].Parts);
|
||||||
Assert.Equal(2, nest.Drawings.Count);
|
Assert.Equal(2, nest.Drawings.Count);
|
||||||
}
|
}
|
||||||
@@ -297,25 +298,6 @@ public class NestReportExportTests
|
|||||||
internal override void ReportSuccess(string destination) => Successes.Add(destination);
|
internal override void ReportSuccess(string destination) => Successes.Add(destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RunSta(System.Action action)
|
private static void RunSta(System.Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(90), "The STA test did not complete.");
|
||||||
Exception? failure = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
failure = ex;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(90)), "The STA test did not complete.");
|
|
||||||
if (failure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.Forms;
|
using OpenNest.Forms;
|
||||||
|
|
||||||
@@ -62,14 +61,6 @@ public class NestValidationFormTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RunSta(Action action)
|
private static void RunSta(Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(15), "The STA test did not complete.");
|
||||||
Exception? error = null;
|
|
||||||
var thread = new Thread(() => { try { action(); } catch (Exception ex) { error = ex; } });
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(15)));
|
|
||||||
if (error != null)
|
|
||||||
ExceptionDispatchInfo.Capture(error).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Data;
|
using OpenNest.Data;
|
||||||
using OpenNest.Forms;
|
using OpenNest.Forms;
|
||||||
@@ -453,25 +452,6 @@ public class PlateExportTests
|
|||||||
public void Dispose() => Directory.Delete(DirectoryPath, true);
|
public void Dispose() => Directory.Delete(DirectoryPath, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RunSta(System.Action action)
|
private static void RunSta(System.Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(90), "The STA test did not complete.");
|
||||||
Exception? failure = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
failure = ex;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(90)), "The STA test did not complete.");
|
|
||||||
if (failure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Controls;
|
using OpenNest.Controls;
|
||||||
@@ -283,25 +282,6 @@ public class PlateViewFillLifetimeTests
|
|||||||
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
.Invoke(form, arguments);
|
.Invoke(form, arguments);
|
||||||
|
|
||||||
private static void RunSta(System.Action action)
|
private static void RunSta(System.Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The STA test did not complete.");
|
||||||
Exception? failure = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
failure = ex;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
|
|
||||||
if (failure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -206,18 +206,6 @@ public class PostVerificationFormTests
|
|||||||
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
form.GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
.Invoke(form, arguments);
|
.Invoke(form, arguments);
|
||||||
|
|
||||||
private static void RunSta(System.Action action)
|
private static void RunSta(System.Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "STA verification test timed out.");
|
||||||
Exception? failure = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try { action(); }
|
|
||||||
catch (Exception ex) { failure = ex; }
|
|
||||||
});
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "STA verification test timed out.");
|
|
||||||
if (failure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
using System.Runtime.ExceptionServices;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Controls;
|
using OpenNest.Controls;
|
||||||
@@ -97,6 +96,80 @@ public class PlateOverlapOverlayTests
|
|||||||
Assert.Null(run.View.OverlapOverlay.CachedPath);
|
Assert.Null(run.View.OverlapOverlay.CachedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnchangedPairStaysVisibleDuringMotionAndPendingRecheck() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.Plate.Parts.Add(Rectangle(20));
|
||||||
|
run.View.Plate.Parts.Add(Rectangle(21));
|
||||||
|
run.Finish();
|
||||||
|
run.View.OverlapDisplay = OverlapDisplayMode.Both;
|
||||||
|
run.View.ZoomToPoint(new Vector(), 20);
|
||||||
|
using var image = new Bitmap(240, 240);
|
||||||
|
using var graphics = Graphics.FromImage(image);
|
||||||
|
var overlay = run.View.OverlapOverlay;
|
||||||
|
var untouchedPoint = run.View.PointWorldToGraph(new Vector(2, 2));
|
||||||
|
var movedPoint = run.View.PointWorldToGraph(new Vector(22, 2));
|
||||||
|
overlay.Draw(graphics);
|
||||||
|
Assert.True(overlay.CachedPath.IsVisible(untouchedPoint));
|
||||||
|
Assert.True(overlay.CachedPath.IsVisible(movedPoint));
|
||||||
|
|
||||||
|
run.View.Plate.Parts[3].Offset(1e-10, 0);
|
||||||
|
overlay.Draw(graphics);
|
||||||
|
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
|
||||||
|
Assert.Null(run.View.OverlapReport);
|
||||||
|
Assert.True(overlay.CachedPath.IsVisible(untouchedPoint));
|
||||||
|
Assert.False(overlay.CachedPath.IsVisible(movedPoint));
|
||||||
|
var retainedPath = overlay.CachedPath;
|
||||||
|
run.View.Plate.Parts[3].Offset(1, 0);
|
||||||
|
overlay.Draw(graphics);
|
||||||
|
Assert.Same(retainedPath, overlay.CachedPath);
|
||||||
|
Assert.Equal(1, run.Calls); // Paint never analyzes.
|
||||||
|
|
||||||
|
var task = run.Start();
|
||||||
|
var pending = run.Next();
|
||||||
|
overlay.Draw(graphics); // Both areas and centroid markers must render without a full report.
|
||||||
|
Assert.Equal(OverlapCheckStatus.Checking, run.View.OverlapStatus);
|
||||||
|
Assert.Same(retainedPath, overlay.CachedPath);
|
||||||
|
run.View.Plate.Parts[0].Offset(1e-10, 0);
|
||||||
|
overlay.Draw(graphics);
|
||||||
|
Assert.Null(overlay.CachedPath);
|
||||||
|
pending.Complete();
|
||||||
|
run.Pump(task);
|
||||||
|
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
|
||||||
|
Assert.Null(run.View.OverlapReport);
|
||||||
|
overlay.Draw(graphics);
|
||||||
|
Assert.Null(overlay.CachedPath); // Late completion cannot restore the removed pair.
|
||||||
|
});
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("editor")]
|
||||||
|
[InlineData("cancel")]
|
||||||
|
[InlineData("failure")]
|
||||||
|
public void RetainedPathClearsOnHardInvalidationOrRequestFailure(string edit) => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.View.Plate.Parts.Add(Rectangle(20));
|
||||||
|
run.Finish();
|
||||||
|
run.View.Plate.Parts[2].Offset(1, 0);
|
||||||
|
run.Paint();
|
||||||
|
var path = run.View.OverlapOverlay.CachedPath;
|
||||||
|
Assert.NotNull(path);
|
||||||
|
var task = run.Start();
|
||||||
|
var pending = run.Next();
|
||||||
|
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
|
||||||
|
switch (edit)
|
||||||
|
{
|
||||||
|
case "editor": run.View.InvalidateOverlapCheck(); pending.Complete(); break;
|
||||||
|
case "cancel": run.View.CancelOverlapCheck(); pending.Complete(); break;
|
||||||
|
case "failure": pending.Fail(); break;
|
||||||
|
}
|
||||||
|
run.Pump(task);
|
||||||
|
Assert.Null(run.View.OverlapReport);
|
||||||
|
run.Paint();
|
||||||
|
Assert.Null(run.View.OverlapOverlay.CachedPath);
|
||||||
|
});
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() =>
|
public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() =>
|
||||||
{
|
{
|
||||||
@@ -233,11 +306,10 @@ public class PlateOverlapOverlayTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("add")]
|
|
||||||
[InlineData("remove")]
|
[InlineData("remove")]
|
||||||
[InlineData("replace")]
|
[InlineData("replace")]
|
||||||
[InlineData("clear")]
|
[InlineData("clear")]
|
||||||
public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
|
public void SlotChangingCollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
|
||||||
{
|
{
|
||||||
using var run = new OverlayRun();
|
using var run = new OverlayRun();
|
||||||
run.Finish();
|
run.Finish();
|
||||||
@@ -247,7 +319,6 @@ public class PlateOverlapOverlayTests
|
|||||||
Assert.NotNull(run.View.OverlapOverlay.CachedPath);
|
Assert.NotNull(run.View.OverlapOverlay.CachedPath);
|
||||||
switch (edit)
|
switch (edit)
|
||||||
{
|
{
|
||||||
case "add": run.View.Plate.Parts.Add(Rectangle()); break;
|
|
||||||
case "remove": run.View.Plate.Parts.RemoveAt(0); break;
|
case "remove": run.View.Plate.Parts.RemoveAt(0); break;
|
||||||
case "replace": run.View.Plate.Parts[0] = Rectangle(); break;
|
case "replace": run.View.Plate.Parts[0] = Rectangle(); break;
|
||||||
case "clear": run.View.Plate.Parts.Clear(); break;
|
case "clear": run.View.Plate.Parts.Clear(); break;
|
||||||
@@ -257,6 +328,23 @@ public class PlateOverlapOverlayTests
|
|||||||
Assert.Null(run.View.OverlapOverlay.CachedPath);
|
Assert.Null(run.View.OverlapOverlay.CachedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AppendingPartRetainsTheExistingPairsPathButInvalidatesTheFullReport() => RunSta(() =>
|
||||||
|
{
|
||||||
|
using var run = new OverlayRun();
|
||||||
|
run.Finish();
|
||||||
|
run.Paint();
|
||||||
|
var path = run.View.OverlapOverlay.CachedPath;
|
||||||
|
Assert.NotNull(path);
|
||||||
|
run.View.Plate.Parts.Add(Rectangle());
|
||||||
|
Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
|
||||||
|
Assert.Null(run.View.OverlapReport);
|
||||||
|
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
|
||||||
|
run.Paint();
|
||||||
|
Assert.Same(path, run.View.OverlapOverlay.CachedPath);
|
||||||
|
Assert.Equal(1, run.Calls);
|
||||||
|
});
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() =>
|
public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() =>
|
||||||
{
|
{
|
||||||
@@ -1094,19 +1182,6 @@ public class PlateOverlapOverlayTests
|
|||||||
return new Part(new Drawing("same name", program), new Vector(x, 0));
|
return new Part(new Drawing("same name", program), new Vector(x, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RunSta(System.Action action)
|
private static void RunSta(System.Action action) =>
|
||||||
{
|
StaTestThread.Run(action, TimeSpan.FromSeconds(60), "The STA test did not complete.");
|
||||||
Exception? failure = null;
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try { action(); }
|
|
||||||
catch (Exception ex) { failure = ex; }
|
|
||||||
})
|
|
||||||
{ IsBackground = true };
|
|
||||||
thread.SetApartmentState(ApartmentState.STA);
|
|
||||||
thread.Start();
|
|
||||||
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
|
|
||||||
if (failure != null)
|
|
||||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a test body on its own STA thread and rethrows its failure on the
|
||||||
|
/// calling test thread.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// By default WinForms catches an exception raised while dispatching a window
|
||||||
|
/// message or a BeginInvoke/SynchronizationContext callback and hands it to the
|
||||||
|
/// thread's ThreadException handler. With none registered it opens a modal
|
||||||
|
/// ThreadExceptionDialog in an interactive session, which blocks the test until
|
||||||
|
/// its join timeout, and silently ignores the exception otherwise. Both the
|
||||||
|
/// unhandled-exception mode and the handler are per-thread, so they cannot be
|
||||||
|
/// configured once for the assembly: each STA thread selects
|
||||||
|
/// <see cref="UnhandledExceptionMode.ThrowException"/> before it creates a
|
||||||
|
/// window, and the exception then propagates to the code pumping messages
|
||||||
|
/// (Show, DoEvents, ShowDialog) and fails the test.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class StaTestThread
|
||||||
|
{
|
||||||
|
public static void Run(Action body, TimeSpan timeout, string timeoutMessage)
|
||||||
|
{
|
||||||
|
Exception? failure = null;
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException);
|
||||||
|
body();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failure = ex;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
};
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
Assert.True(thread.Join(timeout), timeoutMessage);
|
||||||
|
if (failure != null)
|
||||||
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
public class StaTestThreadTests
|
||||||
|
{
|
||||||
|
private const string CallbackMessage = "thrown from a queued UI callback";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QueuedCallbackExceptionFailsTheTestInsteadOfOpeningADialog()
|
||||||
|
{
|
||||||
|
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
StaTestThread.Run(
|
||||||
|
ThrowFromQueuedCallback,
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
"The STA test did not complete."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Equal(CallbackMessage, error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithoutThrowExceptionModeTheCallbackExceptionNeverReachesTheTestBody()
|
||||||
|
{
|
||||||
|
// Control: the default mode routes the same exception to the thread's
|
||||||
|
// ThreadException handler (or, with none, to a modal dialog), so the
|
||||||
|
// test body itself completes without seeing it.
|
||||||
|
Exception? routed = null;
|
||||||
|
Exception? bodyFailure = null;
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
Application.ThreadException += (_, e) => routed = e.Exception;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ThrowFromQueuedCallback();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
bodyFailure = ex;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
};
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
|
||||||
|
Assert.True(thread.Join(TimeSpan.FromSeconds(30)), "The control STA thread did not complete.");
|
||||||
|
if (bodyFailure != null)
|
||||||
|
ExceptionDispatchInfo.Capture(bodyFailure).Throw();
|
||||||
|
Assert.IsType<InvalidOperationException>(routed);
|
||||||
|
Assert.Equal(CallbackMessage, routed!.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ThrowFromQueuedCallback()
|
||||||
|
{
|
||||||
|
using var control = new Control();
|
||||||
|
_ = control.Handle;
|
||||||
|
control.BeginInvoke(new Action(() => throw new InvalidOperationException(CallbackMessage)));
|
||||||
|
Application.DoEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Reporting", "OpenN
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest.Server\OpenNest.Server.csproj", "{A2FC654E-8F13-4AD7-BED2-455F2B690961}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server.Tests", "OpenNest.Server.Tests\OpenNest.Server.Tests.csproj", "{91DCA585-CEBF-4B80-B239-591CD0C919FA}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -312,6 +314,18 @@ Global
|
|||||||
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.Build.0 = Release|Any CPU
|
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.ActiveCfg = Release|Any CPU
|
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.Build.0 = Release|Any CPU
|
{A2FC654E-8F13-4AD7-BED2-455F2B690961}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
private ObservableList<Part> observedParts;
|
private ObservableList<Part> observedParts;
|
||||||
private CancellationTokenSource cancellation;
|
private CancellationTokenSource cancellation;
|
||||||
private GraphicsPath path;
|
private GraphicsPath path;
|
||||||
private PlateOverlapReport pathReport;
|
private IReadOnlyList<PlateOverlapPair> pathPairs;
|
||||||
private float pathScale;
|
private float pathScale;
|
||||||
private Units capturedUnits;
|
private Units capturedUnits;
|
||||||
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
|
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
|
||||||
@@ -139,7 +139,6 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
var source = new CancellationTokenSource();
|
var source = new CancellationTokenSource();
|
||||||
cancellation = source;
|
cancellation = source;
|
||||||
var token = source.Token;
|
var token = source.Token;
|
||||||
ReleasePath();
|
|
||||||
NotifyChanged(toStatusBar: !automatic);
|
NotifyChanged(toStatusBar: !automatic);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -204,13 +203,22 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
{
|
{
|
||||||
materialCache.Clear();
|
materialCache.Clear();
|
||||||
baseline = null;
|
baseline = null;
|
||||||
Invalidate();
|
state.Invalidate();
|
||||||
|
CancelWorker();
|
||||||
|
ReleasePath();
|
||||||
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Invalidate()
|
public void Invalidate()
|
||||||
{
|
{
|
||||||
var generation = state.Generation;
|
var generation = state.Generation;
|
||||||
state.Invalidate();
|
var display = state.DisplayPairs;
|
||||||
|
state.Invalidate(view.Plate);
|
||||||
|
if (!ReferenceEquals(display, state.DisplayPairs))
|
||||||
|
{
|
||||||
|
ReleasePath();
|
||||||
|
ClearHover();
|
||||||
|
}
|
||||||
if (generation == state.Generation)
|
if (generation == state.Generation)
|
||||||
{
|
{
|
||||||
// Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one
|
// Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one
|
||||||
@@ -223,7 +231,6 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CancelWorker();
|
CancelWorker();
|
||||||
ReleasePath();
|
|
||||||
NotifyChanged();
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +257,8 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
|
|
||||||
private void NotifyChanged(bool toStatusBar = false)
|
private void NotifyChanged(bool toStatusBar = false)
|
||||||
{
|
{
|
||||||
|
if (!ReferenceEquals(pathPairs, state.DisplayPairs))
|
||||||
|
ReleasePath();
|
||||||
ClearHover();
|
ClearHover();
|
||||||
if (disposed || view.IsDisposed || view.Disposing)
|
if (disposed || view.IsDisposed || view.Disposing)
|
||||||
return;
|
return;
|
||||||
@@ -343,7 +352,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below.
|
UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below.
|
||||||
ValidateHoverTransform();
|
ValidateHoverTransform();
|
||||||
|
|
||||||
if (state.Report != null)
|
if (state.DisplayPairs.Count > 0)
|
||||||
{
|
{
|
||||||
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
|
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
|
||||||
{
|
{
|
||||||
@@ -366,11 +375,16 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
if (disposed)
|
if (disposed)
|
||||||
return false;
|
return false;
|
||||||
var generation = state.Generation;
|
var generation = state.Generation;
|
||||||
|
var display = state.DisplayPairs;
|
||||||
var fresh = state.EnsureFresh(view.Plate);
|
var fresh = state.EnsureFresh(view.Plate);
|
||||||
|
if (!ReferenceEquals(display, state.DisplayPairs))
|
||||||
|
{
|
||||||
|
ReleasePath();
|
||||||
|
ClearHover();
|
||||||
|
}
|
||||||
if (generation != state.Generation)
|
if (generation != state.Generation)
|
||||||
{
|
{
|
||||||
CancelWorker();
|
CancelWorker();
|
||||||
ReleasePath();
|
|
||||||
NotifyChanged();
|
NotifyChanged();
|
||||||
}
|
}
|
||||||
return fresh;
|
return fresh;
|
||||||
@@ -457,7 +471,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale);
|
using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale);
|
||||||
// Stack coincident pair labels instead of replacing them with a fragment count.
|
// Stack coincident pair labels instead of replacing them with a fragment count.
|
||||||
var labelRows = new Dictionary<PointF, int>();
|
var labelRows = new Dictionary<PointF, int>();
|
||||||
foreach (var pair in state.Report.Pairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
|
foreach (var pair in state.DisplayPairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
|
||||||
{
|
{
|
||||||
var center = view.PointWorldToGraph(pair.Centroid);
|
var center = view.PointWorldToGraph(pair.Centroid);
|
||||||
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
|
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
|
||||||
@@ -532,13 +546,13 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
|
|
||||||
private void EnsurePath()
|
private void EnsurePath()
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null)
|
if (ReferenceEquals(pathPairs, state.DisplayPairs) && pathScale == view.ViewScale && path != null)
|
||||||
return;
|
return;
|
||||||
ReleasePath();
|
ReleasePath();
|
||||||
var next = new GraphicsPath(FillMode.Winding);
|
var next = new GraphicsPath(FillMode.Winding);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var pair in state.Report.Pairs)
|
foreach (var pair in state.DisplayPairs)
|
||||||
{
|
{
|
||||||
foreach (var region in pair.Regions)
|
foreach (var region in pair.Regions)
|
||||||
{
|
{
|
||||||
@@ -560,7 +574,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
path = next;
|
path = next;
|
||||||
pathReport = state.Report;
|
pathPairs = state.DisplayPairs;
|
||||||
pathScale = view.ViewScale;
|
pathScale = view.ViewScale;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -592,7 +606,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
|||||||
{
|
{
|
||||||
path?.Dispose();
|
path?.Dispose();
|
||||||
path = null;
|
path = null;
|
||||||
pathReport = null;
|
pathPairs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ namespace OpenNest.Forms
|
|||||||
);
|
);
|
||||||
|
|
||||||
plateGrid.CellValidating += PlateGrid_CellValidating;
|
plateGrid.CellValidating += PlateGrid_CellValidating;
|
||||||
|
plateGrid.DataError += PlateGrid_DataError;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadDrawings(Nest nest)
|
private void LoadDrawings(Nest nest)
|
||||||
@@ -214,7 +215,36 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
options = new List<PlateOption>();
|
options = new List<PlateOption>();
|
||||||
error = null;
|
error = null;
|
||||||
if (!plateGrid.EndEdit())
|
|
||||||
|
// CellValidating cancels an unparsable size and leaves the cell in edit mode;
|
||||||
|
// calling EndEdit() on that cancelled edit hangs/throws instead of returning
|
||||||
|
// false for it. Catch an unparsable pending edit ourselves first so EndEdit()
|
||||||
|
// is never asked to commit or discard it.
|
||||||
|
if (
|
||||||
|
plateGrid.IsCurrentCellInEditMode
|
||||||
|
&& plateGrid.CurrentCell != null
|
||||||
|
&& plateGrid.Columns[plateGrid.CurrentCell.ColumnIndex].DataPropertyName == "Size"
|
||||||
|
&& plateGrid.EditingControl != null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var pending = plateGrid.EditingControl.Text;
|
||||||
|
if (!string.IsNullOrWhiteSpace(pending) && !TryParseSize(pending, out _, out _))
|
||||||
|
{
|
||||||
|
error = $"Invalid stock size '{Preview(pending)}'. Enter positive dimensions as W x L.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool committed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
committed = plateGrid.EndEdit();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
committed = false;
|
||||||
|
}
|
||||||
|
if (!committed)
|
||||||
{
|
{
|
||||||
var value = plateGrid.EditingControl?.Text ?? plateGrid.CurrentCell?.Value?.ToString();
|
var value = plateGrid.EditingControl?.Text ?? plateGrid.CurrentCell?.Value?.ToString();
|
||||||
error = $"Invalid stock size '{Preview(value)}'. Enter positive dimensions as W x L.";
|
error = $"Invalid stock size '{Preview(value)}'. Enter positive dimensions as W x L.";
|
||||||
@@ -424,6 +454,14 @@ namespace OpenNest.Forms
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CellValidating and TryGetPlateOptions already surface invalid sizes to the user;
|
||||||
|
// this only stops the grid from throwing when a commit is forced outside that path
|
||||||
|
// (e.g. a BindingContext change while a cell is mid-edit).
|
||||||
|
private void PlateGrid_DataError(object sender, DataGridViewDataErrorEventArgs e)
|
||||||
|
{
|
||||||
|
e.ThrowException = false;
|
||||||
|
}
|
||||||
|
|
||||||
private DataGridViewItem GetDataGridViewItem(Drawing dwg)
|
private DataGridViewItem GetDataGridViewItem(Drawing dwg)
|
||||||
{
|
{
|
||||||
var item = new DataGridViewItem();
|
var item = new DataGridViewItem();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ namespace OpenNest.Forms
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_parts = new List<BomPartRow>();
|
_parts = new List<BomPartRow>();
|
||||||
_groupSettings = new Dictionary<string, GroupSettings>();
|
_groupSettings = new Dictionary<string, GroupSettings>();
|
||||||
_defaults = NestDefaults.Load(NestDefaults.DefaultPath);
|
_defaults = MainForm.LoadSavedNestDefaults(out _);
|
||||||
ApplyDefaults();
|
ApplyDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,10 +465,12 @@ namespace OpenNest.Forms
|
|||||||
var nest = new Nest(nestName);
|
var nest = new Nest(nestName);
|
||||||
nest.DateCreated = DateTime.Now;
|
nest.DateCreated = DateTime.Now;
|
||||||
nest.DateLastModified = DateTime.Now;
|
nest.DateLastModified = DateTime.Now;
|
||||||
|
// Saved defaults first (units, quadrant, plate), as New does;
|
||||||
|
// then the group's own plate size and spacing.
|
||||||
|
_defaults.ApplyTo(nest);
|
||||||
nest.PlateDefaults.Size = new Geometry.Size(plateWidth, plateLength);
|
nest.PlateDefaults.Size = new Geometry.Size(plateWidth, plateLength);
|
||||||
nest.Thickness = thickness;
|
nest.Thickness = thickness;
|
||||||
nest.Material = new Material(material);
|
nest.Material = new Material(material);
|
||||||
nest.PlateDefaults.Quadrant = _defaults.Quadrant;
|
|
||||||
nest.PlateDefaults.PartSpacing = partSpacing;
|
nest.PlateDefaults.PartSpacing = partSpacing;
|
||||||
nest.PlateDefaults.EdgeSpacing = new Spacing(
|
nest.PlateDefaults.EdgeSpacing = new Spacing(
|
||||||
edgeLeft,
|
edgeLeft,
|
||||||
|
|||||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user