Author SHA1 Message Date
aj 7127884584 fix(cutting): start tabbed lead-outs at the trimmed cut end
A tab trims the perimeter short of its entry, but the lead-out was still
generated from the nominal entry point. An arc lead-out therefore started
off its own radius (ExecutionMotionReader rejected it as inconsistent), and
a line lead-out ran diagonally back toward the entry.

Every lead-out style on a tabbed perimeter now leaves from the trimmed
cut's actual end, on that entity's normal, so arcs are tangent and the tab
gap stays uncut. Untabbed contours and the corner run-out rules are
unchanged. Malformed legacy output is still refused, never refit.

Red before the fix: the three tabbed arc cases threw "Arc has zero or
inconsistent radius" and the line case ended at y=5 instead of 4.8. Keeping
the entry's normal at the actual end fails the curved-perimeter case.
2026-10-04 23:13:41 -04:00
aj 6f7652ee83 fix(cutting): preserve clean geometry through shared graph rotation 2026-10-04 22:26:22 -04:00
aj fef93cbfa5 style(cutting): format emission characterization initializers 2026-10-04 22:10:41 -04:00
aj cf615dcef8 docs(cutting): document exact replay and fixed-program refusals 2026-10-04 22:02:56 -04:00
aj e8d974a2db fix(cutting): certify complete selected contour programs 2026-10-04 22:02:56 -04:00
aj 9f7580a486 fix(cutting): bind replay proposals to exact captured pose bits 2026-10-04 21:56:49 -04:00
aj fb7ef6fd0d fix(cutting): retain authored motion metadata in owned programs 2026-10-04 21:56:49 -04:00
aj e00aa113a0 fix(cutting): validate original graphs before capture clones 2026-10-04 21:56:49 -04:00
aj d7204e8d07 fix(cutting): share only exact prepared circle programs 2026-10-04 21:36:40 -04:00
aj 1597ee9069 fix(cutting): guard material runtime semantics before capture 2026-10-04 21:36:40 -04:00
aj d1109aaa43 fix(cutting): refuse uncertain native circular contacts 2026-10-04 21:36:40 -04:00
aj 085edeea3b docs(cutting): describe joint planning and refusal boundaries 2026-10-04 21:19:45 -04:00
aj 70d886e4cf feat(cutting): jointly plan contour order and entry points 2026-10-04 21:12:55 -04:00
aj 018e4e3dd4 feat(cutting): validate actual leads against owned material 2026-10-04 20:51:23 -04:00
aj e71fcea73d feat(cutting): emit owned contours in explicit order 2026-10-04 20:45:15 -04:00
aj 6addad7b2e test(cutting): characterize retained contour emission 2026-10-04 20:26:17 -04:00
aj 7451596047 test(cutouts): keep the repeated-call determinism test, skipped on the open Fill defect
Restore RepeatedCallsReturnIdenticalPoses for a 1 x 1 square and a 1.5 x 0.75 rectangle.
Both cases fail intermittently because Default Fill can return different, equally scored
lattices for identical calls (Project Memory opennest c98c21bd), so they are skipped with
that reason until the defect is fixed rather than removed.
2026-10-04 20:07:52 -04:00
aj 15cca0efd5 feat(cutouts): fill a cutout with a shifted Fill lattice
CutoutLatticeFill fills one closed cutout of a frame part with copies of one part, the
first piece of the cutouts-first pass. It runs Default Fill over the cutout's bounds
plus one part step on every side, shifts the lattice over a (2n+1)^2 grid of offsets
up to half a step each way, and at each offset keeps the copies whose reference point
lies in the part's inner-fit region of the inscribed cutout (part circumscribed and
grown by the spacing). The offset keeping the most copies wins; every returned pose
is then certified with NestLayoutCheck.Clears against the frame and the other copies.

20" ring, 10" round cutout, 1" squares, 0.25" spacing: 37 copies, against 25 for a
block sized to the inscribed rectangle and 32 for the unshifted lattice.

Not wired into any engine or pipeline: parts inside cutouts wait on containment-aware
cutting order. Fill can still return different, equally scored lattices between calls
for some parts, so identical results are not yet claimed.
2026-10-04 19:56:05 -04:00
aj 4842b80ceb fix(fill): pick FillPattern winners in angle order, not thread order
FillPattern gathered per-angle results in a ConcurrentBag and kept the first best it
enumerated, so an exact tie between angles went to whichever worker finished first:
identical inputs could return different, equally scored layouts. Results now sit in
slots indexed by angle, V before H within an angle (the order the single-angle bag
already produced and the existing tie tests pin), and ties keep the earliest slot.
Single-angle behaviour is unchanged.

Regression: a never-prefer comparer over one 0-degree and fifteen 180-degree angles
must keep the 0-degree layout in 50 of 50 calls; it failed on the old code.
2026-10-04 19:50:30 -04:00
aj 418f75916b refactor(engine): share the private-plate Fill run between blocks and future callers
Move BlockCatalog's private-drawing Fill invocation (BestFitCache stabilization plus
the Default FillItem call) into PrivatePlateFill so the cutout fill can reuse it
without duplicating the cache handling. Behavior unchanged; Irregular filter 96/96.
2026-10-04 19:40:49 -04:00
aj a15c54fbaf test(desktop): fail WinForms tests on UI-callback exceptions per STA thread
TestAssemblySetup set the unhandled-exception mode and a ThreadException
handler from a module initializer. WinForms stores both per thread
(ThreadContext/NativeWindow thread statics), so they applied only to the
runner thread that loaded the assembly, never to the STA threads the
tests run on. There, an exception escaping a window message or a
BeginInvoke/SynchronizationContext callback still opened a modal
ThreadExceptionDialog in an interactive session, or was silently ignored
otherwise; the handler would also have logged and swallowed it.

Replace the fifteen copied RunSta helpers with StaTestThread.Run, which
selects UnhandledExceptionMode.ThrowException on each STA thread before
it creates a window, so the exception propagates to the test body and
fails the test. Each class keeps its own timeout and message; all
threads are now background threads. Remove TestAssemblySetup.

StaTestThreadTests covers a throwing BeginInvoke callback through
StaTestThread (fails with the original exception) and, as a control,
the default mode on a plain STA thread (the exception goes to the
thread's handler and never reaches the test body). Cross-compiled on
Linux; Windows execution is the windows-desktop CI job.
2026-10-04 18:40:48 -04:00
aj 67fb80e660 ci: kill and name hung Windows tests instead of holding the runner
The first windows-desktop run hung for 25 minutes with no output and
ran until it was cancelled. Pass --blame-hang-timeout 5m so a test that
is silent for five minutes is killed, named in the log and mini-dumped
into TestResults, then cap the job at 20 minutes and upload all of
TestResults, not just the TRX files.

Checked locally with a throwaway xunit project: a Thread.Sleep(Infinite)
test was killed after the configured inactivity, named in the output,
and left a TRX, a test-order (Sequence) file and a mini dump; dotnet test
exited 1.
2026-10-04 18:39:08 -04:00
ajandClaude Sonnet 5 5f9e8814a7 test(desktop): fix Windows-only WinForms and cross-platform test failures
The new windows-desktop CI job surfaced several tests that had never
actually run on Windows:

- SeparatorPenLifetimeTests expected Control.Disposed to fire only
  once; current WinForms re-fires it on every Dispose() call.
- EditNestFocusTraversalTests assumed the Plates tab was selected by
  default, which stopped being true once Drawings became the landing
  tab; select it explicitly before asserting focus.
- NestReportExportTests asserted an exact plate count that ignored
  PlateManager's trailing sentinel plate, and didn't expect the nest
  name in the busy-export message.
- AtomicReportFileTests only expected IOException when replacing a
  directory target; Windows raises UnauthorizedAccessException instead,
  which callers already treat the same way.

Also register a ThreadException handler so an exception inside a
WndProc callback fails the test normally instead of popping a modal
dialog that blocks the desktop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 18:24:20 -04:00
ajandClaude Sonnet 5 3ba8f4e3b2 fix(desktop): avoid AutoNest stock grid hang and name the busy export target
DataGridView.EndEdit() hangs/throws instead of returning false when
CellValidating has already cancelled the pending edit, so an unparsable
stock size froze TryGetPlateOptions. Validate the pending edit text
ourselves before ever asking EndEdit() to commit or discard it.

Also include the nest name in the export-busy message, matching how
the invalid-name rejection already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 18:24:13 -04:00
aj 2a5ebf1e8a ci: run WinForms and front-end tests on a Windows runner
Pushes to master only ran the Linux job, which can cross-compile but
not execute OpenNest.WinForms.Tests; Windows test runs happened only on
release branches and tags. Add a windows-2022 job to the cross-platform
workflow that runs OpenNest.WinForms.Tests and OpenNest.FrontEnd.Tests
in Release on every master push and pull request, runs both projects
before failing, and uploads their TRX results.
2026-10-04 12:39:57 -04:00
aj 90587803be fix(desktop): apply saved units to new and BOM-created nests
New only fell back to the legacy DefaultUnit setting when the whole
defaults file was missing or invalid, so a readable file without a
usable unit produced inches. BOM import never assigned units at all,
and the Nest Defaults dialog showed inches for a missing file.

All three now load through MainForm.LoadSavedNestDefaults, which passes
DefaultUnit as the per-field unit fallback. BOM-created nests start from
NestDefaults.ApplyTo, like New, before applying each group's plate size
and spacing.

The ApplyTo test now asserts units; removing the unit assignment from
ApplyTo fails it. WinForms behaviour is compile-verified only on Linux.
2026-10-04 12:03:22 -04:00
aj b7dc7dee13 fix(defaults): accept only defined unit names, with a caller fallback
Enum.TryParse accepted numeric strings ("7" loaded an undefined unit,
"1" loaded millimeters) and comma-joined names, and a JSON number for
units failed the whole file. Units now load only from a defined unit
name; anything else falls back for that field alone, while the other
valid fields still load.

Add Load(path, fallbackUnits, out status) so callers can keep their
existing unit preference when the file is missing, unusable, or lacks a
usable unit (previously a readable file without units silently became
inches). An undefined fallback is ignored in favour of the built-in
units.

The numeric/comma/JSON-number rows failed against the previous commit;
dropping the fallback assignment or its IsDefined guard fails the new
fallback tests.
2026-10-04 12:01:12 -04:00
aj 39a15aee14 fix(defaults): treat a read-denied defaults file as invalid
NestDefaults.Load caught only JsonException and IOException, so a
defaults.json that File.Exists can see but the process cannot read
threw UnauthorizedAccessException out of New. Report such files as
Invalid and return the fallback values, like a corrupt file.

The regression mode-000s the file on Unix (skipped on Windows and when
running as root); it failed with UnauthorizedAccessException before
this change.
2026-10-04 11:59:08 -04:00
aj 23d258b886 perf(geometry): allocate entity IDs from a salted atomic sequence 2026-10-03 10:59:04 -04:00
aj 60d3915af2 docs(test): use Release for routine cross-platform suites 2026-10-03 10:45:21 -04:00
aj 761cee7c1f feat(cutting): plan verified part order from fixed programs 2026-10-02 21:56:14 -04:00
aj c49c387af5 merge: integrate exact-contact rotated rectangle repair
Brings in the independently reviewed exact-contact repair (c7b4ba4,
25755a8, bd5db59): line-only part outlines keep exact analytic bounds,
so an 8x3 part fits rotated on 8.5x3.5 stock at 0.25 spacing, while
curved outlines keep their conservative padding and no validator,
spacing or fit tolerance changes. The held hole-profile and pocket
commits built on bd5db59 stay unmerged.
2026-10-02 20:50:19 -04:00
aj 9e15d5dceb fix(desktop): track only the latest database browse request
A request is now marked superseded before the previous one is
cancelled, so a query that completes synchronously on cancellation
returns quietly instead of clearing the page and surfacing a stale
cancellation error. NestBrowseSession.IsLoading follows only the latest
request, and the dialog's Previous/Next and Loading text use it rather
than a count that included superseded requests. Each request disposes
its own cancellation source when it ends.
2026-10-02 20:22:24 -04:00
aj eace08f351 fix(server): reject NUL characters in nest query search
SQLite LIKE stops matching at an embedded NUL, so search=Alpha%00x
behaved like 'Alpha' followed by a wildcard and returned rows that do
not contain the whole search text. The shared query validation now
rejects NUL, so the server answers 400 and the client throws before
sending.
2026-10-02 20:16:00 -04:00
aj 96267e760e feat(desktop): page and filter database nests on the server
The Database-mode Open dialog no longer downloads every record. It
browses through NestBrowseSession, which sends one bounded query per
change: the filter box (300 ms debounce) searches on the server and
returns to the first page, column headers sort every match on the
server (a second click reverses), and Previous/Next page by 100 with a
range and total in the status line. A response superseded by a newer
request is cancelled and discarded, refresh steps back when the current
page was emptied, and failures clear the rows and show the error
without a modal box per keystroke.
2026-10-02 20:06:52 -04:00
aj 646d4c3975 feat(server): sort nest queries by allowlisted columns
The query accepts sort=<column>&order=asc|desc over a fixed allowlist
(saved, name, customer, status, material, dates, thickness, plate and
part counts, made by, comments, file size). Text columns sort with
NOCASE, ties break by id in the same direction so pages partition the
matches, and unknown or numeric sort values and other orders return 400.
The client sends the sort as its camelCase name.
2026-10-02 19:57:35 -04:00
aj 1fadf6c4d3 feat(server): add bounded nest query with SQL-side search
GET /api/nests/query filters name, customer, material, made by, comments
and status (stored and display names) with an escaped LIKE parameter,
orders newest saved first with an id tie-break, and returns one page of
at most 500 records plus the total match count, read in one database
hold. Unknown, repeated or out-of-range parameters return 400.

RemoteNestRepository.QueryAsync validates the same bounds before
sending, rejects oversized pages, and reports a 404 from an older server
as a server that needs updating. GET /api/nests stays the full
enumeration used by backup, restore checks and the container smoke.
2026-10-02 19:52:35 -04:00
aj f53bead0a8 fix(server-ci): verify exported image identity across Docker stores 2026-10-02 19:15:53 -04:00
aj 1ec79ae594 ci(server): validate and publish versioned GHCR images 2026-10-02 18:53:00 -04:00
aj d428357c8b feat(server): harden Docker runtime and document persistent deployment
- Run as the .NET image's non-root app user (UID 1654) with root-owned
  application files and an app-owned /app/data that fresh named volumes inherit.
- Add a curl HEALTHCHECK on /healthz (30s/5s/10s/3, 2s start interval), keep the
  explicit 8090 URL and clear the base image's 8080 port default.
- Parameterize VERSION/SOURCE_REVISION and base images; add OCI labels with
  development defaults.
- Add an image-only Compose example (required image and bind address, named
  volume, cap_drop ALL, no-new-privileges) and an env template.
- Document deployment, the upload limit, scoped ownership preparation, and
  checked backup/restore/upgrade functions that refuse existing destinations and
  verify the metadata list and every archive hash before switching volumes.
- Extend the container smoke: image user/healthcheck/label contract, PID 1 UID,
  capabilities and writable paths, Docker-reported health, near-limit and
  oversized uploads, stopped-service backup restored into a second volume, and
  startup failure on read-only and root-owned data mounts.
2026-10-02 17:23:36 -04:00
aj 142c77a27f fix(server): serialize SQLite access and validate storage health 2026-10-02 16:31:46 -04:00
aj b39509a6a3 fix(server): update SQLite to patched native library
Microsoft.Data.Sqlite 8.0.11 bundled SQLitePCLRaw.lib.e_sqlite3 2.1.6 (SQLite 3.41.2), affected by CVE-2025-6965 / GHSA-2m69-gcr7-jv3q. 8.0.31 bundles 2.1.12 (SQLite 3.53.3).
2026-10-02 16:10:24 -04:00
aj 6d1e0cb896 docs: list server integration tests in agent instructions 2026-10-02 16:06:31 -04:00
aj 5d8874a9e7 fix(server): reject malformed multipart uploads with 400 2026-10-02 15:48:55 -04:00
aj b17fe1a318 test(server): cover HTTP storage contracts with isolated SQLite 2026-10-02 15:37:44 -04:00
aj 7999e3cdde fix(server): repair Docker build and verify persistent client round trips 2026-10-02 14:17:13 -04:00
aj 14a7ce43a7 test: prevent selected-area fill from crossing sheet cutoffs 2026-10-02 07:12:15 -04:00
aj bd5db59e42 fix(irregular): preserve analytic line bounds after polygon cleanup 2026-10-01 20:45:22 -04:00
aj 25755a8850 fix(irregular): keep exact bounds for line-only part outlines
Orientation bounds were padded by the arc chord tolerance even when the outline has no arcs, so a part that exactly fills the work area after rotation was rejected before packing. Pad only outlines with flattened arcs; footprints and NFPs still use the chord tolerance.
2026-10-01 18:44:51 -04:00
aj c7b4ba4457 test(irregular): cover exact-contact rotated rectangle placement 2026-10-01 18:44:51 -04:00
aj d8b763da13 test(irregular): add six synthetic nests and bounded validity gate 2026-10-01 17:39:08 -04:00
aj c01a348243 feat(irregular): offer certified Fill blocks for repeated parts 2026-10-01 16:17:25 -04:00
aj e886bcfa35 fix(overlap): retain unchanged pair highlights during rechecks 2026-10-01 15:10:17 -04:00
aj 4fa0f636ce ci: run cross-platform suites on pull requests and master 2026-10-01 13:44:40 -04:00
aj 94e23e4b7a feat(irregular): place best-fit pairs through member free regions 2026-10-01 12:31:38 -04:00
130 changed files with 13988 additions and 658 deletions

No files matched your search

+17
View File
@@ -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
+45
View File
@@ -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
+287
View File
@@ -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
+1 -1
View File
@@ -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." }
} }
+5 -3
View File
@@ -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)
+17 -2
View File
@@ -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();
} }
+1
View File
@@ -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 -1
View File
@@ -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>
+187
View File
@@ -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();
}
}
+58 -13
View File
@@ -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; }
+15
View File
@@ -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; }
}
+50
View File
@@ -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;
}
}
+19
View File
@@ -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,
}
+35
View File
@@ -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,
}; };
} }
+13 -8
View File
@@ -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))
+444
View File
@@ -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));
}
+380
View File
@@ -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>
+53
View File
@@ -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);
}
}
+40 -5
View File
@@ -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"]
+124 -1
View File
@@ -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)
{ {
+96
View File
@@ -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;
}
}
+1 -1
View File
@@ -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>
+29 -3
View File
@@ -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 { }
+13
View File
@@ -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();
}
}
+102 -1
View File
@@ -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();
+73
View File
@@ -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));
} }
+21 -3
View File
@@ -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.");
}
+2 -16
View File
@@ -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();
}
} }
+48
View File
@@ -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();
}
}
+14
View File
@@ -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
+26 -12
View File
@@ -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()
+39 -1
View File
@@ -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();
+4 -2
View File
@@ -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