Author SHA1 Message Date
aj 32db086384 perf(irregular): cap surplus rows in private stripe fills 2026-10-05 09:03:49 -04:00
aj 5f67a51bf1 style(tests): sort imports in fill regression fixtures 2026-10-05 09:03:49 -04:00
aj 20f8b046de fix(cutting): fingerprint only the built-in settings types
The second delta review found four more false-equal classes in the
general reflective fingerprint, all reachable only through custom
settings subclasses: cycle markers that dropped the target ancestor,
display-formatted DateTime/DateTimeOffset, ignored dictionary and set
comparers, and arrays flattened without their dimensions. Safe arrays of
OpenNest elements were also newly refused. Every repair of the generic
traversal opened another such case.

Settings capture now supports exactly the types regeneration already
accepts (OwnedCuttingParameters): CuttingParameters, SequenceParameters,
AssignmentParameters and the built-in lead-in, lead-out and tab types.
Each member is written explicitly, doubles by bit pattern with invariant
numerals and text length-prefixed. Every object's runtime type is checked
before any member is read, so no other type's code runs. A plate-scoped
request whose part or plate settings contain any other type, subclasses
included, is UnsupportedGeometry at capture instead of a Ready plan that
can never apply. A nested settings object replaced by such a type after
capture makes Apply Stale. Detached part-list requests are unaffected.

Coverage tests fail when a supported type gains a property or field the
fingerprint does not write, or when a new built-in lead or tab type is
added without fingerprint support.
2026-10-05 03:16:27 -04:00
aj bb104a07bb fix(cutting): fingerprint settings state without executing foreign code
Delta review found three Important defects and a Minor one in the
settings fingerprint that closed the first freshness gap:

- Accepted settings state was silently omitted: dictionary entries render
  as KeyValuePair structs whose Key/Value are properties, property-backed
  custom structs contribute no public fields, and graphs past the depth
  limit wrote a constant marker, so all three edits fingerprinted equal
  and a changed plate still applied.
- Reading public properties executed arbitrary getters, so a capture
  documented as read-only could mutate live settings (Bump => ++Kerf).
- An enumerable settings member was enumerated at Apply, where its
  enumerator could throw out of the public commit call.
- Fingerprint text used ambient-culture interpolation, so an invariant
  capture compared unequal under a digit-substituting culture.

Traversal is now a closed boundary. OpenNest types render their public
readable properties and fields. Foreign types render only declared
instance fields, which include auto-property backing fields, because a
field read executes no code. Only arrays and List/Dictionary/HashSet/
KeyValuePair are enumerated, with insertion-ordered containers sorted;
other enumerables, delegates and unrepresentable shapes refuse to an
Invalid marker that never compares equal, so refused state is Stale
rather than silently equal. Doubles fingerprint by bit pattern rendered
with invariant formatting, and depth or budget overflow refuses instead
of truncating. A reference already on the path renders as a cycle
marker; built-in lead and tab objects reference settings back.
Capture stores refusals as-is, so a plate with uncaptureable settings
stays plannable and every commit against it reports Stale without
re-reading live state, and a fingerprint that fails on re-read is
likewise Stale, never an exception.
2026-10-05 02:49:23 -04:00
aj ea36c47a30 docs(cutting): describe Apply freshness, installer and nested-part bounds
Document the review hardening: classification, settings content and
malformed programs count as changes, a no-op proposal stays current,
parts repeated across plates are refused, the installer is internal, and
nested-part candidates use material bounds only.
2026-10-05 00:29:47 -04:00
aj b8afcc6fdd perf(cutting): build finished ordinals once per search expansion
The readiness filter rebuilt the finished-part list for every candidate.
Build one set per expanded node; the candidates and their order are
unchanged.
2026-10-05 00:29:47 -04:00
aj 1d8c5fe274 fix(cutting): create plate-scoped requests through a factory
A CuttingPlanRequest constructor overload taking a Plate made the existing
detached call new CuttingPlanRequest(null) ambiguous (CS0121). Plate scope
is now requested with CuttingPlanRequest.ForPlate, and the result summary
describes dependencies and Apply as they now behave.
2026-10-05 00:28:59 -04:00
aj e43a7c99d0 fix(cutting): find nested parts from material bounds
The containment prefilter used the inner part's whole clean-program
bounds, which include rapid endpoints and scribe marks. A remote rapid or
mark could push those bounds outside the host and drop a genuine
inner-before-host prerequisite. Candidate pairs now use the material
extent only (cut and display motions); containment is still proven on
native material.
2026-10-05 00:27:31 -04:00
aj b9b475ab77 fix(cutting): keep plan installation behind the verified service
Review of the atomic Apply found two commit-boundary gaps:

- The plan installer checked only root program references, so a public
  caller could install a payload whose subprograms alias another live
  (even locked) part's program, or share settings between parts. The
  installer and its payload types are now internal; CuttingPlanService.Apply,
  which installs owned copies of independently replayed proposals, is the
  only public path.
- A part placed on two plates in one scope was planned once per plate; one
  plate's install replaced the program the other plate verified as fixed.
  A part repeated anywhere in the scope is now invalid input.
2026-10-05 00:25:43 -04:00
aj d37b38622f fix(cutting): treat classification, settings and malformed programs as stale
Review of the atomic Apply found freshness gaps:

- A drawing's cutoff classification decides lead, material, obstacle and
  dependency treatment but was not captured; changing it after planning
  still applied the old proposal.
- Part and plate cutting settings were compared by reference only, so an
  in-place edit after capture applied (and a regenerated part overwrote
  it); plate settings were not compared at all. Settings are now captured
  as an exact public-state fingerprint.
- A live program whose instruction list was set to null made Apply throw
  instead of returning Stale, and a respelled key in a case-insensitive
  binding dictionary compared equal.

Caller-confirmed planning settings remain planning input: editing them
after capture does not stale the plan and does not leak into the result.
2026-10-05 00:23:48 -04:00
aj 4bbd8454b5 feat(cutting): order cutoffs and nested parts before their hosts
The cutting planner now accepts cutoffs on plate-scoped requests and
plans whole-part prerequisites captured from owned values:

- A cutoff precedes every part its nominal span crosses, using the same
  rule and drawing-reference matching as automatic sequencing; a cutoff
  without a definition precedes every part. Cutoffs stay fixed programs,
  need no lead-in and never become rapid obstacles; rapids into and out
  of them are still checked.
- A part proven, on native clean material, to lie inside a cutout of
  another part precedes that host. Touching or crossing boundaries are
  ambiguous and refuse; a part in a concave pocket has no dependency.
- Both searches only expand ready parts, a preserved order that breaks a
  prerequisite is a constraint conflict, and final replay rechecks the
  captured prerequisites instead of trusting the search.
2026-10-05 00:01:29 -04:00
aj f3bca26fb9 refactor(sequencing): share the cutoff crossing rule with cutting planning
Move the nominal-span cutoff rule unchanged into CuttingDependencyGraph so
automatic sequencing and the cutting planner apply the same dependency.
2026-10-04 23:50:58 -04:00
aj 9c530ca7d2 feat(cutting): apply verified plans atomically
Plate-scoped cutting plan requests now record the plate's exact state at
capture, and CuttingPlanService.Apply installs Ready, replayed proposals
for a whole scope at once:

- Any change after capture (order, pose bits, program reference or
  in-place content, drawing program, lock/lead-in flags, settings,
  quantity, size, quadrant or cutoff definitions) returns Stale with
  nothing changed.
- Order changes without PartAdded/PartRemoved, so drawing quantities and
  sentinel plates are untouched; ObservableList.Reorder exposes the same
  operation and Plate.PartsReordered is raised once per changed plate.
- Regenerated parts receive owned copies of the replayed program and of
  the settings captured with the request; fixed programs stay in place.
- An install failure restores every plate exactly; an observer failure
  after publication is reported as a refresh error, not a rollback.
2026-10-04 23:48:28 -04:00
aj ebc54f0653 refactor(cutting): move owned program copies into Core
Core's commit freshness check needs the same lossless program copy the
planner uses at capture. Move it unchanged apart from its namespace.
2026-10-04 23:47:56 -04:00
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
150 changed files with 16897 additions and 786 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,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>An owned planned program for one unlocked part; ownership passes to the part on Apply.</summary>
internal sealed class PlannedPartProgram
{
public PlannedPartProgram(Part part, Program program, CuttingParameters parameters)
{
Part = part;
Program = program;
Parameters = parameters;
}
public Part Part { get; }
public Program Program { get; }
public CuttingParameters Parameters { get; }
}
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
internal sealed class PlateCuttingPlan
{
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null)
{
Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray());
}
public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; }
}
public enum CuttingCommitStatus
{
Applied,
Stale,
InvalidInput,
Cancelled,
Failed
}
/// <summary>
/// Applied means every plate in scope holds its new state. RefreshErrors are observer failures
/// raised after that consistent state was published; they are not a rollback. Every other
/// status leaves every plate exactly as it was.
/// </summary>
public sealed class CuttingCommitResult
{
internal CuttingCommitResult(CuttingCommitStatus status, string message = null, Plate plate = null,
Exception error = null, IEnumerable<Exception> refreshErrors = null)
{
Status = status;
Message = message;
Plate = plate;
Error = error;
RefreshErrors = Array.AsReadOnly((refreshErrors ?? []).ToArray());
}
public CuttingCommitStatus Status { get; }
public string Message { get; }
public Plate Plate { get; }
public Exception Error { get; }
public IReadOnlyList<Exception> RefreshErrors { get; }
}
/// <summary>
/// Installs verified cutting plans for a whole scope at once. Nothing is searched, emitted,
/// rotated or regenerated here: inputs are validated and checked for freshness, bounds are
/// staged, then order and programs are installed synchronously and published once per plate.
/// Internal: it checks root program references only, so payloads must be owned copies of
/// independently replayed proposals. CuttingPlanService.Apply is the public entry point.
/// </summary>
internal static class CuttingPlanCommit
{
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token = default) =>
Apply(plans, token, null);
// beforeInstall is a test seam that runs inside the install boundary, before each program.
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token,
Action<Plate, Part> beforeInstall)
{
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var scope = plans?.ToArray();
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
return Invalid(null, "A nonempty set of captured plate plans is required.");
var plates = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
foreach (var plan in scope)
if (!plates.Add(plan.Expected.Plate))
return Invalid(plan.Expected.Plate, "A plate appears more than once in the commit scope.");
// Freshness for the whole scope before any validation that reads live geometry.
foreach (var plan in scope)
{
string difference;
try
{
difference = plan.Expected.Difference(token);
}
catch (OperationCanceledException)
{
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
}
if (difference != null)
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
}
var staged = new List<Staged>(scope.Length);
var targets = new HashSet<Part>(ReferenceEqualityComparer.Instance);
var members = new HashSet<Part>(ReferenceEqualityComparer.Instance);
var installed = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var live = new HashSet<Program>(scope.SelectMany(p => p.Expected.Order).Select(p => p.Program),
ReferenceEqualityComparer.Instance);
foreach (var plan in scope)
{
var plate = plan.Expected.Plate;
Part[] order;
try
{
order = plate.Parts.ValidateReorder(plan.Order);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Invalid(plate, "The planned order is not exactly the plate's current parts.");
}
// A part on two plates would let one plate's install change the other's verified program.
if (order.Any(part => !members.Add(part)))
return Invalid(plate, "A part appears on more than one plate in the commit scope.");
var onPlate = new HashSet<Part>(order, ReferenceEqualityComparer.Instance);
var programs = new List<(Part, Program, Box, CuttingParameters)>();
foreach (var planned in plan.Programs)
{
if (planned?.Part == null || !onPlate.Contains(planned.Part) || !targets.Add(planned.Part))
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
if (planned.Part.LeadInsLocked)
return Invalid(plate, "A locked part's program is retained exactly and cannot be replaced.");
if (planned.Program == null || planned.Parameters == null || live.Contains(planned.Program)
|| !installed.Add(planned.Program))
return Invalid(plate, "Planned programs and settings must be present, owned and unshared.");
if (!planned.Program.Codes.Any(code => code is Motion
|| code is SubProgramCall call && call.Program?.Codes.Any(sub => sub is Motion) == true))
return Invalid(plate, "A planned program has no motion.");
Box bounds;
try
{
bounds = planned.Program.BoundingBox();
bounds.Offset(planned.Part.Location);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Invalid(plate, "A planned program's bounds cannot be computed.");
}
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
}
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
}
// Last cancellation point. The boundary below has no await, search or geometry work.
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
try
{
foreach (var item in staged)
{
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
item.Plate.Parts.SetOrder(item.Order);
foreach (var (part, program, bounds, parameters) in item.Programs)
{
beforeInstall?.Invoke(item.Plate, part);
part.InstallPlannedProgram(program, bounds, parameters);
}
}
}
catch (Exception ex)
{
for (var i = undo.Count - 1; i >= 0; i--)
{
foreach (var (part, state) in undo[i].States)
part.RestoreCuttingState(state);
undo[i].Plate.Parts.SetOrder(undo[i].Order);
}
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
undo.Count == 0 ? null : undo[^1].Plate, ex);
}
// Publish only after the whole scope is consistent.
var errors = new List<Exception>();
foreach (var item in staged.Where(s => s.Changed))
item.Plate.Parts.RaiseItemsReordered(errors);
return new(CuttingCommitStatus.Applied, errors.Count == 0 ? null
: "Applied; one or more views failed to refresh.", refreshErrors: errors);
}
private static CuttingCommitResult Invalid(Plate plate, string message) =>
new(CuttingCommitStatus.InvalidInput, message, plate);
private sealed record Staged(Plate Plate, Part[] Order,
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
}
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Guarded read of stable placed programs; never clones or mutates their graphs.</summary>
public static class ExecutionMotionReader
{
public static OwnedExecution Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
=> ReadCore(program, origin, previous, token, false);
/// <summary>Read only exact built-in runtime types and defined program modes.
/// Validate the original graph before cloning: Clone can erase unsupported subclasses.
/// Inputs must remain stable during this bounded, cancellation-aware read.</summary>
public static OwnedExecution ReadSupported(Program program, Vector origin, Vector? previous,
CancellationToken token = default)
=> ReadCore(program, origin, previous, token, true);
private static OwnedExecution ReadCore(Program program, Vector origin, Vector? previous,
CancellationToken token, bool requireSupportedTypes)
{
var moves = new List<ExecutionMotion>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return new OwnedExecution(moves);
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
if (requireSupportedTypes && (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode)))
throw new NotSupportedException("Unsupported program runtime type or mode.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (requireSupportedTypes)
{
var type = code.GetType();
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
throw new NotSupportedException("Unsupported instruction runtime type.");
}
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
}
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Owned nominal material: one simple perimeter minus disjoint simple holes.</summary>
public sealed class LeadMaterialSnapshot
{
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, string reason)
{
Rings = rings;
Reason = reason;
}
public bool IsComplete => Reason == null;
public string Reason { get; }
internal IReadOnlyList<PostVerificationGeometry.Curve[]> Rings { get; }
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
CancellationToken token = default)
{
try
{
var execution = ExecutionMotionReader.ReadSupported(cleanProgram, location, null, token);
var rings = new List<PostVerificationGeometry.Curve[]>();
var chain = new List<PostVerificationGeometry.Curve>();
foreach (var motion in execution.Motions)
{
token.ThrowIfCancellationRequested();
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout)
{
Finish();
continue;
}
if (motion.Layer is not (LayerType.Cut or LayerType.Display))
throw new ArgumentException("Material capture contains an unsupported layer.");
var curve = motion.Curve;
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contains a degenerate motion.");
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve);
if (PostVerificationGeometry.Closed(chain))
Finish();
}
Finish();
if (rings.Count == 0)
throw new ArgumentException("Material has no closed contour.");
var budget = 1000000;
// Certify simple rings and mutually disjoint boundaries before containment.
for (var r = 0; r < rings.Count; r++)
for (var s = r; s < rings.Count; s++)
for (var i = 0; i < rings[r].Length; i++)
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Material validation exceeds the native query limit.");
var a = rings[r][i];
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon)
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon)))
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
var outer = -1;
for (var i = 0; i < rings.Count; i++)
{
token.ThrowIfCancellationRequested();
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
{
if (outer >= 0)
throw new ArgumentException("Material perimeter is ambiguous.");
outer = i;
}
}
if (outer < 0)
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
var holes = rings.Where((_, i) => i != outer).ToArray();
for (var i = 0; i < holes.Length; i++)
for (var j = i + 1; j < holes.Length; j++)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), null);
void Finish()
{
if (chain.Count == 0)
return;
if (!PostVerificationGeometry.Closed(chain))
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
rings.Add(chain.ToArray());
chain.Clear();
}
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), ex.Message);
}
}
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
{
var inside = false;
foreach (var curve in ring)
{
token.ThrowIfCancellationRequested();
if (curve.CrossesRay(point))
inside = !inside;
}
return inside;
}
}
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>A complete unsafe result is distinct from an incomplete/unsupported check.</summary>
public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, string Reason);
/// <summary>Certifies actual emitted native lead paths against owned nominal material.</summary>
public static class LeadPathValidator
{
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (execution == null || target == null || otherMaterials == null)
return new(false, false, "Missing execution or material snapshots.");
if (!target.IsComplete || otherMaterials.Any(m => m == null || !m.IsComplete))
return new(false, false, "Material snapshot is incomplete.");
try
{
var budget = 1000000;
for (var i = 0; i < execution.Motions.Count; i++)
{
token.ThrowIfCancellationRequested();
var move = execution.Motions[i];
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
continue;
if (move.Rapid || move.Start is not { } start || move.Curve == null
|| !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon
|| start.DistanceTo(move.Curve.Start) > PostVerificationGeometry.Epsilon
|| move.End.DistanceTo(move.Curve.End) > PostVerificationGeometry.Epsilon)
return new(false, false, "Lead motion is missing, degenerate or inconsistent.");
Vector? allowed = null;
var groupEdge = i;
var step = move.Layer == LayerType.Leadin ? 1 : -1;
while (groupEdge + step >= 0 && groupEdge + step < execution.Motions.Count
&& execution.Motions[groupEdge + step].Layer == move.Layer)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
groupEdge += step;
}
var adjacentIndex = groupEdge + step;
var genuineJoint = false;
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
{
var adjacent = execution.Motions[adjacentIndex];
if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
{
var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
foreach (var boundary in target.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
if (joint.DistanceTo(contourJoint) <= PostVerificationGeometry.Epsilon
&& boundary.SameSupport(adjacent.Curve)
&& boundary.Contains(adjacent.Curve.Start) && boundary.Contains(adjacent.End)
&& boundary.Contains(adjacent.Curve.Midpoint)
&& adjacent.Length <= boundary.Length + PostVerificationGeometry.Epsilon)
{
genuineJoint = true;
if (groupEdge == i)
allowed = joint;
break;
}
}
}
}
if (!genuineJoint)
return new(true, false, "Lead chain has no genuine adjacent target contour entry or exit.");
if (allowed is { } jointPoint
&& (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon)
return new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only.");
var failure = CheckMaterial(target, allowed);
if (failure != null)
return new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure}).");
foreach (var material in otherMaterials)
{
token.ThrowIfCancellationRequested();
if (ReferenceEquals(material, target))
continue;
if (CheckMaterial(material, null) != null)
return new(true, false, "Lead contacts or enters another placed material.");
}
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
{
foreach (var boundary in material.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = move.Curve.Contacts(boundary, out var overlap);
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
}
// With all other boundary contacts excluded, the connected open path
// has constant material membership. Use the native arc midpoint, not
// the chord midpoint or endpoints (which can both lie in scrap).
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
}
}
// Missing leads are the ReleasedContourState check's responsibility.
return new(true, true, null);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return new(false, false, ex.Message);
}
}
}
@@ -0,0 +1,139 @@
using System;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Copies only supported, exact setting types; never slices custom subclasses.</summary>
internal static class OwnedCuttingParameters
{
internal static CuttingParameters Copy(CuttingParameters source)
{
Exact<CuttingParameters>(source);
Exact<SequenceParameters>(source.Sequencing);
Exact<AssignmentParameters>(source.Assignment);
var sequence = source.Sequencing;
var assignment = source.Assignment;
if (!Enum.IsDefined(sequence.Method) || !Enum.IsDefined(assignment.Method))
throw new ArgumentException("Unsupported sequence method.");
if (assignment.Preference == null)
throw new ArgumentException("Missing assignment preference.");
if (!double.IsFinite(source.LeadInAngleIncrement) || source.LeadInAngleIncrement <= 0
|| source.LeadInAngleIncrement > 360)
throw new ArgumentException("Angle increment must be in (0, 360] degrees.");
return new CuttingParameters
{
Id = source.Id,
MachineName = source.MachineName,
MaterialName = source.MaterialName,
Grade = source.Grade,
Thickness = Dimension(source.Thickness),
Kerf = Dimension(source.Kerf),
PartSpacing = Dimension(source.PartSpacing),
PierceClearance = Dimension(source.PierceClearance),
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
RoundLeadInAngles = source.RoundLeadInAngles,
LeadInAngleIncrement = source.LeadInAngleIncrement,
AutoTabMinSize = Dimension(source.AutoTabMinSize),
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
TabConfig = source.TabConfig == null
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
: CopyTab(source.TabConfig),
TabsEnabled = source.TabsEnabled,
Sequencing = new SequenceParameters
{
Method = sequence.Method,
SmallCutoutWidth = Dimension(sequence.SmallCutoutWidth),
SmallCutoutHeight = Dimension(sequence.SmallCutoutHeight),
MediumCutoutWidth = Dimension(sequence.MediumCutoutWidth),
MediumCutoutHeight = Dimension(sequence.MediumCutoutHeight),
DistanceMediumSmall = Dimension(sequence.DistanceMediumSmall),
AlternateRowsColumns = sequence.AlternateRowsColumns,
AlternateCutoutsWithinRowColumn = sequence.AlternateCutoutsWithinRowColumn,
MinDistanceBetweenRowsColumns = Dimension(sequence.MinDistanceBetweenRowsColumns)
},
Assignment = new AssignmentParameters
{
Method = assignment.Method,
Preference = assignment.Preference,
MinGeometryLength = Dimension(assignment.MinGeometryLength)
}
};
}
private static LeadIn CopyLeadIn(LeadIn source) => source switch
{
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
{
Length1 = Dimension(n.Length1),
Length2 = Dimension(n.Length2),
ApproachAngle1 = Angle(n.ApproachAngle1),
ApproachAngle2 = Angle(n.ApproachAngle2)
},
CleanHoleLeadIn n when n.GetType() == typeof(CleanHoleLeadIn) => new CleanHoleLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), Kerf = Dimension(n.Kerf) },
null => throw new ArgumentException("Missing lead-in settings."),
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
};
private static LeadOut CopyLeadOut(LeadOut source) => source switch
{
NoLeadOut n when n.GetType() == typeof(NoLeadOut) => new NoLeadOut(),
LineLeadOut n when n.GetType() == typeof(LineLeadOut) => new LineLeadOut
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadOut n when n.GetType() == typeof(ArcLeadOut) => new ArcLeadOut { Radius = Dimension(n.Radius) },
null => throw new ArgumentException("Missing lead-out settings."),
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
};
private static Tab CopyTab(Tab source)
{
Tab copy = source switch
{
NormalTab n when n.GetType() == typeof(NormalTab) => new NormalTab
{
CutoutMinWidth = Dimension(n.CutoutMinWidth),
CutoutMinHeight = Dimension(n.CutoutMinHeight),
CutoutMaxWidth = Dimension(n.CutoutMaxWidth),
CutoutMaxHeight = Dimension(n.CutoutMaxHeight)
},
BreakerTab n when n.GetType() == typeof(BreakerTab) => new BreakerTab
{
BreakerDepth = Dimension(n.BreakerDepth),
BreakerLeadInLength = Dimension(n.BreakerLeadInLength),
BreakerAngle = Angle(n.BreakerAngle)
},
MachineTab n when n.GetType() == typeof(MachineTab) => new MachineTab { MachineTabId = n.MachineTabId },
_ => throw new NotSupportedException("Unsupported tab runtime type.")
};
copy.Size = Dimension(source.Size);
copy.TabLeadIn = source.TabLeadIn == null ? null : CopyLeadIn(source.TabLeadIn);
copy.TabLeadOut = source.TabLeadOut == null ? null : CopyLeadOut(source.TabLeadOut);
return copy;
}
private static void Exact<T>(T value) where T : class
{
if (value == null)
throw new ArgumentException("Missing cutting settings.");
if (value.GetType() != typeof(T))
throw new NotSupportedException("Unsupported settings runtime type.");
}
private static double Dimension(double value) => double.IsFinite(value) && value >= 0
? value : throw new ArgumentException("Dimensions must be finite and nonnegative.");
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
private static double Angle(double value) => double.IsFinite(value)
? value : throw new ArgumentException("Angles must be finite.");
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Owned value motions; no references to live programs, instructions or settings.</summary>
public sealed class OwnedExecution
{
internal OwnedExecution(IEnumerable<ExecutionMotion> motions)
{
Motions = Array.AsReadOnly(motions.ToArray());
}
public IReadOnlyList<ExecutionMotion> Motions { get; }
public Vector DeparturePoint => Motions[^1].End;
public bool HasCuttingContour => Motions.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Length > PostVerificationGeometry.Epsilon);
public double RapidDistanceFrom(Vector arrival)
{
PostVerificationGeometry.Validate(arrival);
var total = 0.0;
for (var index = 0; index < Motions.Count; index++)
{
var move = Motions[index];
if (move.Rapid && (index == 0 ? arrival : move.Start) is { } start)
total += start.DistanceTo(move.End);
}
return total;
}
}
/// <summary>An immutable motion value; native line/arc geometry stays private to Core.</summary>
public sealed class ExecutionMotion
{
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve)
{
Start = start;
End = end;
Rapid = rapid;
Layer = layer;
Curve = curve;
}
public Vector? Start { get; }
public Vector End { get; }
public bool Rapid { get; }
public LayerType Layer { get; }
public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
}
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Lossless owned copies at the cutting-plan boundary, not a general Clone change.</summary>
internal static class OwnedProgramCopy
{
private const int Limit = 1000000;
private const int MaxDepth = 64;
internal static Program Copy(Program original, CancellationToken token = default)
{
Validate(original, token);
// Built-in Clone preserves mode/rotation and binds calls without rotating setters.
// Its per-parent maps can duplicate diamonds; restore one global owned graph below.
var cloned = (Program)original.Clone();
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
var bindings = new Dictionary<Dictionary<string, string>, Dictionary<string, string>>(ReferenceEqualityComparer.Instance);
return Restore(original, cloned);
Program Restore(Program source, Program copy)
{
token.ThrowIfCancellationRequested();
if (programs.TryGetValue(source, out var existing)) return existing;
programs.Add(source, copy);
for (var i = 0; i < source.Codes.Count; i++)
{
token.ThrowIfCancellationRequested();
var authored = source.Codes[i];
if (codes.TryGetValue(authored, out var owned))
{
copy.Codes[i] = owned;
continue;
}
owned = copy.Codes[i];
codes.Add(authored, owned);
if (authored is Motion motion)
{
var target = (Motion)owned;
// All other built-in fields are retained by their Clone implementations.
target.UseExactStop = motion.UseExactStop;
target.Feedrate = motion.Feedrate;
if (motion.VariableRefs != null)
{
if (!bindings.TryGetValue(motion.VariableRefs, out var refs))
{
refs = new Dictionary<string, string>(motion.VariableRefs, motion.VariableRefs.Comparer);
bindings.Add(motion.VariableRefs, refs);
}
target.VariableRefs = refs;
}
}
if (authored is SubProgramCall call)
{
var target = (SubProgramCall)owned;
target.BindProgram(Restore(call.Program, target.Program));
}
}
foreach (var (id, child) in source.SubPrograms)
{
token.ThrowIfCancellationRequested();
copy.SubPrograms[id] = Restore(child, copy.SubPrograms[id]);
}
return copy;
}
}
// Unlike the execution reader, inactive registered graphs can be motionless or
// contain suppressed motions. They still must be supported, acyclic and bounded.
internal static void Validate(Program program, CancellationToken token)
{
var active = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var done = new Dictionary<Program, (int Cost, int Depth)>(ReferenceEqualityComparer.Instance);
var budget = Limit;
Visit(program);
(int Cost, int Depth) Visit(Program current)
{
token.ThrowIfCancellationRequested();
if (current == null || active.Contains(current) || active.Count >= MaxDepth)
throw new ArgumentException("Missing, recursive or excessively nested clone graph.");
if (done.TryGetValue(current, out var previous))
{
if (active.Count + previous.Depth > MaxDepth)
throw new ArgumentException("Excessively nested clone graph.");
return previous;
}
if (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode))
throw new NotSupportedException("Unsupported program runtime type or mode.");
if (current.Codes == null)
throw new ArgumentException("Missing clone graph instructions.");
active.Add(current);
var children = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var cost = 1;
var depth = 1;
foreach (var code in current.Codes)
{
Step();
cost++;
if (code == null)
throw new ArgumentException("Missing clone graph instruction.");
var type = code.GetType();
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
throw new NotSupportedException("Unsupported instruction runtime type.");
if (code is SubProgramCall call) Child(call.Program);
}
foreach (var child in current.SubPrograms.Values)
{
Step();
Child(child);
}
if (cost > Limit)
throw new ArgumentException("Clone graph exceeds the verification limit.");
active.Remove(current);
var result = (cost, depth);
done.Add(current, result);
return result;
void Child(Program child)
{
var summary = Visit(child);
// Program.Clone shares children only within one parent; bound its real
// expanded work as well as the distinct original graph before calling it.
if (!children.Add(child)) return;
if (summary.Cost > Limit - cost)
throw new ArgumentException("Clone expansion exceeds the verification limit.");
cost += summary.Cost;
depth = System.Math.Max(depth, summary.Depth + 1);
}
}
void Step()
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Clone graph exceeds the verification limit.");
}
}
}
@@ -0,0 +1,9 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Every Part field a cutting commit can change, held by reference for exact rollback.</summary>
internal sealed record PartCuttingState(Program Program, bool OwnsProgram, double PreLeadInRotation,
bool HasManualLeadIns, bool LeadInsLocked, CuttingParameters CuttingParameters, Vector Location,
Box BoundingBox);
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact caller-thread record of everything a cutting proposal for one plate depends on:
/// part list instance and order, plate quantity/size/quadrant/settings, cutoff definitions, and
/// each part's complete cutting state with owned copies of its placed and drawing programs,
/// drawing cutoff classification and exact settings content.
/// A commit compares it with the live plate and refuses when anything differs.
/// </summary>
public sealed class PlateCuttingState
{
private readonly ObservableList<Part> partList;
private readonly PartRecord[] parts;
private readonly int quantity;
private readonly Size size;
private readonly int quadrant;
private readonly ObservableList<CutOff> cutOffList;
private readonly CutOffRecord[] cutOffs;
private readonly Dictionary<Drawing, (Program Program, Program Copy, bool IsCutOff)> drawings;
private readonly Dictionary<CuttingParameters, string> settings;
private readonly CuttingParameters plateSettings;
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
Dictionary<Drawing, (Program, Program, bool)> drawings, Dictionary<CuttingParameters, string> settings)
{
this.settings = settings;
plateSettings = plate.CuttingParameters;
Plate = plate;
partList = plate.Parts;
this.parts = parts;
quantity = plate.Quantity;
size = plate.Size;
quadrant = plate.Quadrant;
cutOffList = plate.CutOffs;
this.cutOffs = cutOffs;
this.drawings = drawings;
Order = Array.AsReadOnly(parts.Select(p => p.Part).ToArray());
}
public Plate Plate { get; }
/// <summary>Captured part order (reference identity).</summary>
public IReadOnlyList<Part> Order { get; }
/// <summary>
/// Captures on the caller thread. Unsupported or malformed programs throw
/// <see cref="ArgumentException"/> or <see cref="NotSupportedException"/>; so do part or
/// plate settings that are not exact built-in settings types, whose state cannot be
/// captured exactly.
/// </summary>
public static PlateCuttingState Capture(Plate plate, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(plate);
if (plate.Parts == null || plate.CutOffs == null)
throw new ArgumentException("Plate part and cutoff lists are required.");
var drawings = new Dictionary<Drawing, (Program, Program, bool)>(ReferenceEqualityComparer.Instance);
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
Fingerprint(plate.CuttingParameters);
var records = new List<PartRecord>(plate.Parts.Count);
foreach (var part in plate.Parts)
{
token.ThrowIfCancellationRequested();
if (part?.BaseDrawing == null || part.Program == null)
throw new ArgumentException("Plate contains a missing part, drawing or program.");
var drawing = part.BaseDrawing;
if (!drawings.ContainsKey(drawing))
drawings.Add(drawing, (drawing.Program, drawing.Program == null ? null
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff));
var state = part.CaptureCuttingState();
Fingerprint(state.CuttingParameters);
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox)));
}
var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters)
{
if (parameters != null && !settings.ContainsKey(parameters))
settings.Add(parameters, StateFingerprint.Of(parameters));
}
}
/// <summary>True when the live plate still has exactly the captured state.</summary>
public bool IsCurrent(CancellationToken token = default) => Difference(token) == null;
/// <summary>Null when current; otherwise the first observed difference.</summary>
public string Difference(CancellationToken token = default)
{
var checkedSettings = new Dictionary<CuttingParameters, bool>(ReferenceEqualityComparer.Instance);
var plate = Plate;
if (!ReferenceEquals(plate.Parts, partList) || !ReferenceEquals(plate.CutOffs, cutOffList))
return "The plate's part or cutoff list was replaced.";
if (plate.Quantity != quantity || !Bits(plate.Size.Width, size.Width)
|| !Bits(plate.Size.Length, size.Length) || plate.Quadrant != quadrant)
return "Plate quantity, size or quadrant changed.";
if (!ReferenceEquals(plate.CuttingParameters, plateSettings) || !SameSettings(plateSettings))
return "Plate cutting settings changed.";
if (plate.Parts.Count != parts.Length)
return "Parts were added or removed.";
for (var i = 0; i < parts.Length; i++)
{
token.ThrowIfCancellationRequested();
var record = parts[i];
var part = plate.Parts[i];
if (!ReferenceEquals(part, record.Part))
return $"Part order changed at position {i + 1}.";
var live = part.CaptureCuttingState();
var was = record.State;
if (!ReferenceEquals(live.Program, was.Program) || live.OwnsProgram != was.OwnsProgram
|| !ReferenceEquals(live.CuttingParameters, was.CuttingParameters)
|| !ReferenceEquals(live.BoundingBox, was.BoundingBox))
return $"Part {i + 1} program, settings or bounds were replaced.";
if (!Bits(live.Location.X, was.Location.X) || !Bits(live.Location.Y, was.Location.Y)
|| !Bits(live.PreLeadInRotation, was.PreLeadInRotation)
|| live.HasManualLeadIns != was.HasManualLeadIns || live.LeadInsLocked != was.LeadInsLocked)
return $"Part {i + 1} pose, lead-in or lock state changed.";
if (!BoxValues(live.BoundingBox).SequenceEqual(record.Bounds))
return $"Part {i + 1} bounds changed.";
if (!ProgramContent.Equal(part.Program, record.Program, token))
return $"Part {i + 1} program was edited in place.";
if (!SameSettings(live.CuttingParameters))
return $"Part {i + 1} cutting settings were edited in place.";
var (drawingProgram, drawingCopy, isCutOff) = drawings[part.BaseDrawing];
if (!ReferenceEquals(part.BaseDrawing.Program, drawingProgram)
|| !ProgramContent.Equal(drawingProgram, drawingCopy, token))
return $"Part {i + 1} drawing program changed.";
if (part.BaseDrawing.IsCutOff != isCutOff)
return $"Part {i + 1} cutoff classification changed.";
}
if (plate.CutOffs.Count != cutOffs.Length)
return "Cutoffs were added or removed.";
for (var i = 0; i < cutOffs.Length; i++)
{
var record = cutOffs[i];
var cutOff = plate.CutOffs[i];
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
return $"Cutoff {i + 1} definition changed.";
}
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
// all compared exactly above.
return null;
// References were compared already; this catches in-place edits of the same object.
// Each distinct settings object is fingerprinted at most once per check. A nested
// settings object replaced since capture by an unsupported type cannot equal the
// captured built-in state: Stale, not an exception.
bool SameSettings(CuttingParameters parameters)
{
if (parameters == null)
return true;
if (checkedSettings.TryGetValue(parameters, out var known))
return known;
bool current;
try
{
current = settings.TryGetValue(parameters, out var captured)
&& captured == StateFingerprint.Of(parameters);
}
catch (NotSupportedException)
{
current = false;
}
checkedSettings[parameters] = current;
return current;
}
}
private static long[] BoxValues(Box box) => box == null ? [] :
[BitConverter.DoubleToInt64Bits(box.X), BitConverter.DoubleToInt64Bits(box.Y),
BitConverter.DoubleToInt64Bits(box.Width), BitConverter.DoubleToInt64Bits(box.Length)];
private static bool Bits(double a, double b) =>
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
private static bool Bits(double? a, double? b) => a.HasValue == b.HasValue
&& (!a.HasValue || Bits(a.Value, b.Value));
private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit);
}
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>A nominal native entry owned by one preparation, not an actual emitted pierce.</summary>
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
{
internal PreparedContours Owner { get; init; }
}
/// <summary>
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
/// </summary>
public sealed class PreparedContours
{
private readonly Shape[] shapes;
private readonly CuttingParameters parameters;
private readonly List<Entity> scribes;
private PreparedContours(Shape[] shapes, List<Entity> scribes, CuttingParameters parameters)
{
this.shapes = shapes;
this.scribes = scribes;
this.parameters = parameters;
}
public int Count => shapes.Length;
public int PerimeterOrdinal => Count - 1;
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
ExecutionMotionReader.ReadSupported(program, Vector.Zero, null, token);
// ToGeometry constructs fresh native entities. Normalize an owned motion graph
// to incremental mode so absolute subprograms retain their frame offsets too.
// Retain execution boundaries: ShapeBuilder can join duplicate closed contours.
var geometryProgram = CopyForGeometry(program, token);
var contours = new List<Shape>();
var scribes = new List<Entity>();
var current = new Shape();
foreach (var entity in geometryProgram.ToGeometry())
{
token.ThrowIfCancellationRequested();
if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle))
throw new NotSupportedException("Unsupported native geometry.");
if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Preparation requires closed execution contours.");
if (entity.Layer == SpecialLayers.Scribe)
scribes.Add(entity);
continue;
}
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display)
throw new ArgumentException("Preparation requires clean cut/display contours.");
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate contour entity.");
if (entity is Circle)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Circle interrupts an open contour.");
current.Entities.Add(entity);
FinishContour();
continue;
}
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Discontinuous native contour.");
current.Entities.Add(entity);
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
FinishContour();
}
if (current.Entities.Count != 0 || contours.Count == 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
var perimeter = 0;
for (var i = 1; i < contours.Count; i++)
{
var box = contours[i].BoundingBox;
var best = contours[perimeter].BoundingBox;
if (box.Width * box.Length > best.Width * best.Length)
perimeter = i;
}
var outer = contours[perimeter];
contours.RemoveAt(perimeter);
contours.Add(outer);
return new(contours.ToArray(), scribes, ownedParameters);
void FinishContour()
{
current.UpdateBounds();
contours.Add(current);
current = new Shape();
}
}
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
{
PostVerificationGeometry.Validate(approach);
var shape = GetShape(contourOrdinal);
var point = shape.ClosestPointTo(approach, out var entity);
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
}
/// <summary>
/// Closest native point first, then source-order vertices/midpoints or eight circle
/// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
/// Emit before validating actual paths: circle rounding/clamping may move this point.
/// </summary>
public IReadOnlyList<ContourChoice> Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (maxEntries <= 0)
throw new ArgumentOutOfRangeException(nameof(maxEntries));
var shape = GetShape(contourOrdinal);
var entries = new List<ContourChoice>();
entries.Add(ClosestEntry(contourOrdinal, approach));
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
{
token.ThrowIfCancellationRequested();
var entity = shape.Entities[i];
if (entity is Circle circle)
{
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius);
}
else
{
Add(i, Start(entity));
Add(i, entity is Line line ? line.MidPoint : ((Arc)entity).MidPoint());
Add(i, End(entity));
}
}
token.ThrowIfCancellationRequested();
return entries.AsReadOnly();
void Add(int entityOrdinal, Vector point)
{
token.ThrowIfCancellationRequested();
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
}
}
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
ValidateChoice(choice);
return choice;
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
public Program Emit(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, true);
return EmitPrefix(choices);
}
// Each prefix is a standalone owned program, including the same scribes once.
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, false);
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
}
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
// Build expected-emission metadata independently of the selected payload BEFORE
// replay. Final verification only reads this immutable execution and clean geometry.
internal SelectedContourProgram CaptureSelectedProgram(IReadOnlyList<ContourChoice> choices,
Vector location, CancellationToken token)
{
token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
}
private void Validate(IReadOnlyList<ContourChoice> choices, bool complete)
{
if (choices == null || (complete && choices.Count != Count) || choices.Count > Count)
throw new ArgumentException("Every contour must be represented exactly once.");
var visited = new HashSet<int>();
foreach (var choice in choices)
{
ValidateChoice(choice);
if (!visited.Add(choice.ContourOrdinal) || choice.ContourOrdinal == PerimeterOrdinal && visited.Count != Count)
throw new ArgumentException("Duplicate contour or perimeter before its internal contours.");
}
}
private void ValidateChoice(ContourChoice choice)
{
if (choice == null || !ReferenceEquals(choice.Owner, this))
throw new ArgumentException("Foreign contour choice.");
PostVerificationGeometry.Validate(choice.Point);
var shape = GetShape(choice.ContourOrdinal);
if (choice.EntityOrdinal < 0 || choice.EntityOrdinal >= shape.Entities.Count)
throw new ArgumentException("Foreign entity ordinal.");
var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Entry is not on the selected native entity.");
}
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
private static Program CopyForGeometry(Program source, CancellationToken token)
{
var copies = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
return Copy(source);
Program Copy(Program current)
{
token.ThrowIfCancellationRequested();
if (copies.TryGetValue(current, out var existing))
return existing;
var copy = new Program(current.Mode);
copies.Add(current, copy);
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
var ownedCode = code.Clone();
if (ownedCode is SubProgramCall call)
call.BindProgram(Copy(((SubProgramCall)code).Program));
copy.Codes.Add(ownedCode);
}
copy.Mode = Mode.Incremental;
return copy;
}
}
}
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact structural equality of two program graphs: instruction runtime types, scalar bits,
/// motion metadata, variables and the same instruction/sub-program sharing shape. This is
/// a freshness check, not geometric equivalence; unsupported instruction types never match.
/// </summary>
internal static class ProgramContent
{
internal static bool Equal(Program left, Program right, CancellationToken token = default)
{
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
var programOwners = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
var codeOwners = new HashSet<ICode>(ReferenceEqualityComparer.Instance);
return SameProgram(left, right);
bool SameProgram(Program a, Program b)
{
token.ThrowIfCancellationRequested();
if (a == null || b == null)
return a == null && b == null;
// Pair graphs one-to-one so shared and distinct sub-programs cannot be confused.
if (programs.TryGetValue(a, out var paired))
return ReferenceEquals(paired, b);
if (!programOwners.Add(b))
return false;
programs.Add(a, b);
// Codes is a writable field: a missing list is a difference, never an exception.
if (a.Codes == null || b.Codes == null)
return a.Codes == null && b.Codes == null && a.GetType() == b.GetType();
if (a.GetType() != b.GetType() || a.Mode != b.Mode || !Bits(a.Rotation, b.Rotation)
|| a.Codes.Count != b.Codes.Count || a.SubPrograms.Count != b.SubPrograms.Count
|| !SameVariables(a.Variables, b.Variables))
return false;
for (var i = 0; i < a.Codes.Count; i++)
if (!SameCode(a.Codes[i], b.Codes[i]))
return false;
foreach (var (id, child) in a.SubPrograms)
if (!b.SubPrograms.TryGetValue(id, out var other) || !SameProgram(child, other))
return false;
return true;
}
bool SameCode(ICode a, ICode b)
{
token.ThrowIfCancellationRequested();
if (a == null || b == null)
return a == null && b == null;
if (codes.TryGetValue(a, out var paired))
return ReferenceEquals(paired, b);
if (!codeOwners.Add(b))
return false;
codes.Add(a, b);
if (a.GetType() != b.GetType())
return false;
return (a, b) switch
{
(RapidMove x, RapidMove y) => SameMotion(x, y),
(LinearMove x, LinearMove y) => SameMotion(x, y) && x.Layer == y.Layer,
(ArcMove x, ArcMove y) => SameMotion(x, y) && x.Layer == y.Layer
&& x.Rotation == y.Rotation && Bits(x.CenterPoint, y.CenterPoint),
(SubProgramCall x, SubProgramCall y) => x.Id == y.Id && Bits(x.Offset, y.Offset)
&& Bits(x.Rotation, y.Rotation) && SameProgram(x.Program, y.Program),
(Comment x, Comment y) => string.Equals(x.Value, y.Value, StringComparison.Ordinal),
(Feedrate x, Feedrate y) => Bits(x.Value, y.Value)
&& string.Equals(x.VariableRef, y.VariableRef, StringComparison.Ordinal),
(Kerf x, Kerf y) => x.Value == y.Value,
_ => false
};
}
}
private static bool SameMotion(Motion a, Motion b) => Bits(a.EndPoint, b.EndPoint)
&& a.UseExactStop == b.UseExactStop && a.Feedrate == b.Feedrate && a.Suppressed == b.Suppressed
&& SameRefs(a.VariableRefs, b.VariableRefs);
private static bool SameRefs(Dictionary<string, string> a, Dictionary<string, string> b)
{
if (a == null || b == null)
return a == null && b == null;
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
return false;
foreach (var (key, value) in a)
if (!b.TryGetValue(key, out var other) || !string.Equals(value, other, StringComparison.Ordinal))
return false;
return true;
}
// A case-insensitive dictionary finds a respelled key; authored spelling must still match.
private static bool SameSpelling(IEnumerable<string> a, IEnumerable<string> b) =>
new HashSet<string>(a, StringComparer.Ordinal).SetEquals(b);
private static bool SameVariables(Dictionary<string, VariableDefinition> a, Dictionary<string, VariableDefinition> b)
{
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
return false;
foreach (var (key, value) in a)
{
if (!b.TryGetValue(key, out var other))
return false;
if (ReferenceEquals(value, other))
continue;
if (value == null || other == null
|| !string.Equals(value.Name, other.Name, StringComparison.Ordinal)
|| !string.Equals(value.Expression, other.Expression, StringComparison.Ordinal)
|| !Bits(value.Value, other.Value) || value.Inline != other.Inline || value.Global != other.Global)
return false;
}
return true;
}
private static bool Bits(Vector a, Vector b) => Bits(a.X, b.X) && Bits(a.Y, b.Y);
private static bool Bits(double a, double b) =>
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
}
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Direct XY completed-contour checker. This is not physical machine safety.</summary>
public sealed class ReleasedContourState
{
private readonly List<Obstacle> obstacles = new();
public ReleasedContourState Copy()
{
var copy = new ReleasedContourState();
copy.obstacles.AddRange(obstacles);
return copy;
}
/// <summary>Consumes one whole owned program; part numbers are caller identity keys.</summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, CancellationToken token = default) => Check(execution, arrival, partNumber, false, token);
/// <summary>
/// As the analyzer does for cutoffs: rapids are checked, but cutoff cuts need no lead-in and,
/// being open, never become obstacles.
/// </summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, bool cutoff, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(execution);
if (arrival is { } point)
PostVerificationGeometry.Validate(point);
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
return findings.AsReadOnly();
}
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
var hasLead = false;
var contourNumber = 0;
foreach (var move in moves)
{
token.ThrowIfCancellationRequested();
if (move.Rapid)
{
Finish();
hasLead = false;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
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);
}
@@ -0,0 +1,216 @@
using System;
using System.Globalization;
using System.Text;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact text record of cutting settings for detecting in-place edits after capture. It is a
/// freshness fingerprint, not a serializer.
/// Only the exact built-in settings types are supported — the same set
/// <see cref="OwnedCuttingParameters"/> copies: <see cref="CuttingParameters"/>,
/// <see cref="SequenceParameters"/>, <see cref="AssignmentParameters"/> and the built-in
/// lead-in, lead-out and tab types. Every member is written explicitly by type, so nothing is
/// discovered reflectively and no code of another type ever runs: each object's runtime type
/// is checked before any of its members is read, and any other runtime type (a subclass
/// included) throws <see cref="NotSupportedException"/>. Doubles are written by bit pattern
/// and every number is formatted invariantly, so the text is exact and culture-free.
/// </summary>
internal static class StateFingerprint
{
/// <summary>Fingerprint of <paramref name="settings"/>; null renders as a marker.</summary>
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
internal static string Of(CuttingParameters settings)
{
var text = new StringBuilder();
Settings(settings);
return text.ToString();
void Settings(CuttingParameters p)
{
if (!Begin(p, typeof(CuttingParameters)))
return;
Int(p.Id);
Text(p.MachineName);
Text(p.MaterialName);
Text(p.Grade);
Number(p.Thickness);
Number(p.Kerf);
Number(p.PartSpacing);
Lead(p.ExternalLeadIn);
Out(p.ExternalLeadOut);
Lead(p.InternalLeadIn);
Out(p.InternalLeadOut);
Lead(p.ArcCircleLeadIn);
Out(p.ArcCircleLeadOut);
Number(p.PierceClearance);
Flag(p.RoundLeadInAngles);
Number(p.LeadInAngleIncrement);
Number(p.AutoTabMinSize);
Number(p.AutoTabMaxSize);
TabSettings(p.TabConfig);
Flag(p.TabsEnabled);
Sequence(p.Sequencing);
Assignment(p.Assignment);
text.Append('}');
}
void Sequence(SequenceParameters s)
{
if (!Begin(s, typeof(SequenceParameters)))
return;
Int((int)s.Method);
Number(s.SmallCutoutWidth);
Number(s.SmallCutoutHeight);
Number(s.MediumCutoutWidth);
Number(s.MediumCutoutHeight);
Number(s.DistanceMediumSmall);
Flag(s.AlternateRowsColumns);
Flag(s.AlternateCutoutsWithinRowColumn);
Number(s.MinDistanceBetweenRowsColumns);
text.Append('}');
}
void Assignment(AssignmentParameters a)
{
if (!Begin(a, typeof(AssignmentParameters)))
return;
Int((int)a.Method);
Text(a.Preference);
Number(a.MinGeometryLength);
text.Append('}');
}
void Lead(LeadIn lead)
{
if (lead == null)
{
text.Append("null;");
return;
}
switch (lead)
{
case NoLeadIn when Begin(lead, typeof(NoLeadIn)):
break;
case LineLeadIn l when Begin(lead, typeof(LineLeadIn)):
Number(l.Length);
Number(l.ApproachAngle);
break;
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
Number(l.Radius);
break;
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
Number(l.LineLength);
Number(l.ApproachAngle);
Number(l.ArcRadius);
break;
case LineLineLeadIn l when Begin(lead, typeof(LineLineLeadIn)):
Number(l.Length1);
Number(l.ApproachAngle1);
Number(l.Length2);
Number(l.ApproachAngle2);
break;
case CleanHoleLeadIn l when Begin(lead, typeof(CleanHoleLeadIn)):
Number(l.LineLength);
Number(l.ArcRadius);
Number(l.Kerf);
break;
default:
throw Unsupported(lead);
}
text.Append('}');
}
void Out(LeadOut lead)
{
if (lead == null)
{
text.Append("null;");
return;
}
switch (lead)
{
case NoLeadOut when Begin(lead, typeof(NoLeadOut)):
break;
case LineLeadOut l when Begin(lead, typeof(LineLeadOut)):
Number(l.Length);
Number(l.ApproachAngle);
break;
case ArcLeadOut l when Begin(lead, typeof(ArcLeadOut)):
Number(l.Radius);
break;
default:
throw Unsupported(lead);
}
text.Append('}');
}
void TabSettings(Tab tab)
{
if (tab == null)
{
text.Append("null;");
return;
}
switch (tab)
{
case NormalTab t when Begin(tab, typeof(NormalTab)):
Number(t.CutoutMinWidth);
Number(t.CutoutMinHeight);
Number(t.CutoutMaxWidth);
Number(t.CutoutMaxHeight);
break;
case BreakerTab t when Begin(tab, typeof(BreakerTab)):
Number(t.BreakerDepth);
Number(t.BreakerLeadInLength);
Number(t.BreakerAngle);
break;
case MachineTab t when Begin(tab, typeof(MachineTab)):
Int(t.MachineTabId);
break;
default:
throw Unsupported(tab);
}
Number(tab.Size);
Lead(tab.TabLeadIn);
Out(tab.TabLeadOut);
text.Append('}');
}
// Writes the exact type tag and returns true; null writes a marker and returns false.
// A runtime type other than the expected exact type throws before any member is read.
bool Begin(object value, Type exact)
{
if (value == null)
{
text.Append("null;");
return false;
}
if (value.GetType() != exact)
throw Unsupported(value);
text.Append(exact.Name).Append('{');
return true;
}
void Number(double value) =>
text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
void Flag(bool value) => text.Append(value ? "1;" : "0;");
// Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
void Text(string value)
{
if (value == null)
text.Append("~;");
else
text.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value).Append(';');
}
}
private static NotSupportedException Unsupported(object value) =>
new($"Cutting settings of type {value.GetType().FullName} cannot be captured exactly; " +
"only the built-in settings types are supported.");
}
@@ -234,6 +234,22 @@ namespace OpenNest.CNC.CuttingStrategy
return null; 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)
@@ -13,6 +13,9 @@ namespace OpenNest.Collections
public event EventHandler<ItemRemovedEventArgs<T>> ItemRemoved; public event EventHandler<ItemRemovedEventArgs<T>> ItemRemoved;
public event EventHandler<ItemChangedEventArgs<T>> ItemChanged; public event EventHandler<ItemChangedEventArgs<T>> ItemChanged;
/// <summary>Raised once after <see cref="Reorder"/> (or a cutting commit) installs a new order.</summary>
public event EventHandler ItemsReordered;
public ObservableList() public ObservableList()
{ {
items = new List<T>(); items = new List<T>();
@@ -64,6 +67,68 @@ namespace OpenNest.Collections
RemoveAt(i); RemoveAt(i);
} }
/// <summary>
/// Changes only the order of the current items. <paramref name="order"/> must hold exactly
/// the current non-null items: the same references with the same multiplicity. No
/// ItemAdded/ItemRemoved is raised, so quantity accounting is untouched; ItemsReordered is
/// raised once after the new order is installed. Invalid input throws before any change.
/// </summary>
public void Reorder(IEnumerable<T> order)
{
SetOrder(ValidateReorder(order));
ItemsReordered?.Invoke(this, EventArgs.Empty);
}
internal T[] ValidateReorder(IEnumerable<T> order)
{
ArgumentNullException.ThrowIfNull(order);
var proposed = order.ToArray();
if (proposed.Length != items.Count)
throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
var comparer = typeof(T).IsValueType
? EqualityComparer<T>.Default
: (IEqualityComparer<T>)(object)ReferenceEqualityComparer.Instance;
var counts = new Dictionary<T, int>(comparer);
foreach (var item in items)
{
if (item == null)
throw new InvalidOperationException("A list holding null items cannot be reordered.");
counts[item] = counts.TryGetValue(item, out var count) ? count + 1 : 1;
}
foreach (var item in proposed)
{
if (item == null || !counts.TryGetValue(item, out var count) || count == 0)
throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
counts[item] = count - 1;
}
return proposed;
}
// Validated order only; raises nothing so a commit can publish after a whole scope installs.
internal void SetOrder(T[] order)
{
items.Clear();
items.AddRange(order);
}
// Invokes every observer even if one throws, so a refresh failure cannot starve the rest.
internal void RaiseItemsReordered(ICollection<Exception> errors)
{
if (ItemsReordered == null)
return;
foreach (var handler in ItemsReordered.GetInvocationList())
{
try
{
((EventHandler)handler)(this, EventArgs.Empty);
}
catch (Exception ex)
{
errors.Add(ex);
}
}
}
public int IndexOf(T item) public int IndexOf(T item)
{ {
return items.IndexOf(item); return items.IndexOf(item);
@@ -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" />
+33
View File
@@ -135,6 +135,39 @@ namespace OpenNest
return true; return true;
} }
/// <summary>Exact cutting-state record for freshness and rollback; references are not copied.</summary>
internal CNC.CuttingPlanning.PartCuttingState CaptureCuttingState() =>
new(Program, ownsProgram, preLeadInRotation, HasManualLeadIns, LeadInsLocked,
CuttingParameters, location, BoundingBox);
/// <summary>Reinstates a state captured from this part, field for field.</summary>
internal void RestoreCuttingState(CNC.CuttingPlanning.PartCuttingState state)
{
Program = state.Program;
ownsProgram = state.OwnsProgram;
preLeadInRotation = state.PreLeadInRotation;
HasManualLeadIns = state.HasManualLeadIns;
LeadInsLocked = state.LeadInsLocked;
CuttingParameters = state.CuttingParameters;
location = state.Location;
BoundingBox = state.BoundingBox;
}
/// <summary>
/// Installs an owned, prevalidated planned program and its precomputed placed bounds.
/// Pose and lock are unchanged; nothing is regenerated or rotated here.
/// </summary>
internal void InstallPlannedProgram(Program program, Box bounds,
CNC.CuttingStrategy.CuttingParameters parameters)
{
preLeadInRotation = Rotation;
Program = program;
ownsProgram = true;
CuttingParameters = parameters;
HasManualLeadIns = true;
BoundingBox = bounds;
}
public void RemoveLeadIns() public void RemoveLeadIns()
{ {
var rotation = preLeadInRotation; var rotation = preLeadInRotation;
+11
View File
@@ -30,6 +30,17 @@ namespace OpenNest
remove { Parts.ItemChanged -= value; } remove { Parts.ItemChanged -= value; }
} }
/// <summary>
/// Raised once after the part order changes without adding or removing parts, including
/// a committed cutting plan that also installed planned programs. Refresh order- and
/// program-derived views; quantity accounting is unchanged.
/// </summary>
public event EventHandler PartsReordered
{
add { Parts.ItemsReordered += value; }
remove { Parts.ItemsReordered -= value; }
}
public Plate() public Plate()
: this(60, 120) { } : this(60, 120) { }
+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,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Whole-part cutting prerequisites for one captured plate. A cutoff precedes every part its
/// nominal span crosses (an orphaned cutoff, whose definition is missing, precedes every part),
/// and a part proven to lie inside a cutout of another part precedes that host. Built on the
/// caller thread from owned values; ambiguous containment refuses instead of guessing from bounds.
/// </summary>
internal sealed class CuttingDependencyGraph
{
private const int ContactBudget = 1000000;
private readonly int[][] prerequisites;
private CuttingDependencyGraph(int[][] prerequisites) => this.prerequisites = prerequisites;
internal static CuttingDependencyGraph Empty(int count) =>
new(Enumerable.Range(0, count).Select(_ => Array.Empty<int>()).ToArray());
/// <summary>Exact prerequisites, for tests and callers that already proved them.</summary>
internal static CuttingDependencyGraph FromPrerequisites(IReadOnlyList<int[]> prerequisites)
{
var copy = prerequisites.Select(p => p.Distinct().Order().ToArray()).ToArray();
if (copy.SelectMany(p => p).Any(i => i < 0 || i >= copy.Length))
throw new ArgumentException("Prerequisite ordinal out of range.");
return new(copy);
}
internal int Count => prerequisites.Length;
internal IReadOnlyList<int> PrerequisitesOf(int ordinal) => prerequisites[ordinal];
internal bool IsReady(int ordinal, IEnumerable<int> done)
{
if (prerequisites[ordinal].Length == 0)
return true;
var finished = done as ICollection<int> ?? done.ToHashSet();
return prerequisites[ordinal].All(finished.Contains);
}
/// <summary>First (part, missing prerequisite) in a proposed complete order, or null.</summary>
internal (int Part, int Prerequisite)? FirstViolation(IReadOnlyList<int> order)
{
var position = Enumerable.Repeat(-1, prerequisites.Length).ToArray();
for (var i = 0; i < order.Count; i++)
position[order[i]] = i;
foreach (var part in order)
foreach (var prerequisite in prerequisites[part])
if (position[prerequisite] < 0 || position[prerequisite] > position[part])
return (part, prerequisite);
return null;
}
/// <summary>An ordinal on a prerequisite cycle, or null when the graph is acyclic.</summary>
internal int? FindCycle()
{
var remaining = prerequisites.Select(p => p.Length).ToArray();
var dependents = Enumerable.Range(0, prerequisites.Length).Select(_ => new List<int>()).ToArray();
for (var part = 0; part < prerequisites.Length; part++)
foreach (var prerequisite in prerequisites[part])
dependents[prerequisite].Add(part);
var ready = new Queue<int>(Enumerable.Range(0, prerequisites.Length).Where(i => remaining[i] == 0));
while (ready.Count != 0)
foreach (var dependent in dependents[ready.Dequeue()])
if (--remaining[dependent] == 0)
ready.Enqueue(dependent);
var cyclic = Array.FindIndex(remaining, r => r != 0);
return cyclic < 0 ? null : cyclic;
}
internal static CuttingDependencyGraph Build(IReadOnlyList<DependencyNode> nodes, Box plate,
CancellationToken token)
{
var edges = nodes.Select(_ => new SortedSet<int>()).ToArray();
for (var cut = 0; cut < nodes.Count; cut++)
{
if (!nodes[cut].IsCutOff)
continue;
var definition = nodes[cut].CutOff;
for (var part = 0; part < nodes.Count; part++)
{
token.ThrowIfCancellationRequested();
if (!nodes[part].IsCutOff && (definition == null || CutOffCrosses(definition.Axis,
definition.Position, definition.StartLimit, definition.EndLimit, nodes[part].PlacedBounds, plate)))
edges[part].Add(cut);
}
}
for (var inner = 0; inner < nodes.Count; inner++)
for (var host = 0; host < nodes.Count; host++)
{
token.ThrowIfCancellationRequested();
// Bounds only select candidates; containment itself is proven on native material.
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds)
&& InsideCutout(nodes, inner, host, token))
edges[host].Add(inner);
}
var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray());
if (graph.FindCycle() is { } cyclic)
throw new CuttingDependencyException(CuttingPlanStatus.ConstraintConflict, cyclic, null,
$"Part {cyclic + 1} is on a cutting dependency cycle.");
return graph;
}
/// <summary>
/// Whether a cutoff's nominal line crosses a part's bounds within the cutoff's span. Uses the
/// nominal line, not its trimmed cutting segments (which deliberately skip the parts). Bounds
/// conservatively include edge contacts and concave recesses; limits prevent unrelated
/// dependencies beyond the cutoff's span. Negative coordinates need no special case.
/// </summary>
internal static bool CutOffCrosses(CutOffAxis axis, Vector cutOffPosition, double? startLimit,
double? endLimit, Box part, Box plate)
{
var vertical = axis == CutOffAxis.Vertical;
var position = vertical ? cutOffPosition.X : cutOffPosition.Y;
var acrossMin = vertical ? part.Left : part.Bottom;
var acrossMax = vertical ? part.Right : part.Top;
var alongMin = vertical ? part.Bottom : part.Left;
var alongMax = vertical ? part.Top : part.Right;
var start = startLimit ?? (vertical ? plate.Bottom : plate.Left);
var end = endLimit ?? (vertical ? plate.Top : plate.Right);
return !(position < acrossMin - Tolerance.Epsilon
|| position > acrossMax + Tolerance.Epsilon
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
}
// True when the inner part's perimeter lies strictly inside one cutout of the host. Any
// boundary contact or crossing between them, or incomplete material, is ambiguous.
private static bool InsideCutout(IReadOnlyList<DependencyNode> nodes, int inner, int host,
CancellationToken token)
{
var part = nodes[inner].Material;
var enclosing = nodes[host].Material;
if (!part.IsComplete || !enclosing.IsComplete)
throw Ambiguous(inner, host, part.Reason ?? enclosing.Reason);
var budget = ContactBudget;
foreach (var curve in part.Rings[0])
foreach (var ring in enclosing.Rings)
foreach (var boundary in ring)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw Ambiguous(inner, host, "the containment check exceeds the native query limit");
if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
throw Ambiguous(inner, host, "their boundaries touch or cross");
}
var probe = part.Rings[0][0].Start;
return enclosing.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
}
private static CuttingDependencyException Ambiguous(int inner, int host, string reason) =>
new(CuttingPlanStatus.UnsupportedGeometry, inner, host,
$"Containment of part {inner + 1} relative to part {host + 1} cannot be established: {reason}.");
private static bool Within(Box inner, Box outer) =>
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
}
/// <summary>Owned copy of a cutoff definition's nominal line.</summary>
internal sealed record CutOffDefinition(CutOffAxis Axis, Vector Position, double? StartLimit, double? EndLimit);
/// <summary>Owned per-placement dependency input; material is captured only when a pair needs it.</summary>
internal sealed class DependencyNode
{
private readonly Func<LeadMaterialSnapshot> materialFactory;
private LeadMaterialSnapshot material;
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
Func<LeadMaterialSnapshot> materialFactory)
{
IsCutOff = isCutOff;
CutOff = cutOff;
PlacedBounds = Copy(placedBounds);
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
HostBounds = cleanBounds == null ? PlacedBounds : Union(PlacedBounds, CleanBounds);
this.materialFactory = materialFactory;
}
internal bool IsCutOff { get; }
internal CutOffDefinition CutOff { get; }
internal Box PlacedBounds { get; }
internal Box CleanBounds { get; }
internal Box HostBounds { get; }
internal LeadMaterialSnapshot Material => material ??= materialFactory();
private static Box Copy(Box box) => new(box.X, box.Y, box.Length, box.Width);
private static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
}
}
/// <summary>A dependency refusal naming the part and, when relevant, the other part.</summary>
internal sealed class CuttingDependencyException(CuttingPlanStatus status, int ordinal, int? other, string message)
: Exception(message)
{
internal CuttingPlanStatus Status { get; } = status;
internal int Ordinal { get; } = ordinal;
internal int? Other { get; } = other;
}
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Caller-side direct-XY input. Regeneration requires explicit confirmed parameters.
/// Keep sources/settings stable during Capture. Only plate-scoped requests (<see cref="ForPlate"/>)
/// record the state that CuttingPlanService.Apply checks; a detached part list cannot be applied.
/// </summary>
public sealed class CuttingPlanRequest
{
public CuttingPlanRequest(IEnumerable<Part> parts, Vector startPoint = default, int expansionBudget = 20000,
CuttingParameters confirmedParameters = null, IEnumerable<Part> eligibleParts = null,
bool preservePartOrder = false, int maxEntries = 16)
{
Parts = parts == null ? null : Array.AsReadOnly(parts.ToArray());
StartPoint = startPoint;
ExpansionBudget = expansionBudget;
ConfirmedParameters = confirmedParameters;
EligibleParts = eligibleParts == null ? null : Array.AsReadOnly(eligibleParts.ToArray());
PreservePartOrder = preservePartOrder;
MaxEntries = maxEntries;
}
/// <summary>
/// Plate scope: plans the plate's current parts and captures its exact state, so a Ready
/// result can later be applied through CuttingPlanService.Apply. A factory rather than a
/// constructor overload, so <c>new CuttingPlanRequest(null)</c> stays unambiguous.
/// </summary>
public static CuttingPlanRequest ForPlate(Plate plate, Vector startPoint = default, int expansionBudget = 20000,
CuttingParameters confirmedParameters = null, IEnumerable<Part> eligibleParts = null,
bool preservePartOrder = false, int maxEntries = 16) =>
new(plate?.Parts, startPoint, expansionBudget, confirmedParameters, eligibleParts,
preservePartOrder, maxEntries)
{ Plate = plate };
/// <summary>The plate scope, or null for a detached part list that cannot be applied.</summary>
public Plate Plate { get; private init; }
public CuttingParameters ConfirmedParameters { get; }
public IReadOnlyList<Part> EligibleParts { get; }
public bool PreservePartOrder { get; }
public int MaxEntries { get; }
internal Action<int> ExpansionObserver { get; init; }
public IReadOnlyList<Part> Parts { get; }
public Vector StartPoint { get; }
public int ExpansionBudget { get; }
}
/// <summary>Owned worker input. SourcePart references are identity handles only, never worker data.</summary>
public sealed class CuttingPlanSnapshot
{
internal CuttingPlanSnapshot(IEnumerable<FixedProgramPlacement> placements, Vector startPoint,
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
Action<int> expansionObserver = null, PlateCuttingState plateState = null,
CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null)
{
PlateState = plateState;
OwnedParameters = ownedParameters;
Placements = Array.AsReadOnly(placements.ToArray());
Dependencies = dependencies ?? CuttingDependencyGraph.Empty(Placements.Count);
StartPoint = startPoint;
ExpansionBudget = expansionBudget;
Failure = failure;
Findings = Array.AsReadOnly((findings ?? []).ToArray());
Regeneration = regeneration;
PreservePartOrder = preservePartOrder;
MaxEntries = maxEntries;
ExpansionObserver = expansionObserver;
}
/// <summary>Whole-part prerequisites by source ordinal (cutoffs, inner parts before hosts).</summary>
internal CuttingDependencyGraph Dependencies { get; }
/// <summary>Exact captured plate state for plate-scoped requests; null for detached part lists.</summary>
internal PlateCuttingState PlateState { get; }
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
internal CuttingParameters OwnedParameters { get; }
internal bool Regeneration { get; }
internal bool PreservePartOrder { get; }
internal int MaxEntries { get; }
internal Action<int> ExpansionObserver { get; }
public IReadOnlyList<FixedProgramPlacement> Placements { get; }
public Vector StartPoint { get; }
public int ExpansionBudget { get; }
internal CuttingPlanStatus? Failure { get; }
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
}
public sealed class FixedProgramPlacement
{
internal FixedProgramPlacement(Part sourcePart, int sourceOrdinal, Vector location,
double rotation, bool leadInsLocked, OwnedExecution execution, Program program = null,
PreparedContours prepared = null, LeadMaterialSnapshot material = null,
IReadOnlyList<ContourChoice> choices = null)
{
SourcePart = sourcePart;
SourceOrdinal = sourceOrdinal;
Location = location;
Rotation = rotation;
LeadInsLocked = leadInsLocked;
Execution = execution;
this.program = program;
Prepared = prepared;
Material = material;
ContourChoices = Array.AsReadOnly((choices ?? []).ToArray());
}
private readonly Program program;
internal PreparedContours Prepared { get; }
internal LeadMaterialSnapshot Material { get; }
public IReadOnlyList<ContourChoice> ContourChoices { get; }
public bool IsRegenerated => ContourChoices.Count != 0;
/// <summary>Returns an independent deep copy; never an alias to captured/proposed code.</summary>
public Program CopyProgram() => program == null ? null : OwnedProgramCopy.Copy(program);
internal SelectedContourProgram SelectedProgram { get; private init; }
internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList<ContourChoice> choices,
System.Threading.CancellationToken token = default)
{
SelectedContourProgram selected = null;
try
{
// The expected program is constructed from owned choices/settings, NEVER
// from proposed or its cached execution. Counterfeit payloads remain subject
// to independent final checks, including callers of this internal test seam.
selected = Prepared?.CaptureSelectedProgram(choices, Location, token);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException or ArithmeticException)
{
// Invalid metadata is a refused proposal, not an exception from final replay.
}
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
{ SelectedProgram = selected, IsCutOff = IsCutOff };
}
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
public bool IsCutOff { get; internal init; }
public Part SourcePart { get; }
public int SourceOrdinal { get; }
public Vector Location { get; }
public double Rotation { get; }
public bool LeadInsLocked { get; }
public OwnedExecution Execution { get; }
}
public enum CuttingPlanStatus
{
Ready,
ConstraintConflict,
UnsupportedGeometry,
InvalidInput,
NoSolutionWithinBudget,
Cancelled
}
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
int? OtherSourceOrdinal, Part OtherSourcePart, PostVerificationKind? Kind, string Message);
/// <summary>
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
/// captured cutoff/containment prerequisites. Not physical safety or posting consent. Apply
/// installs it only for a plate-scoped request whose plate is unchanged. Failures contain no proposals.
/// </summary>
public sealed class CuttingPlanResult
{
internal CuttingPlanResult(CuttingPlanStatus status, IEnumerable<FixedProgramPlacement> order = null,
IEnumerable<CuttingPlanFinding> findings = null, int expansions = 0,
double rapidDistance = 0, bool independentlyReplayed = false)
{
Status = status;
ProposedOrder = Array.AsReadOnly((order ?? []).ToArray());
Findings = Array.AsReadOnly((findings ?? []).ToArray());
Expansions = expansions;
RapidDistance = rapidDistance;
IndependentlyReplayed = independentlyReplayed;
}
public CuttingPlanStatus Status { get; }
public IReadOnlyList<FixedProgramPlacement> ProposedOrder { get; }
public IReadOnlyList<CuttingPlanFinding> Findings { get; }
public int Expansions { get; }
public double RapidDistance { get; }
public bool IndependentlyReplayed { get; }
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
internal CuttingPlanSnapshot Snapshot { get; set; }
}
@@ -0,0 +1,385 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
public static class CuttingPlanService
{
/// <summary>
/// Read stable caller-owned sources once into privately owned programs and geometry.
/// No private Plates, settings aliases, quantity updates or source subcall rebinding.
/// </summary>
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default)
{
var placements = new List<FixedProgramPlacement>();
Part source = null;
int? ordinal = null;
try
{
token.ThrowIfCancellationRequested();
PlateCuttingState plateState = null;
if (request?.Plate != null)
{
// Exact freshness record first, on the caller thread; the planned list must be it.
plateState = PlateCuttingState.Capture(request.Plate, token);
if (request.Parts == null || !request.Parts.SequenceEqual(plateState.Order, ReferenceEqualityComparer.Instance))
throw new ArgumentException("The plate's parts changed after the request was created.");
if (request.Parts.Count == 0 && request.ExpansionBudget > 0 && request.MaxEntries > 0)
return new([], request.StartPoint, request.ExpansionBudget, plateState: plateState);
}
if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0 || request.MaxEntries <= 0)
throw new ArgumentException("A nonempty source list and positive expansion budget are required.");
var eligible = new HashSet<Part>(ReferenceEqualityComparer.Instance);
if (request.EligibleParts != null)
{
if (request.ConfirmedParameters == null)
throw new ArgumentException("Eligibility requires confirmed cutting parameters.");
foreach (var part in request.EligibleParts)
if (part == null || !request.Parts.Any(p => ReferenceEquals(p, part)) || !eligible.Add(part))
throw new ArgumentException("Foreign or duplicate eligible placement.");
}
// Cutoff definitions by drawing reference, as automatic sequencing matches them.
var definitions = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
foreach (var cutOff in request.Plate?.CutOffs ?? [])
if (cutOff?.Drawing != null)
definitions[cutOff.Drawing] = cutOff;
var nodes = new List<DependencyNode>();
// Reuse the reader's coordinate validation without publishing its native kernel.
var identities = new HashSet<Part>(ReferenceEqualityComparer.Instance);
for (var index = 0; index < request.Parts.Count; index++)
{
token.ThrowIfCancellationRequested();
source = request.Parts[index];
ordinal = index;
if (source?.BaseDrawing == null || source.Program == null || !double.IsFinite(source.Rotation)
|| !identities.Add(source))
throw new ArgumentException("Missing/duplicate source placement or invalid pose.");
// Validate both original graphs before Clone or any virtual transform can
// erase unsupported runtime semantics, including fixed/ineligible targets.
OwnedProgramCopy.Validate(source.BaseDrawing.Program, token);
OwnedProgramCopy.Validate(source.Program, token);
if (source.BaseDrawing.IsCutOff)
{
// A cutoff is a fixed open cut: never material, never regenerated.
if (request.Plate == null)
throw new NotSupportedException("Cutoff dependencies require a plate-scoped request.");
if (eligible.Contains(source))
throw new ArgumentException("A cutoff cannot be eligible for regeneration.");
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
if (!cut.HasCuttingContour)
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition)
? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
source.BoundingBox, null, null));
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
cut, OwnedProgramCopy.Copy(source.Program, token))
{ IsCutOff = true });
continue;
}
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
if (!clean.HasCuttingContour)
throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice.");
var execution = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
if (!execution.HasCuttingContour)
throw new ArgumentException("Placed program has no nonzero cutting contour motions.");
var ownedProgram = OwnedProgramCopy.Copy(source.Program, token);
PreparedContours prepared = null;
LeadMaterialSnapshot material = null;
// Geometry-only transform: original graphs were strictly validated and
// clone expansion bounded above. Legacy Rotate visits per parent, so
// retain Clone's per-parent sharing here instead of restoring diamonds.
// Exact placed/proposed payloads still use lossless OwnedProgramCopy.
var ownedClean = (Program)source.BaseDrawing.Program.Clone();
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
// Material extent only: remote rapids and scribe marks must not widen it, or a
// genuine insert could fail the containment prefilter.
var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
var location = source.Location;
if (request.ConfirmedParameters != null)
{
material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
if (!material.IsComplete)
throw new NotSupportedException(material.Reason);
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
}
var captured = material;
// Fixed-route material is captured only for a pair whose bounds admit containment.
nodes.Add(new(false, null, source.BoundingBox, cleanBounds,
() => captured ?? LeadMaterialSnapshot.Capture(ownedClean, location, token)));
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
execution, ownedProgram, prepared, material));
}
// The start can be invalid even when the first rapid has no cutting geometry.
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
source = null;
ordinal = null;
var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token);
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
expansionObserver: request.ExpansionObserver, plateState: plateState,
ownedParameters: request.ConfirmedParameters == null ? null
: OwnedCuttingParameters.Copy(request.ConfirmedParameters),
dependencies: dependencies);
}
catch (OperationCanceledException)
{
return Failure(CuttingPlanStatus.Cancelled, "Capture cancelled.");
}
catch (CuttingDependencyException exception)
{
var other = exception.Other is { } index ? request.Parts[index] : null;
return new(placements, request.StartPoint, request.ExpansionBudget, exception.Status,
[new(exception.Ordinal, request.Parts[exception.Ordinal], exception.Other, other, null, exception.Message)]);
}
catch (NotSupportedException exception)
{
return Failure(CuttingPlanStatus.UnsupportedGeometry, exception.Message);
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or ArithmeticException)
{
return Failure(CuttingPlanStatus.InvalidInput, exception.Message);
}
CuttingPlanSnapshot Failure(CuttingPlanStatus status, string message) =>
new(placements, request?.StartPoint ?? Vector.Zero, request?.ExpansionBudget ?? 0, status,
[new(ordinal, source, null, null, null, message)]);
}
public static CuttingPlanResult Plan(CuttingPlanRequest request, CancellationToken token = default) =>
Plan(Capture(request, token), token);
/// <summary>Worker-only planning uses owned values; live identities are never dereferenced.</summary>
public static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token = default)
{
var result = PlanCaptured(snapshot, token);
result.Snapshot = snapshot;
return result;
}
/// <summary>
/// Installs exactly the replayed proposals of Ready plate-scoped results, all or nothing.
/// Each plate must still match the state captured with its request; otherwise Stale and
/// nothing changes. Run on the thread that owns the plates. Never replans.
/// </summary>
public static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token = default) =>
Apply(results, token, null);
// beforeInstall is the commit's install-boundary test seam.
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
Action<Plate, Part> beforeInstall)
{
var plans = new List<PlateCuttingPlan>();
foreach (var result in results ?? [])
{
var snapshot = result?.Snapshot;
if (result?.Status != CuttingPlanStatus.Ready || !result.IndependentlyReplayed
|| snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
return new(CuttingCommitStatus.InvalidInput,
"Only Ready, independently replayed plate-scoped proposals can be applied.");
var programs = new List<PlannedPartProgram>();
foreach (var proposal in result.ProposedOrder)
{
if (!proposal.IsRegenerated)
continue;
if (snapshot.OwnedParameters == null)
return new(CuttingCommitStatus.InvalidInput, "A regenerated proposal has no captured settings.",
snapshot.PlateState.Plate);
// Fresh owned copies: a result can be applied at most once per captured state,
// and nothing installed aliases the proposal or another part's settings.
programs.Add(new(proposal.SourcePart, proposal.CopyProgram(),
OwnedCuttingParameters.Copy(snapshot.OwnedParameters)));
}
plans.Add(new(snapshot.PlateState, result.ProposedOrder.Select(p => p.SourcePart), programs));
}
return CuttingPlanCommit.Apply(plans, token, beforeInstall);
}
private static CuttingPlanResult PlanCaptured(CuttingPlanSnapshot snapshot, CancellationToken token)
{
if (token.IsCancellationRequested)
return new(CuttingPlanStatus.Cancelled);
if (snapshot == null)
return new(CuttingPlanStatus.InvalidInput);
if (snapshot.Failure is { } failure)
return new(failure, findings: snapshot.Findings);
if (snapshot.Placements.Count == 0)
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
if (snapshot.PreservePartOrder
&& snapshot.Dependencies.FirstViolation(Enumerable.Range(0, snapshot.Placements.Count).ToArray()) is { } manual)
return new(CuttingPlanStatus.ConstraintConflict,
findings: [DependencyFinding(snapshot, manual, "The preserved part order")]);
try
{
var fixedFindings = new List<CuttingPlanFinding>();
foreach (var placement in snapshot.Placements)
{
token.ThrowIfCancellationRequested();
if (placement.Prepared != null)
continue; // An eligible old crossing is precisely what regeneration may repair.
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
var findings = new ReleasedContourState().Check(placement.Execution, null,
placement.SourceOrdinal + 1, placement.IsCutOff, token);
fixedFindings.AddRange(Map(snapshot, findings));
}
if (fixedFindings.Count != 0)
return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: fixedFindings);
if (snapshot.Regeneration)
{
var joint = JointCuttingPlanSearch.Run(snapshot, token);
if (joint.Status != CuttingPlanStatus.Ready)
return new(joint.Status, findings: joint.Findings, expansions: joint.Expansions);
return ReplayPrograms(snapshot, joint.Order, joint.Expansions, token);
}
var search = FixedProgramSearch.Run(snapshot, token);
if (search.Status != CuttingPlanStatus.Ready)
return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions);
return Replay(snapshot, search.Order, search.Expansions, token);
}
catch (OperationCanceledException)
{
return new(CuttingPlanStatus.Cancelled);
}
}
// Fresh checker and full sequence replay: no cached branch state is accepted as evidence.
internal static CuttingPlanResult Replay(CuttingPlanSnapshot snapshot, IReadOnlyList<int> order,
int expansions, CancellationToken token)
{
if (order.Count != snapshot.Placements.Count || order.Distinct().Count() != order.Count
|| order.Any(index => index < 0 || index >= snapshot.Placements.Count))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
// Dependencies are rechecked from the captured graph, not trusted from the search.
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState();
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List<PostVerificationFinding>();
foreach (var index in order)
{
token.ThrowIfCancellationRequested();
var placement = snapshot.Placements[index];
findings.AddRange(checker.Check(placement.Execution, position, placement.SourceOrdinal + 1,
placement.IsCutOff, token));
distance += placement.Execution.RapidDistanceFrom(position);
position = placement.Execution.DeparturePoint;
}
token.ThrowIfCancellationRequested();
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: Map(snapshot, findings), expansions: expansions);
return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]),
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
}
// Re-read EXACT selected programs with a fresh checker and native lead validation.
// No emission/regeneration or cached branch verdict is used here.
internal static CuttingPlanResult ReplayPrograms(CuttingPlanSnapshot snapshot,
IReadOnlyList<FixedProgramPlacement> order, int expansions, CancellationToken token)
{
if (order == null || order.Count != snapshot.Placements.Count
|| order.Any(p => p == null || p.SourceOrdinal < 0 || p.SourceOrdinal >= snapshot.Placements.Count)
|| order.Select(p => p.SourceOrdinal).Distinct().Count() != order.Count)
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState();
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List<CuttingPlanFinding>();
var materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
foreach (var proposal in order)
{
token.ThrowIfCancellationRequested();
var source = snapshot.Placements[proposal.SourceOrdinal];
if (!ReferenceEquals(source.SourcePart, proposal.SourcePart)
|| !SameBits(source.Location.X, proposal.Location.X) || !SameBits(source.Location.Y, proposal.Location.Y)
|| !SameBits(source.Rotation, proposal.Rotation) || source.LeadInsLocked != proposal.LeadInsLocked
|| !ReferenceEquals(source.Prepared, proposal.Prepared) || !ReferenceEquals(source.Material, proposal.Material)
|| source.IsCutOff != proposal.IsCutOff
|| snapshot.PreservePartOrder && proposal.SourceOrdinal != order.TakeWhile(p => !ReferenceEquals(p, proposal)).Count())
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
if (source.Prepared == null)
{
if (!ReferenceEquals(source, proposal))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
}
else if (proposal.SelectedProgram == null
|| !proposal.SelectedProgram.Matches(source.Prepared, proposal.ContourChoices))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
OwnedExecution execution;
bool complete;
try
{
execution = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, position, token);
// A cutoff is an open cut with no material; it is retained exactly (checked above).
complete = source.IsCutOff
|| ContourProgramVerifier.Verify(execution, source.Material, proposal.SelectedProgram, token);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return new(CuttingPlanStatus.UnsupportedGeometry,
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
}
findings.AddRange(JointCuttingPlanSearch.Map(snapshot,
checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, token)));
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
: LeadPathValidator.Check(execution, source.Material, materials, token);
if (!lead.IsComplete || !lead.IsClear)
findings.Add(JointCuttingPlanSearch.Finding(source,
lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
if (!complete && findings.Count == 0)
return new(CuttingPlanStatus.InvalidInput,
findings: [JointCuttingPlanSearch.Finding(source, null,
"Selected program does not preserve complete directed contours, certified tabs or selected entry/style geometry.")],
expansions: expansions);
distance += execution.RapidDistanceFrom(position);
position = execution.DeparturePoint;
}
token.ThrowIfCancellationRequested();
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: findings, expansions: expansions);
return new(CuttingPlanStatus.Ready, order, expansions: expansions,
rapidDistance: distance, independentlyReplayed: true);
}
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
(int Part, int Prerequisite) violation, string subject)
{
var part = snapshot.Placements[violation.Part];
var prerequisite = snapshot.Placements[violation.Prerequisite];
var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
}
private static bool SameBits(double source, double proposed) =>
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
private static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot,
IEnumerable<PostVerificationFinding> findings) => findings.Select(finding =>
{
var source = finding.PartNumber is { } part ? snapshot.Placements[part - 1] : null;
var other = finding.OtherPartNumber is { } otherPart ? snapshot.Placements[otherPart - 1] : null;
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
other?.SourceOrdinal, other?.SourcePart, finding.Kind, finding.Message);
});
}
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>Bounded forward depth-first search, nearest feasible branch then source ordinal.</summary>
internal static class FixedProgramSearch
{
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<int> Order,
IReadOnlyList<PostVerificationFinding> Findings, int Expansions);
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
var stack = new Stack<Frame>();
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
var rejected = new HashSet<PostVerificationFinding>();
var expansions = 0;
while (stack.Count != 0)
{
token.ThrowIfCancellationRequested();
var frame = stack.Peek();
if (frame.Order.Length == snapshot.Placements.Count)
return new(CuttingPlanStatus.Ready, frame.Order, [], expansions);
if (frame.Next == frame.Candidates.Length)
{
stack.Pop();
continue;
}
if (expansions == snapshot.ExpansionBudget)
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.ToArray(), expansions);
var candidate = frame.Candidates[frame.Next++];
expansions++;
var placement = snapshot.Placements[candidate];
var checker = frame.Checker.Copy();
var findings = checker.Check(placement.Execution, frame.Position, placement.SourceOrdinal + 1,
placement.IsCutOff, token);
if (findings.Count != 0)
{
rejected.UnionWith(findings);
continue;
}
stack.Push(Create([.. frame.Order, candidate], placement.Execution.DeparturePoint, checker));
}
return new(CuttingPlanStatus.ConstraintConflict, [], rejected.ToArray(), expansions);
Frame Create(int[] order, Vector position, ReleasedContourState checker) => new(order, position, checker,
Enumerable.Range(0, snapshot.Placements.Count).Where(index => !order.Contains(index)
&& snapshot.Dependencies.IsReady(index, order)
&& (!snapshot.PreservePartOrder || index == order.Length))
.OrderBy(index => snapshot.Placements[index].Execution.RapidDistanceFrom(position))
.ThenBy(index => snapshot.Placements[index].SourceOrdinal).ToArray());
}
private sealed class Frame(int[] order, Vector position, ReleasedContourState checker, int[] candidates)
{
internal int[] Order { get; } = order;
internal Vector Position { get; } = position;
internal ReleasedContourState Checker { get; } = checker;
internal int[] Candidates { get; } = candidates;
internal int Next { get; set; }
}
}
@@ -0,0 +1,168 @@
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.Where(p => !p.IsCutOff).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 finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
var sources = node.Active is { } active ? new[] { active.Source }
: snapshot.Placements.Where(p => !finished.Contains(p.SourceOrdinal)
&& snapshot.Dependencies.IsReady(p.SourceOrdinal, finished)
&& (!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, source.IsCutOff, token);
rejected.AddRange(Map(snapshot, findings));
// A fixed cutoff has no material or leads to certify; its rapids are still checked.
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
: LeadPathValidator.Check(execution, source.Material, materials, token);
if (!lead.IsComplete || !lead.IsClear)
rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
return findings.Count == 0 && lead.IsComplete && lead.IsClear;
}
}
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; }
}
}
+26 -5
View File
@@ -53,6 +53,13 @@ namespace OpenNest.Engine.Fill
/// </summary> /// </summary>
public string Label { get; set; } public string Label { get; set; }
/// <summary>
/// When positive, grid fills stop adding perpendicular rows once they hold at least
/// this many parts; a first row that already holds them is returned on its own. The
/// first row is always complete. Zero fills the whole work area.
/// </summary>
public int MaxParts { get; init; }
private static Vector MakeOffset(NestDirection direction, double distance) private static Vector MakeOffset(NestDirection direction, double distance)
{ {
return direction == NestDirection.Horizontal return direction == NestDirection.Horizontal
@@ -195,7 +202,12 @@ namespace OpenNest.Engine.Fill
/// patterns, also adds individual parts from the next incomplete copy /// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area. /// that still fit within the work area.
/// </summary> /// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, OffsetPerimeterCache cache) private List<Part> TilePattern(
Pattern basePattern,
NestDirection direction,
OffsetPerimeterCache cache,
int maxParts = 0
)
{ {
var copyDistance = FindPatternCopyDistance(basePattern, direction, cache); var copyDistance = FindPatternCopyDistance(basePattern, direction, cache);
@@ -211,7 +223,10 @@ namespace OpenNest.Engine.Fill
var count = 1; var count = 1;
while (true) // maxParts counts the base pattern too: stop once the tiling holds that many.
bool Full() => maxParts > 0 && basePattern.Parts.Count + result.Count >= maxParts;
while (!Full())
{ {
var nextPos = start + copyDistance * count; var nextPos = start + copyDistance * count;
@@ -230,7 +245,7 @@ namespace OpenNest.Engine.Fill
// next copy that didn't fit as a whole. This handles cases where // next copy that didn't fit as a whole. This handles cases where
// e.g. a 2-part pair only partially fits — one part may still be // e.g. a 2-part pair only partially fits — one part may still be
// within the work area even though the full pattern exceeds it. // within the work area even though the full pattern exceeds it.
if (basePattern.Parts.Count > 1) if (basePattern.Parts.Count > 1 && !Full())
{ {
var offset = MakeOffset(direction, copyDistance * count); var offset = MakeOffset(direction, copyDistance * count);
@@ -383,7 +398,7 @@ namespace OpenNest.Engine.Fill
// If primary tiling didn't produce copies, just tile along perpendicular // If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count) if (row.Count <= pattern.Parts.Count)
{ {
row.AddRange(TilePattern(pattern, perpAxis, cache)); row.AddRange(TilePattern(pattern, perpAxis, cache, MaxParts));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2)) if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
{ {
@@ -395,13 +410,19 @@ namespace OpenNest.Engine.Fill
return row; return row;
} }
// A capped fill whose first row already holds enough parts needs no more rows.
// Step 2's check could only switch perpendicular tiling to the bbox fallback; it
// never repairs the row itself, so returning the row loses no protection.
if (MaxParts > 0 && row.Count >= MaxParts)
return row;
// Step 2: Build row pattern and tile along perpendicular axis // Step 2: Build row pattern and tile along perpendicular axis
var rowPattern = new Pattern(); var rowPattern = new Pattern();
rowPattern.Parts.AddRange(row); rowPattern.Parts.AddRange(row);
rowPattern.UpdateBounds(); rowPattern.UpdateBounds();
var gridResult = new List<Part>(rowPattern.Parts); var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache)); gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache, MaxParts));
// Only the unchanged row is covered by Step 1's clean verdict: skip Step 2 // Only the unchanged row is covered by Step 1's clean verdict: skip Step 2
// only when the perpendicular tiling appended zero parts, so gridResult // only when the perpendicular tiling appended zero parts, so gridResult
+33 -11
View File
@@ -68,14 +68,16 @@ public class StripeFiller
sheetSpan, sheetSpan,
spacing, spacing,
axis, axis,
_context.Token _context.Token,
_context.MaxQuantity
); );
var shrinkResult = ConvergeStripeAngleShrink( var shrinkResult = ConvergeStripeAngleShrink(
pairParts, pairParts,
sheetSpan, sheetSpan,
spacing, spacing,
axis, axis,
_context.Token _context.Token,
_context.MaxQuantity
); );
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult }) foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
@@ -127,7 +129,11 @@ public class StripeFiller
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis)) if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
return null; return null;
var stripeEngine = new FillLinear(stripeBox, spacing) { Label = "Stripe" }; var stripeEngine = new FillLinear(stripeBox, spacing)
{
Label = "Stripe",
MaxParts = _context.MaxQuantity,
};
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis); var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
if (stripeParts == null || stripeParts.Count == 0) if (stripeParts == null || stripeParts.Count == 0)
@@ -144,7 +150,11 @@ public class StripeFiller
stripePattern.Parts.AddRange(stripeParts); stripePattern.Parts.AddRange(stripeParts);
stripePattern.UpdateBounds(); stripePattern.UpdateBounds();
var gridEngine = new FillLinear(workArea, spacing) { Label = "Stripe-Grid" }; var gridEngine = new FillLinear(workArea, spacing)
{
Label = "Stripe-Grid",
MaxParts = _context.MaxQuantity,
};
var gridParts = gridEngine.Fill(stripePattern, perpAxis); var gridParts = gridEngine.Fill(stripePattern, perpAxis);
if (gridParts == null || gridParts.Count == 0) if (gridParts == null || gridParts.Count == 0)
@@ -179,6 +189,10 @@ public class StripeFiller
var allParts = new List<Part>(gridParts); var allParts = new List<Part>(gridParts);
// A capped fill that already holds the quantity needs no remnant parts.
if (_context.MaxQuantity > 0 && gridParts.Count >= _context.MaxQuantity)
return allParts;
var remnantParts = FillRemnant(gridParts, primaryAxis); var remnantParts = FillRemnant(gridParts, primaryAxis);
if (remnantParts != null) if (remnantParts != null)
{ {
@@ -351,18 +365,20 @@ public class StripeFiller
/// <summary> /// <summary>
/// Iteratively finds the rotation angle where N copies of the pattern /// Iteratively finds the rotation angle where N copies of the pattern
/// span the given dimension with minimal waste by expanding pair width. /// span the given dimension with minimal waste by expanding pair width.
/// Returns (angle, waste, pairCount). /// Returns (angle, waste, pairCount). A positive <paramref name="maxParts"/> caps
/// the estimate rows like <see cref="FillLinear.MaxParts"/>.
/// </summary> /// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngle( public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
List<Part> patternParts, List<Part> patternParts,
double sheetSpan, double sheetSpan,
double spacing, double spacing,
NestDirection axis, NestDirection axis,
CancellationToken token = default CancellationToken token = default,
int maxParts = 0
) )
{ {
var startAngle = OrientShortSideAlong(patternParts, axis); var startAngle = OrientShortSideAlong(patternParts, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token); return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
} }
/// <summary> /// <summary>
@@ -374,7 +390,8 @@ public class StripeFiller
double sheetSpan, double sheetSpan,
double spacing, double spacing,
NestDirection axis, NestDirection axis,
CancellationToken token = default CancellationToken token = default,
int maxParts = 0
) )
{ {
var baseAngle = OrientShortSideAlong(patternParts, axis); var baseAngle = OrientShortSideAlong(patternParts, axis);
@@ -392,7 +409,7 @@ public class StripeFiller
return (0, double.MaxValue, 0); return (0, double.MaxValue, 0);
var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis); var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token); return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
} }
private static (double Angle, double Waste, int Count) ConvergeFromAngle( private static (double Angle, double Waste, int Count) ConvergeFromAngle(
@@ -401,7 +418,8 @@ public class StripeFiller
double sheetSpan, double sheetSpan,
double spacing, double spacing,
NestDirection axis, NestDirection axis,
CancellationToken token CancellationToken token,
int maxParts
) )
{ {
var bestWaste = double.MaxValue; var bestWaste = double.MaxValue;
@@ -428,7 +446,11 @@ public class StripeFiller
axis == NestDirection.Horizontal axis == NestDirection.Horizontal
? new Box(0, 0, sheetSpan, perpDim) ? new Box(0, 0, sheetSpan, perpDim)
: new Box(0, 0, perpDim, sheetSpan); : new Box(0, 0, perpDim, sheetSpan);
var engine = new FillLinear(stripeBox, spacing) { Label = "Stripe-EstimateRow" }; var engine = new FillLinear(stripeBox, spacing)
{
Label = "Stripe-EstimateRow",
MaxParts = maxParts,
};
var filled = engine.Fill(rotated, axis); var filled = engine.Fill(rotated, axis);
var n = filled?.Count ?? 0; var n = filled?.Count ?? 0;
@@ -0,0 +1,227 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>
/// Fills one closed cutout of a frame part with copies of one insert part. A Fill lattice is
/// built over the cutout's bounds plus one part step on every side and shifted across a grid of
/// offsets of up to half a step each way. At each offset the copies whose spacing-grown outline
/// lies inside the cutout (a point test against the insert's inner-fit region of the cutout)
/// are counted; the offset keeping the most copies wins. Every kept pose is then certified with
/// <see cref="NestLayoutCheck.Clears"/> against the frame and the other copies.
/// </summary>
/// <remarks>
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order.
/// </remarks>
internal static class CutoutLatticeFill
{
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
internal const int DefaultShiftSteps = 8;
private const double FlattenTolerance = 0.001;
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
private const double Margin = FlattenTolerance + 0.001;
private const int Precision = NestTolerances.ClipperPrecision;
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
/// or none when no copy fits.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, CancellationToken token = default) =>
Fill(frame, cutoutIndex, insert, maxQuantity, spacing, DefaultShiftSteps, token);
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(insert);
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
return Array.Empty<NestJobPlacement>();
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
if (frameGeometry == null || insertGeometry == null)
return Array.Empty<NestJobPlacement>();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed())
return Array.Empty<NestJobPlacement>();
// Inscribed: the flattened cutout never extends past the real one.
var hole = Positive(ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, FlattenTolerance, circumscribe: false),
new Vector()));
if (hole.Count < 3)
return Array.Empty<NestJobPlacement>();
var bounds = cutout.BoundingBox;
var insertBounds = insertGeometry.Bounds;
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
var lattice = Lattice(insert, bounds, step, spacing, token);
if (lattice.Count == 0)
return Array.Empty<NestJobPlacement>();
var regions = new Dictionary<double, PathsD>();
foreach (var rotation in lattice.Select(p => p.Rotation).Distinct())
regions[rotation] = InnerFit(insertGeometry, rotation, hole, spacing);
var best = (Count: 0, I: 0, J: 0);
for (var i = -shiftSteps; i <= shiftSteps; i++)
for (var j = -shiftSteps; j <= shiftSteps; j++)
{
token.ThrowIfCancellationRequested();
var (dx, dy) = Shift(step, shiftSteps, i, j);
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy));
if (Better(count, i, j, best))
best = (count, i, j);
}
if (best.Count == 0)
return Array.Empty<NestJobPlacement>();
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy))
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
System.Math.Round(p.Y + sy, 8), p.Rotation))
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
.ToList();
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
}
/// <summary>Default Fill over the cutout's bounds grown by one step on every side, in frame
/// coordinates. Fill places whole parts only, so the margin keeps every offset covered.</summary>
private static List<NestJobPlacement> Lattice(NestJobPart insert, Box bounds, double step, double spacing,
CancellationToken token)
{
var drawing = DrawingJobMapper.CreateDrawing(insert);
try
{
var parts = PrivatePlateFill.Run(drawing, insert.Rotation, spacing,
bounds.Length + 2 * step, bounds.Width + 2 * step, 0, token);
token.ThrowIfCancellationRequested();
var left = bounds.Left - step;
var bottom = bounds.Bottom - step;
return parts.Select(p => new NestJobPlacement(insert.Id, 0, p.Location.X + left, p.Location.Y + bottom,
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.Where(p => double.IsFinite(p.X) && double.IsFinite(p.Y) && insert.Rotation.Allows(p.Rotation))
.ToList();
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return new List<NestJobPlacement>();
}
finally
{
BestFitCache.Invalidate(drawing);
}
}
/// <summary>
/// Reference points t at which the insert, rotated and grown by the spacing, lies inside the
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
/// never meets the cutout boundary (t outside boundary + reflected outline).
/// </summary>
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
{
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
foreach (var entity in entities)
entity.Rotate(rotation);
var perimeter = new ShapeProfile(entities).Perimeter;
var outline = Positive(ClipperBridge.ToPath(ClipperBridge.Flatten(perimeter, FlattenTolerance, circumscribe: true),
new Vector()));
var grown = Clipper.InflatePaths(new PathsD { outline }, spacing + Margin, JoinType.Round, EndType.Polygon,
2.0, Precision, FlattenTolerance)
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
if (grown == null || grown.Count < 3)
return new PathsD();
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
var forbidden = Clipper.Union(Minkowski.Sum(reflected, hole, true, Precision),
new PathsD { Clipper.TranslatePath(reflected, hole[0].x, hole[0].y) }, FillRule.NonZero, Precision);
var anchor = grown[0];
return Clipper.Difference(new PathsD { Clipper.TranslatePath(hole, -anchor.x, -anchor.y) }, forbidden,
FillRule.NonZero, Precision);
}
/// <summary>Drops copies that fail the production clearance check against the frame; any
/// failing pair of copies rejects the whole fill, since the lattice itself is then unsound.</summary>
private static IReadOnlyList<NestJobPlacement> Certify(string frameId, JobPartGeometry frame,
JobPartGeometry insertGeometry, List<NestJobPlacement> poses, double spacing, CancellationToken token)
{
var framePose = new NestJobPlacement(frameId, 0, 0, 0, 0);
var clear = new List<NestJobPlacement>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
if (NestLayoutCheck.Clears(frame, framePose, insertGeometry, pose, spacing))
clear.Add(pose);
}
// Rotation is about the reference point, so every copy's material lies within this
// distance of its pose whatever its rotation.
var b = insertGeometry.Bounds;
var reach = new[] { (b.Left, b.Bottom), (b.Right, b.Bottom), (b.Right, b.Top), (b.Left, b.Top) }
.Max(c => System.Math.Sqrt(c.Item1 * c.Item1 + c.Item2 * c.Item2));
var apart = 2 * reach + spacing;
for (var i = 0; i < clear.Count; i++)
for (var j = i + 1; j < clear.Count; j++)
{
token.ThrowIfCancellationRequested();
if (System.Math.Abs(clear[i].X - clear[j].X) > apart || System.Math.Abs(clear[i].Y - clear[j].Y) > apart)
continue;
if (!NestLayoutCheck.Clears(insertGeometry, clear[i], insertGeometry, clear[j], spacing))
return Array.Empty<NestJobPlacement>();
}
return clear;
}
private static (double Dx, double Dy) Shift(double step, int steps, int i, int j) =>
steps == 0 ? (0, 0) : (step * i / (2.0 * steps), step * j / (2.0 * steps));
/// <summary>More copies win; ties keep the smaller offset, then the lower i, then j.</summary>
private static bool Better(int count, int i, int j, (int Count, int I, int J) best)
{
if (count != best.Count)
return count > best.Count;
var distance = System.Math.Abs(i) + System.Math.Abs(j);
var bestDistance = System.Math.Abs(best.I) + System.Math.Abs(best.J);
if (distance != bestDistance)
return distance < bestDistance;
return i != best.I ? i < best.I : j < best.J;
}
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
private static bool Inside(PathsD region, double x, double y)
{
var point = new PointD(x, y);
var inside = false;
foreach (var path in region)
{
var result = Clipper.PointInPolygon(point, path, Precision);
if (result == PointInPolygonResult.IsOn)
return false;
if (result == PointInPolygonResult.IsInside)
inside = !inside;
}
return inside;
}
private static PathD Positive(PathD path)
{
if (path.Count >= 3 && !Clipper.IsPositive(path))
path.Reverse();
return path;
}
}
@@ -28,6 +28,13 @@ internal class DefaultPlateFiller : PlateFillerBase
set => angleBuilder.ForceFullSweep = value; set => angleBuilder.ForceFullSweep = value;
} }
/// <summary>
/// When true, a positive item quantity lets supporting strategies stop adding rows once a
/// candidate holds the quantity. Other strategies still fill the work area; the winner is
/// trimmed to the quantity. Off by default, so ordinary fills are unchanged.
/// </summary>
internal bool StopAtQuantity { get; init; }
public override List<double> BuildAngles( public override List<double> BuildAngles(
NestItem item, NestItem item,
ClassificationResult classification, ClassificationResult classification,
@@ -338,7 +345,7 @@ internal class DefaultPlateFiller : PlateFillerBase
Token = token, Token = token,
Progress = progress, Progress = progress,
Policy = BuildPolicy(), Policy = BuildPolicy(),
MaxQuantity = item.Quantity, MaxQuantity = StopAtQuantity ? System.Math.Max(0, item.Quantity) : 0,
}; };
RunPipeline(context); RunPipeline(context);
@@ -0,0 +1,45 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Placement;
/// <summary>
/// Runs the Default Fill for one private drawing on a private plate, for callers that turn the
/// result into job poses. The caller owns the drawing and must call
/// <see cref="BestFitCache.Invalidate"/> for it when finished.
/// </summary>
internal static class PrivatePlateFill
{
/// <param name="length">X extent of the plate and work area.</param>
/// <param name="width">Y extent of the plate and work area.</param>
/// <param name="quantity">Copies wanted; zero fills the whole area.</param>
internal static List<Part> Run(Drawing drawing, RotationPolicy rotation, double spacing,
double length, double width, int quantity, CancellationToken token)
{
var plate = new Plate(new Size(width, length)) { PartSpacing = spacing };
// The drawing is private: stabilize this cache entry before Fill's candidate pruning.
var fits = BestFitCache.GetOrCompute(drawing, plate.Size.Length, plate.Size.Width, spacing);
var sorted = fits.OrderBy(f => f.RotatedArea).ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation).ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray();
fits.Clear();
fits.AddRange(sorted);
// A positive quantity lets supporting strategies avoid surplus rows. Zero still
// fills the whole area (the cutout lattice caller requires an uncapped lattice).
var filler = new DefaultPlateFiller(plate) { StopAtQuantity = true };
return filler.Fill(new NestItem
{
Drawing = drawing,
Quantity = quantity,
RotationStart = rotation.Start,
RotationEnd = rotation.End,
StepAngle = DrawingJobMapper.LegacyStep(rotation),
}, new Box(0, 0, length, width), null!, token);
}
}
@@ -0,0 +1,142 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>Per-solve, per-spacing Fill proposals. Only the members occupy material.</summary>
internal sealed class BlockCatalog : IDisposable
{
private readonly double spacing;
private readonly Dictionary<int, Drawing> drawings = new();
private readonly Dictionary<int, List<Orientation>> orientations = new();
private readonly Dictionary<int, int> attempts = new();
internal int PreparationCount => attempts.Values.Sum();
private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList<Placed>> cache = new();
public BlockCatalog(double spacing, IReadOnlyList<PartType> types,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs)
{
this.spacing = spacing;
foreach (var type in types)
{
var poses = type.Orientations.ToList();
if (pairs.TryGetValue(type.Index, out var found))
poses.AddRange(found.SelectMany(p => new[] { p.A, p.B }));
orientations[type.Index] = poses.Distinct().ToList();
}
}
internal static IReadOnlyList<Box> Rectangles(Box work, IReadOnlyList<Placed> placed, double spacing)
{
var free = new PathsD { new PathD
{
new(work.Left, work.Bottom), new(work.Right, work.Bottom),
new(work.Right, work.Top), new(work.Left, work.Top),
} };
foreach (var part in placed)
{
// Physical occupied material, not a reference-point region for any moving part.
// The catalog currently prepares solid outlines; future profile preparation owns holes.
var blocked = Clipper.InflatePaths(new PathsD { part.Orientation.Outline },
spacing + part.Orientation.Tolerance + 0.001, JoinType.Miter, EndType.Polygon,
2, NoFitCache.Precision);
free = Clipper.Difference(free, Clipper.TranslatePaths(blocked, part.X, part.Y),
FillRule.NonZero, NoFitCache.Precision);
}
return MaximalRectangles.InRegion(free).OrderByDescending(b => b.Area())
.ThenBy(b => b.Left).ThenBy(b => b.Bottom).ThenBy(b => b.Length).Take(2).ToArray();
}
public IReadOnlyList<Placed> Get(PartType type, int quantity, Box rectangle, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (quantity <= 2 || type.Orientations.Count == 0 || rectangle.Length <= 0 || rectangle.Width <= 0)
return Array.Empty<Placed>();
var key = (type.Index, quantity, rectangle.Length, rectangle.Width);
if (cache.TryGetValue(key, out var cached))
return cached;
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8)
return Array.Empty<Placed>();
attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1;
var result = Build(type, quantity, rectangle, token);
cache[key] = result;
return result;
}
private IReadOnlyList<Placed> Build(PartType type, int quantity, Box rectangle, CancellationToken token)
{
if (!drawings.TryGetValue(type.Index, out var drawing))
drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part);
try
{
var members = PrivatePlateFill.Run(drawing, type.Part.Rotation, spacing,
rectangle.Length, rectangle.Width, quantity, token);
token.ThrowIfCancellationRequested();
return Resolve(type, members.Take(quantity).ToArray(), orientations[type.Index], spacing, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Array.Empty<Placed>();
}
}
public void Dispose()
{
foreach (var drawing in drawings.Values)
BestFitCache.Invalidate(drawing);
drawings.Clear();
}
internal static IReadOnlyList<Placed> Resolve(PartType type, IReadOnlyList<Part> members,
List<Orientation> orientations, double spacing, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (members.Count <= 2)
return Array.Empty<Placed>();
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
return Array.Empty<Placed>();
// Canonical rebinding is already performed by FillItem. Quantization removes sub-grid
// arithmetic differences from equivalent Fill proposals; certify the resulting poses.
var poses = members.Select(p => new NestJobPlacement(type.Part.Id, 0,
System.Math.Round(p.Location.X, 8), System.Math.Round(p.Location.Y, 8),
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.OrderBy(p => p.X).ThenBy(p => p.Y).ThenBy(p => p.Rotation).ToArray();
if (poses.Any(p => !double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Rotation)
|| !type.Part.Rotation.Allows(p.Rotation)))
return Array.Empty<Placed>();
for (var i = 0; i < poses.Length; i++)
for (var j = i + 1; j < poses.Length; j++)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing))
return Array.Empty<Placed>();
}
var result = new List<Placed>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
var orientation = orientations.FirstOrDefault(o => o.Rotation == pose.Rotation);
if (orientation == null)
{
orientation = PartCatalog.CreateOrientation(type, orientations.Max(o => o.Index) + 1, pose.Rotation);
if (orientation == null)
return Array.Empty<Placed>();
orientations.Add(orientation);
}
result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y));
}
return result;
}
}
@@ -55,17 +55,30 @@ internal sealed class FrontierPacker
private readonly IReadOnlyList<PartType> types; private readonly 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,74 +89,145 @@ 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); {
var typeIndex = region.Orientation.TypeIndex; placed.Add(part);
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();
counter.Add(snapshot.Length); foreach (var part in choice)
Parallel.For( {
0, counter.Add(snapshot.Length);
snapshot.Length, Parallel.For(
new ParallelOptions { CancellationToken = token }, 0,
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y) snapshot.Length,
); new ParallelOptions { CancellationToken = token },
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,49 +374,132 @@ 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)
{ {
point = default;
advance = side = lead = double.PositiveInfinity;
var found = false;
var o = Orientation; var o = Orientation;
foreach (var path in free) return BestVertex(free, minX, minY, maxX, maxY, (o.MinX, o.MinY, o.MaxX, o.MaxY),
foreach (var raw in path) axis, front, out point, out advance, out side, out lead);
}
}
/// <summary>
/// Scores the vertices of <paramref name="free"/>, each clamped to the inner-fit box
/// [minX, maxX] x [minY, maxY], for a piece occupying <paramref name="box"/> around its
/// reference point.
/// </summary>
private static bool BestVertex(
PathsD free,
double minX,
double minY,
double maxX,
double maxY,
(double MinX, double MinY, double MaxX, double MaxY) box,
PackAxis axis,
double front,
out PointD point,
out double advance,
out double side,
out double lead
)
{
point = default;
advance = side = lead = double.PositiveInfinity;
var found = false;
foreach (var path in free)
foreach (var raw in path)
{
var x = System.Math.Clamp(raw.x, minX, maxX);
var y = System.Math.Clamp(raw.y, minY, maxY);
double reach, across, start;
if (axis == PackAxis.X)
{ {
var x = System.Math.Clamp(raw.x, minX, maxX); reach = x + box.MaxX;
var y = System.Math.Clamp(raw.y, minY, maxY); across = y + box.MinY;
double reach, across, start; start = x + box.MinX;
if (axis == PackAxis.X)
{
reach = x + o.MaxX;
across = y + o.MinY;
start = x + o.MinX;
}
else
{
reach = y + o.MaxY;
across = x + o.MinX;
start = y + o.MinY;
}
var adv = System.Math.Max(0, reach - front);
var better = !found
|| adv < advance - Tie
|| (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie)));
if (!better)
continue;
found = true;
point = new PointD(x, y);
advance = adv;
side = across;
lead = start;
} }
return found; else
{
reach = y + box.MaxY;
across = x + box.MinX;
start = y + box.MinY;
}
var adv = System.Math.Max(0, reach - front);
var better = !found
|| adv < advance - Tie
|| (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie)));
if (!better)
continue;
found = true;
point = new PointD(x, y);
advance = adv;
side = across;
lead = start;
}
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,
}; };
} }
+3 -24
View File
@@ -1,8 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.CNC.CuttingStrategy; using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry; using OpenNest.Engine.CuttingPlanning;
using OpenNest.Math;
namespace OpenNest.Engine.Sequencing namespace OpenNest.Engine.Sequencing
{ {
@@ -60,7 +59,8 @@ namespace OpenNest.Engine.Sequencing
// An orphaned cutoff still must not follow potentially // An orphaned cutoff still must not follow potentially
// crossed parts when its nominal span cannot be recovered. // crossed parts when its nominal span cannot be recovered.
if (!definitions.TryGetValue(cut.Part.BaseDrawing, out var definition) if (!definitions.TryGetValue(cut.Part.BaseDrawing, out var definition)
|| CrossesBounds(definition, part.BoundingBox, bounds)) || CuttingDependencyGraph.CutOffCrosses(definition.Axis, definition.Position,
definition.StartLimit, definition.EndLimit, part.BoundingBox, bounds))
{ {
result.Add(cut.Part); result.Add(cut.Part);
emitted[cut.Index] = true; emitted[cut.Index] = true;
@@ -77,26 +77,5 @@ namespace OpenNest.Engine.Sequencing
return result; return result;
} }
private static bool CrossesBounds(CutOff cutOff, Box part, Box plate)
{
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var position = vertical ? cutOff.Position.X : cutOff.Position.Y;
var acrossMin = vertical ? part.Left : part.Bottom;
var acrossMax = vertical ? part.Right : part.Top;
var alongMin = vertical ? part.Bottom : part.Left;
var alongMax = vertical ? part.Top : part.Right;
var start = cutOff.StartLimit ?? (vertical ? plate.Bottom : plate.Left);
var end = cutOff.EndLimit ?? (vertical ? plate.Top : plate.Right);
// Use the nominal line, not its trimmed cutting segments (which
// deliberately skip the parts). Bounds conservatively include edge
// contacts and concave recesses; limits prevent unrelated dependencies
// beyond the cutoff's span. Negative coordinates need no special case.
return !(position < acrossMin - Tolerance.Epsilon
|| position > acrossMax + Tolerance.Epsilon
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
}
} }
} }
@@ -17,6 +17,10 @@ namespace OpenNest.Engine.Strategies
public CancellationToken Token { get; init; } public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; } public IProgress<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer()); public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
/// <summary>
/// When positive, strategies may stop placing parts once a candidate holds this
/// many (see <see cref="FillLinear.MaxParts"/>). Zero fills the whole work area.
/// </summary>
public int MaxQuantity { get; init; } public int MaxQuantity { get; init; }
public PartType PartType { get; set; } public PartType PartType { get; set; }
+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"]
+174 -51
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)
@@ -46,80 +49,159 @@ public sealed class NestDatabase : IDisposable
public IReadOnlyList<NestRecord> List() public IReadOnlyList<NestRecord> List()
{ {
using var command = _connection.CreateCommand(); lock (_sync)
command.CommandText = {
$"SELECT {RecordColumns} FROM nests ORDER BY savedAt DESC"; using var command = _connection.CreateCommand();
var records = new List<NestRecord>(); command.CommandText =
using var reader = command.ExecuteReader(); $"SELECT {RecordColumns} FROM nests ORDER BY savedAt DESC";
while (reader.Read()) var records = new List<NestRecord>();
records.Add(ReadRecord(reader)); using var reader = command.ExecuteReader();
return records; while (reader.Read())
records.Add(ReadRecord(reader));
return records;
}
} }
public NestRecord? Get(Guid id) public NestRecord? Get(Guid id)
{ {
using var command = _connection.CreateCommand(); lock (_sync)
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id"; {
command.Parameters.AddWithValue("$id", id.ToString()); using var command = _connection.CreateCommand();
using var reader = command.ExecuteReader(); command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id";
return reader.Read() ? ReadRecord(reader) : null; command.Parameters.AddWithValue("$id", id.ToString());
using var reader = command.ExecuteReader();
return reader.Read() ? ReadRecord(reader) : null;
}
}
/// <summary>
/// One bounded page of matching metadata plus the total match count. The count and
/// the page are read in the same monitor hold, so they describe one state.
/// </summary>
public NestPage Query(NestQuery query)
{
ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError();
if (error is not null)
throw new ArgumentException(error, nameof(query));
var search = query.NormalizedSearch;
var where = search.Length == 0 ? "" : SearchPredicate;
lock (_sync)
{
using var count = _connection.CreateCommand();
count.CommandText = $"SELECT COUNT(*) FROM nests {where}";
AddSearchParameter(count, search);
var total = checked((int)(long)count.ExecuteScalar()!);
using var command = _connection.CreateCommand();
var direction = query.Descending ? "DESC" : "ASC";
command.CommandText = $"""
SELECT {RecordColumns} FROM nests {where}
ORDER BY {SortColumn(query.Sort)} {direction}, id {direction}
LIMIT $limit OFFSET $offset
""";
AddSearchParameter(command, search);
command.Parameters.AddWithValue("$limit", query.Limit);
command.Parameters.AddWithValue("$offset", query.Offset);
var records = new List<NestRecord>();
using var reader = command.ExecuteReader();
while (reader.Read())
records.Add(ReadRecord(reader));
return new NestPage { Items = records, Total = total, Offset = query.Offset, Limit = query.Limit };
}
} }
public byte[]? GetFile(Guid id) public byte[]? GetFile(Guid id)
{ {
using var command = _connection.CreateCommand(); lock (_sync)
command.CommandText = "SELECT file FROM nests WHERE id = $id"; {
command.Parameters.AddWithValue("$id", id.ToString()); using var command = _connection.CreateCommand();
var value = command.ExecuteScalar(); command.CommandText = "SELECT file FROM nests WHERE id = $id";
return value is byte[] bytes ? bytes : null; command.Parameters.AddWithValue("$id", id.ToString());
var value = command.ExecuteScalar();
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)
{ {
using var command = _connection.CreateCommand(); lock (_sync)
command.CommandText = """ {
INSERT INTO nests (id, name, customer, dateCreated, dateModified, material, using var command = _connection.CreateCommand();
thickness, status, plateCount, partCount, comments, madeBy, fileSize, command.CommandText = """
savedAt, file) INSERT INTO nests (id, name, customer, dateCreated, dateModified, material,
VALUES ($id, $name, $customer, $dateCreated, $dateModified, $material, thickness, status, plateCount, partCount, comments, madeBy, fileSize,
$thickness, $status, $plateCount, $partCount, $comments, $madeBy, savedAt, file)
$fileSize, $savedAt, $file) VALUES ($id, $name, $customer, $dateCreated, $dateModified, $material,
"""; $thickness, $status, $plateCount, $partCount, $comments, $madeBy,
AddRecordParameters(command, id, record, file, updateFile: true); $fileSize, $savedAt, $file)
command.ExecuteNonQuery(); """;
return Get(id) ?? throw new InvalidOperationException("Insert did not persist."); AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery();
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)
{ {
if (Get(id) is null) lock (_sync)
return null; {
if (Get(id) is null)
return null;
using var command = _connection.CreateCommand(); using var command = _connection.CreateCommand();
var fileClause = file is null ? "" : ", file = $file, fileSize = $fileSize"; var fileClause = file is null ? "" : ", file = $file, fileSize = $fileSize";
command.CommandText = $""" command.CommandText = $"""
UPDATE nests SET UPDATE nests SET
name = $name, customer = $customer, dateCreated = $dateCreated, name = $name, customer = $customer, dateCreated = $dateCreated,
dateModified = $dateModified, material = $material, dateModified = $dateModified, material = $material,
thickness = $thickness, status = $status, plateCount = $plateCount, thickness = $thickness, status = $status, plateCount = $plateCount,
partCount = $partCount, comments = $comments, madeBy = $madeBy, partCount = $partCount, comments = $comments, madeBy = $madeBy,
savedAt = $savedAt{fileClause} savedAt = $savedAt{fileClause}
WHERE id = $id WHERE id = $id
"""; """;
AddRecordParameters(command, id, record, file, updateFile: file is not null); AddRecordParameters(command, id, record, file, updateFile: file is not null);
command.ExecuteNonQuery(); command.ExecuteNonQuery();
return Get(id); return Get(id);
}
} }
public bool Delete(Guid id) public bool Delete(Guid id)
{ {
using var command = _connection.CreateCommand(); lock (_sync)
command.CommandText = "DELETE FROM nests WHERE id = $id"; {
command.Parameters.AddWithValue("$id", id.ToString()); using var command = _connection.CreateCommand();
return command.ExecuteNonQuery() > 0; command.CommandText = "DELETE FROM nests WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString());
return command.ExecuteNonQuery() > 0;
}
} }
public void Dispose() => _connection.Dispose(); /// <summary>Checks the live connection without exposing storage error details.</summary>
public bool IsHealthy()
{
lock (_sync)
{
try
{
using var command = _connection.CreateCommand();
command.CommandText = "SELECT 1";
return command.ExecuteScalar() is long value && value == 1;
}
catch (Exception ex) when (ex is SqliteException or InvalidOperationException)
{
return false;
}
}
}
public void Dispose()
{
lock (_sync)
_connection.Dispose();
}
private void Execute(string sql) 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,318 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingDependencyTests
{
// Started just above P, nearest-first cuts P before the far cutoff start. P leaves upward
// and the cutoff's trimmed cut stops short of P, so P, cutoff, Q crosses nothing: only the
// nominal-span dependency puts the cutoff first.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Plan_CutOffWhoseNominalSpanCrossesAPart_IsCutFirstAndAppliedThatWay(bool regenerate)
{
var parameters = ExplicitContourTests.Parameters();
var (_, plate, p, q, cut) = CutOffPlate(parameters, orphan: false);
var request = CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5),
confirmedParameters: regenerate ? parameters : null);
var snapshot = CuttingPlanService.Capture(request);
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(0));
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(2));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
Assert.True(order.IndexOf(cut) < order.IndexOf(p), string.Join(",", result.ProposedOrder.Select(o => o.SourceOrdinal)));
Assert.Equal(regenerate, result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, p)).IsRegenerated);
Assert.False(result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, cut)).IsRegenerated);
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
Assert.Equal(order, plate.Parts);
Assert.Contains(q, plate.Parts);
}
[Fact]
public void Apply_CutOffReclassifiedAfterPlanning_IsStale()
{
var (_, plate, p, q, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5)));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
cut.BaseDrawing.IsCutOff = false;
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Equal(new[] { p, q, cut }, plate.Parts);
}
[Fact]
public void Plan_OrphanedCutOff_PrecedesEveryPart()
{
var (_, plate, p, q, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: true);
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5)));
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(0));
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(1));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.Same(cut, result.ProposedOrder[0].SourcePart);
}
[Fact]
public void Plan_PreservedOrderCuttingAPartBeforeItsCutOff_IsAConstraintConflict()
{
var (_, plate, p, _, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5), preservePartOrder: true));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
Assert.Empty(result.ProposedOrder);
var finding = Assert.Single(result.Findings);
Assert.Same(p, finding.SourcePart);
Assert.Same(cut, finding.OtherSourcePart);
Assert.Contains("cutoff", finding.Message);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Replay_RechecksDependenciesInsteadOfTrustingTheSearch(bool regenerate)
{
var parameters = ExplicitContourTests.Parameters();
var (_, plate, _, _, _) = CutOffPlate(parameters, orphan: false);
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5),
confirmedParameters: regenerate ? parameters : null));
var ready = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, ready.Status);
// Same verified placements, counterfeit order: crossed part before its cutoff.
var counterfeit = ready.ProposedOrder.OrderBy(o => o.SourceOrdinal).ToArray();
var replayed = regenerate
? CuttingPlanService.ReplayPrograms(snapshot, counterfeit, 0, default)
: CuttingPlanService.Replay(snapshot, counterfeit.Select(o => o.SourceOrdinal).ToArray(), 0, default);
Assert.Equal(CuttingPlanStatus.InvalidInput, replayed.Status);
Assert.Empty(replayed.ProposedOrder);
Assert.Contains(replayed.Findings, f => f.SourceOrdinal == 0 && f.OtherSourceOrdinal == 2);
}
[Fact]
public void Capture_DetachedListWithACutOff_IsUnsupported()
{
var (_, plate, _, _, _) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate.Parts.ToArray()));
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
Assert.Contains("plate-scoped", Assert.Single(result.Findings).Message);
}
[Fact]
public void Plan_PartInsideAHostCutout_IsCutBeforeTheHost()
{
var (plate, host, inner) = NestedPlate(Rectangle(2.7, 2.7, 0.6, 0.6));
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(new[] { 1 }, snapshot.Dependencies.PrerequisitesOf(0));
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.Equal(new[] { inner, host }, result.ProposedOrder.Select(o => o.SourcePart));
var preserved = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, preservePartOrder: true));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, preserved.Status);
var finding = Assert.Single(preserved.Findings);
Assert.Same(host, finding.SourcePart);
Assert.Same(inner, finding.OtherSourcePart);
Assert.Contains("nested in its cutout", finding.Message);
}
[Fact]
public void Dependencies_NonmaterialMotionsCannotHideANestedPart()
{
// Same insert as above plus a remote rapid and scribe mark: material is unchanged, so
// the inner-before-host prerequisite must not depend on whole-program bounds.
var (plate, host, inner) = NestedPlate(Marked(Rectangle(2.7, 2.7, 0.6, 0.6)));
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
Assert.Null(snapshot.Findings.FirstOrDefault()?.Message);
Assert.True(inner.BoundingBox.Left < host.BoundingBox.Left);
Assert.Equal(new[] { 1 }, snapshot.Dependencies.PrerequisitesOf(0));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.Equal(new[] { inner, host }, result.ProposedOrder.Select(o => o.SourcePart));
}
[Fact]
public void Capture_PartStraddlingAHostCutoutEdge_IsAmbiguousContainment()
{
var (plate, host, inner) = NestedPlate(Rectangle(3.6, 2.8, 0.8, 0.4));
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
var finding = Assert.Single(result.Findings);
Assert.Same(inner, finding.SourcePart);
Assert.Same(host, finding.OtherSourcePart);
Assert.Contains("Containment", finding.Message);
}
[Fact]
public void Capture_PartInAConcavePocketOutsideTheHost_HasNoDependency()
{
var nest = new Nest();
var plate = nest.CreatePlate();
var host = Pocketed();
var inner = Rectangle(4.5, 5, 1, 1);
plate.Parts.Add(host);
plate.Parts.Add(inner);
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
Assert.Null(snapshot.Findings.FirstOrDefault()?.Message);
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(0));
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.Plan(snapshot).Status);
}
[Fact]
public void DependencyGraph_ReportsCyclesAndFirstViolation()
{
Assert.NotNull(CuttingDependencyGraph.FromPrerequisites([[1], [0], []]).FindCycle());
var chain = CuttingDependencyGraph.FromPrerequisites([[], [0], [1]]);
Assert.Null(chain.FindCycle());
Assert.Null(chain.FirstViolation([0, 1, 2]));
Assert.Equal((2, 1), chain.FirstViolation([0, 2, 1]));
Assert.True(chain.IsReady(1, [0]));
Assert.False(chain.IsReady(2, [0]));
}
private static (Nest, Plate, Part P, Part Q, Part Cut) CutOffPlate(CuttingParameters parameters, bool orphan)
{
var nest = new Nest();
var plate = nest.CreatePlate();
var p = Emitted(parameters, new Vector(10, 10));
var q = Emitted(parameters, new Vector(40, 20));
var cutOff = new CutOff(new Vector(11, 0), CutOffAxis.Vertical) { StartLimit = 0, EndLimit = 30 };
var program = new Program();
program.Codes.Add(new RapidMove(11, 30));
program.Codes.Add(new LinearMove(11, 12.5) { Layer = LayerType.Cut });
cutOff.Drawing.Program = program;
if (!orphan)
plate.CutOffs.Add(cutOff);
var cut = new Part(cutOff.Drawing);
plate.Parts.Add(p);
plate.Parts.Add(q);
plate.Parts.Add(cut);
return (nest, plate, p, q, cut);
}
// A regenerable 2x2 square with its emitted lead at the top-edge midpoint.
private static Part Emitted(CuttingParameters parameters, Vector location)
{
var clean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
var prepared = PreparedContours.Capture(clean, parameters);
var part = new Part(new Drawing("same", clean), location);
Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(1, 3))]), false));
part.CuttingParameters = parameters;
return part;
}
// Host: 10x10 square with circular cutouts of radius 1 at (3,3) and (7,3), cut hole, hole,
// perimeter from the right so its own program has no completed-contour crossing.
private static (Plate, Part Host, Part Inner) NestedPlate(Part inner)
{
var clean = PreparedContourTests.Holes();
var parameters = ExplicitContourTests.Parameters();
var prepared = PreparedContours.Capture(clean, parameters);
var program = prepared.Emit(
[
prepared.Entry(0, 0, new Vector(4, 3)),
prepared.Entry(1, 0, new Vector(8, 3)),
prepared.ClosestEntry(2, new Vector(12, 3))
]);
var host = new Part(new Drawing("same", clean));
Assert.True(host.RestoreLeadInProgram(program, false));
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Parts.Add(host);
plate.Parts.Add(inner);
return (plate, host, inner);
}
// Prefixes a remote rapid and scribe mark to both the clean and placed programs.
private static Part Marked(Part part)
{
static Program Prefix(Program source)
{
var program = new Program();
program.Codes.Add(new RapidMove(-20, -20));
program.Codes.Add(new LinearMove(-19, -20) { Layer = LayerType.Scribe });
program.Codes.AddRange(source.Codes);
return program;
}
var marked = new Part(new Drawing("same", Prefix(part.BaseDrawing.Program)), part.Location);
Assert.True(marked.RestoreLeadInProgram(Prefix(part.Program), false));
return marked;
}
// A U-shaped part open at the top between x 3 and 7, lead on its right edge.
private static Part Pocketed()
{
var outline = new[] { (10.0, 0.0), (0.0, 0.0), (0.0, 10.0), (3.0, 10.0), (3.0, 3.0), (7.0, 3.0),
(7.0, 10.0), (10.0, 10.0), (10.0, 5.0) };
var clean = new Program();
clean.MoveTo(10, 5);
foreach (var (x, y) in outline)
clean.LineTo(x, y);
var placed = new Program();
placed.MoveTo(10.25, 5);
placed.Codes.Add(new LinearMove(10, 5) { Layer = LayerType.Leadin });
foreach (var (x, y) in outline)
placed.LineTo(x, y);
placed.Codes.Add(new LinearMove(10.25, 5) { Layer = LayerType.Leadout });
var part = new Part(new Drawing("same", clean));
Assert.True(part.RestoreLeadInProgram(placed, false));
return part;
}
// Fixed-program rectangle at (x, y), contour starting at its right-edge midpoint with a
// 0.25 straight lead-in and lead-out to the right.
private static Part Rectangle(double x, double y, double width, double height)
{
var clean = new Program();
clean.MoveTo(width, height / 2);
Contour(clean, width, height);
var part = new Part(new Drawing("same", clean), new Vector(x, y));
var placed = new Program();
placed.MoveTo(width + 0.25, height / 2);
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
Contour(placed, width, height);
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
Assert.True(part.RestoreLeadInProgram(placed, false));
return part;
}
private static void Contour(Program program, double width, double height)
{
program.LineTo(width, 0);
program.LineTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, height / 2);
}
private static string Describe(CuttingPlanResult r) =>
$"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message));
}
@@ -0,0 +1,575 @@
using System.Reflection;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanCommitTests
{
[Fact]
public void Apply_FixedProgramPlan_ReordersWithoutAccountingEventsAndPublishesOnce()
{
var (nest, plate, parts) = FixedPlate();
var programs = parts.Select(p => p.Program).ToArray();
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var events = Watch(plate);
Assert.Contains(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
f => f.Kind == PostVerificationKind.RapidCrossing);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(parts, plate.Parts); // Planning never mutates.
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
Assert.Empty(commit.RefreshErrors);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, plate.Parts);
Assert.Equal((0, 0, 1), events());
Assert.Equal(quantities, parts.Select(p => p.BaseDrawing.Quantity.Nested));
Assert.Equal(programs, parts.Select(p => p.Program)); // Fixed programs are not replaced.
Assert.DoesNotContain(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
f => f.Kind == PostVerificationKind.RapidCrossing);
// The same proposal is bound to the captured state, which no longer exists.
var again = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, again.Status);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, plate.Parts);
Assert.Equal((0, 0, 1), events());
}
[Fact]
public void Apply_RegeneratedPlan_InstallsTheExactReplayedProgramWithOwnedSettings()
{
var (nest, plate, part, _) = RegeneratedPlate(Vector.Zero);
var location = part.Location;
var rotation = part.Rotation;
var quantity = part.BaseDrawing.Quantity.Nested;
// Caller-confirmed settings are planning input, not plate state: editing them after
// capture neither stales the plan nor leaks into what is installed.
var parameters = ExplicitContourTests.Parameters();
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
var proposal = Assert.Single(result.ProposedOrder);
Assert.True(proposal.IsRegenerated);
((LineLeadIn)parameters.ExternalLeadIn).Length = length * 3;
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
Assert.True(ProgramContent.Equal(proposal.CopyProgram(), part.Program));
Assert.NotSame(parameters, part.CuttingParameters);
Assert.Equal(length, ((LineLeadIn)part.CuttingParameters.ExternalLeadIn).Length);
Assert.True(part.HasManualLeadIns);
Assert.False(part.LeadInsLocked);
Assert.Equal(BitConverter.DoubleToInt64Bits(location.X), BitConverter.DoubleToInt64Bits(part.Location.X));
Assert.Equal(BitConverter.DoubleToInt64Bits(location.Y), BitConverter.DoubleToInt64Bits(part.Location.Y));
Assert.Equal(BitConverter.DoubleToInt64Bits(rotation), BitConverter.DoubleToInt64Bits(part.Rotation));
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
var bounds = part.Program.BoundingBox();
bounds.Offset(part.Location);
Assert.Equal((bounds.X, bounds.Y, bounds.Width, bounds.Length),
(part.BoundingBox.X, part.BoundingBox.Y, part.BoundingBox.Width, part.BoundingBox.Length));
// The installed program is owned: later proposal copies cannot alias it.
var detached = proposal.CopyProgram();
detached.Codes.Clear();
Assert.NotEmpty(part.Program.Codes);
Assert.Empty(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings);
}
[Theory]
[InlineData("order")]
[InlineData("pose")]
[InlineData("program-in-place")]
[InlineData("program-replaced")]
[InlineData("lock")]
[InlineData("quantity")]
[InlineData("cutoff")]
[InlineData("same-name-drawing")]
[InlineData("list-replaced")]
[InlineData("added")]
[InlineData("cutoff-classification")]
[InlineData("part-settings-in-place")]
[InlineData("part-settings-nested")]
[InlineData("plate-settings-in-place")]
[InlineData("plate-settings-replaced")]
public void Apply_AnyChangeAfterCapture_IsStaleAndChangesNothing(string change)
{
var (_, plate, parts) = FixedPlate();
var cutOff = new CutOff(new Vector(30, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutOff);
plate.CuttingParameters = new CuttingParameters();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
switch (change)
{
case "order": plate.Parts.Reorder([parts[2], parts[1], parts[0]]); break;
case "pose": parts[0].Offset(1e-12, 0); break;
case "program-in-place": ((Motion)parts[1].Program.Codes[1]).Feedrate = 7; break;
case "program-replaced":
Assert.True(parts[1].RestoreLeadInProgram((Program)parts[1].Program.Clone(), false)); break;
case "lock": parts[2].LeadInsLocked = true; break;
case "quantity": plate.Quantity = 2; break;
case "cutoff":
cutOff.Position = new Vector(BitConverter.Int64BitsToDouble(
BitConverter.DoubleToInt64Bits(30.0) + 1), 0); break;
// Every fixture drawing has the same name: identity must be by reference.
case "same-name-drawing": parts[2].BaseDrawing.Program.Codes.Add(new Comment("edited")); break;
case "list-replaced":
var list = new ObservableList<Part>();
foreach (var part in parts) list.Add(part);
plate.Parts = list; break;
case "added": plate.Parts.Add(Rectangle(20, 0, 2, 2)); break;
// Classification decides lead, material, obstacle and dependency treatment.
case "cutoff-classification": parts[0].BaseDrawing.IsCutOff = true; break;
// A regenerated part would otherwise overwrite an edit made after capture.
case "part-settings-in-place": parts[0].CuttingParameters.Kerf = 0.125; break;
case "part-settings-nested": parts[0].CuttingParameters.Assignment.Preference = "LIAT"; break;
case "plate-settings-in-place": plate.CuttingParameters.PierceClearance = 0.25; break;
case "plate-settings-replaced": plate.CuttingParameters = new CuttingParameters(); break;
}
var after = PlateCuttingState.Capture(plate);
var events = Watch(plate);
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Same(plate, commit.Plate);
Assert.False(string.IsNullOrEmpty(commit.Message));
Assert.True(after.IsCurrent(), after.Difference());
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Apply_MalformedLiveProgram_IsStaleInsteadOfThrowing()
{
var (_, plate, parts) = FixedPlate();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
parts[1].Program.Codes = null!;
var events = Watch(plate);
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Equal(parts, plate.Parts);
Assert.Null(parts[1].Program.Codes);
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void ProgramContent_ComparesAuthoredKeySpellingExactly()
{
Program With(string key)
{
var program = new Program();
program.Codes.Add(new LinearMove(1, 0)
{
VariableRefs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { [key] = "v" }
});
return program;
}
Assert.True(ProgramContent.Equal(With("X"), With("X")));
Assert.False(ProgramContent.Equal(With("X"), With("x")));
}
[Fact]
public void Apply_PartSharedByTwoPlates_IsRefusedWithoutChange()
{
var parameters = ExplicitContourTests.Parameters();
var clean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
var prepared = PreparedContours.Capture(clean, parameters);
var shared = new Part(new Drawing("same", clean), new Vector(5, 5));
Assert.True(shared.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(3, 1))]), false));
var nest = new Nest();
var regenerated = nest.CreatePlate();
var fixedPlate = nest.CreatePlate();
regenerated.Parts.Add(shared);
fixedPlate.Parts.Add(shared);
var results = new[]
{
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(regenerated, confirmedParameters: parameters)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(fixedPlate))
};
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
Assert.True(results[0].ProposedOrder[0].IsRegenerated);
var program = shared.Program;
var states = new[] { regenerated, fixedPlate }.Select(p => PlateCuttingState.Capture(p)).ToArray();
var commit = CuttingPlanService.Apply(results);
// Installing through one plate would silently replace the other plate's fixed program.
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
Assert.Same(program, shared.Program);
Assert.All(states, s => Assert.True(s.IsCurrent(), s.Difference()));
}
[Fact]
public void CommitInstaller_IsOnlyReachableThroughTheVerifiedService()
{
// The installer validates root references only; owned, verified payloads come from
// CuttingPlanService.Apply. Public access would accept nested aliases of live programs.
Assert.False(typeof(CuttingPlanCommit).IsPublic);
Assert.False(typeof(PlateCuttingPlan).IsPublic);
Assert.False(typeof(PlannedPartProgram).IsPublic);
Assert.True(typeof(CuttingPlanService).GetMethod(nameof(CuttingPlanService.Apply),
[typeof(IEnumerable<CuttingPlanResult>), typeof(CancellationToken)])!.IsPublic);
}
[Fact]
public void Apply_LaterPlateStale_AppliesNothingAnywhere()
{
var (_, first, firstParts) = FixedPlate();
var (_, second, secondParts) = FixedPlate();
var results = new[]
{
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second))
};
secondParts[0].LeadInsLocked = true;
var firstState = PlateCuttingState.Capture(first);
var events = Watch(first);
var commit = CuttingPlanService.Apply(results);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Same(second, commit.Plate);
Assert.Equal(firstParts, first.Parts);
Assert.True(firstState.IsCurrent(), firstState.Difference());
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Apply_InstallFailureOnLaterPlate_RestoresEveryPlateExactlyWithoutPublishing()
{
var (_, first, firstParts) = FixedPlate();
var (_, second, part, parameters) = RegeneratedPlate(Vector.Zero);
var (_, third, thirdPart, thirdParameters) = RegeneratedPlate(Vector.Zero);
var results = new[]
{
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second, confirmedParameters: parameters)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(third, confirmedParameters: thirdParameters))
};
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
var states = new[] { first, second, third }.Select(p => PlateCuttingState.Capture(p)).ToArray();
var program = part.Program;
var bounds = part.BoundingBox;
var events = new[] { Watch(first), Watch(second), Watch(third) };
var failure = new InvalidOperationException("injected");
var installs = 0;
var commit = CuttingPlanService.Apply(results, default, (plate, _) =>
{
installs++;
if (ReferenceEquals(plate, third)) throw failure;
});
Assert.Equal(CuttingCommitStatus.Failed, commit.Status);
Assert.Same(failure, commit.Error);
Assert.Equal(2, installs); // The second plate really was installed before the failure.
Assert.Equal(firstParts, first.Parts);
Assert.Same(program, part.Program);
Assert.Same(bounds, part.BoundingBox);
Assert.Same(parameters, part.CuttingParameters);
Assert.All(states, s => Assert.True(s.IsCurrent(), s.Difference()));
Assert.All(events, e => Assert.Equal((0, 0, 0), e()));
Assert.Same(thirdParameters, thirdPart.CuttingParameters);
}
[Fact]
public void Apply_CancelledBeforeCommit_ChangesNothing()
{
var (_, plate, parts) = FixedPlate();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
var state = PlateCuttingState.Capture(plate);
var events = Watch(plate);
using var cancel = new CancellationTokenSource();
cancel.Cancel();
var commit = CuttingPlanService.Apply([result], cancel.Token);
Assert.Equal(CuttingCommitStatus.Cancelled, commit.Status);
Assert.Equal(parts, plate.Parts);
Assert.True(state.IsCurrent(), state.Difference());
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Apply_ObserverFailureAfterPublication_IsAReportedRefreshFailureNotARollback()
{
var (_, first, firstParts) = FixedPlate();
var (_, second, secondParts) = FixedPlate();
var results = new[]
{
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second))
};
var failure = new InvalidOperationException("view refresh");
var seen = new List<string>();
first.PartsReordered += (_, _) => throw failure;
first.PartsReordered += (_, _) => seen.Add("first");
second.PartsReordered += (_, _) => seen.Add("second");
var commit = CuttingPlanService.Apply(results);
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
Assert.Same(failure, Assert.Single(commit.RefreshErrors));
Assert.Equal(new[] { "first", "second" }, seen);
Assert.Equal(new[] { firstParts[1], firstParts[0], firstParts[2] }, first.Parts);
Assert.Equal(new[] { secondParts[1], secondParts[0], secondParts[2] }, second.Parts);
}
[Theory]
[InlineData("detached")]
[InlineData("not-ready")]
[InlineData("duplicate")]
[InlineData("empty")]
public void Apply_RefusesResultsThatAreNotApplicablePlatePlans(string fault)
{
var (_, plate, parts) = FixedPlate();
var state = PlateCuttingState.Capture(plate);
var events = Watch(plate);
CuttingPlanResult[] results = fault switch
{
"detached" => [CuttingPlanService.Plan(new CuttingPlanRequest(parts))],
"not-ready" => [CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, preservePartOrder: true))],
"duplicate" => Enumerable.Repeat(CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate)), 2).ToArray(),
_ => []
};
if (fault == "detached")
Assert.Equal(CuttingPlanStatus.Ready, results[0].Status);
if (fault == "not-ready")
Assert.Equal(CuttingPlanStatus.ConstraintConflict, results[0].Status);
var commit = CuttingPlanService.Apply(results);
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
Assert.Equal(parts, plate.Parts);
Assert.True(state.IsCurrent(), state.Difference());
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Request_NullDetachedListStaysUnambiguousAndInvalid()
{
// A plate overload of the constructor made this existing call ambiguous (CS0121).
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest(null)).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(null)).Status);
}
[Fact]
public void Apply_AllPlatesWithEmptySentinel_KeepsSentinelAndPlateList()
{
var nest = new Nest();
using var manager = new PlateManager(nest);
var plate = nest.CreatePlate();
foreach (var part in Fixture())
plate.Parts.Add(part);
Assert.Equal(2, nest.Plates.Count);
var sentinel = nest.Plates[1];
Assert.Empty(sentinel.Parts);
var listChanges = 0;
manager.PlateListChanged += (_, _) => listChanges++;
var sentinelEvents = Watch(sentinel);
var results = nest.Plates.ToArray().Select(p => CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(p))).ToArray();
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
Assert.Empty(results[1].ProposedOrder);
var commit = CuttingPlanService.Apply(results);
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
Assert.Equal(new[] { plate, sentinel }, nest.Plates);
Assert.Equal(0, listChanges);
Assert.Equal((0, 0, 0), sentinelEvents());
Assert.Equal(3, plate.Parts.Count);
}
[Theory]
[InlineData("missing")]
[InlineData("duplicate")]
[InlineData("foreign")]
[InlineData("count")]
[InlineData("null")]
public void Reorder_RejectsAnythingButTheSameReferencesWithoutChangeOrEvents(string fault)
{
var (_, plate, parts) = FixedPlate();
var events = Watch(plate);
Part[] order = fault switch
{
"missing" => [parts[0], parts[1], parts[1]],
"duplicate" => [parts[0], parts[1], parts[2], parts[2]],
"foreign" => [parts[0], parts[1], Rectangle(0, 0, 1, 1)],
"count" => [parts[0], parts[1]],
_ => [parts[0], parts[1], null!]
};
Assert.Throws<ArgumentException>(() => plate.Parts.Reorder(order));
Assert.Equal(parts, plate.Parts);
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void ProgramContent_DetectsEveryAuthoredFieldAndSharingShape()
{
// Guard: a new settable instruction field must be added to ProgramContent and here.
var expected = new Dictionary<Type, string[]>
{
[typeof(RapidMove)] = ["EndPoint", "Feedrate", "Suppressed", "UseExactStop", "VariableRefs"],
[typeof(LinearMove)] = ["EndPoint", "Feedrate", "Layer", "Suppressed", "UseExactStop", "VariableRefs"],
[typeof(ArcMove)] = ["CenterPoint", "EndPoint", "Feedrate", "Layer", "Rotation", "Suppressed",
"UseExactStop", "VariableRefs"],
[typeof(SubProgramCall)] = ["Id", "Offset", "Program", "Rotation"],
[typeof(Comment)] = ["Value"],
[typeof(Feedrate)] = ["Value", "VariableRef"],
[typeof(Kerf)] = ["Value"],
[typeof(Program)] = ["Mode"]
};
foreach (var (type, names) in expected)
Assert.Equal(names, type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.SetMethod?.IsPublic == true && p.GetIndexParameters().Length == 0)
.Select(p => p.Name).Order());
var mutations = new Action<Program>[]
{
p => ((Motion)p.Codes[0]).EndPoint = Nudge(((Motion)p.Codes[0]).EndPoint),
p => ((Motion)p.Codes[1]).UseExactStop = true,
p => ((Motion)p.Codes[1]).Feedrate = 3,
p => ((Motion)p.Codes[1]).Suppressed = true,
p => ((Motion)p.Codes[1]).VariableRefs!["x"] = "other",
p => ((Motion)p.Codes[1]).VariableRefs = null,
p => ((LinearMove)p.Codes[1]).Layer = LayerType.Display,
p => ((ArcMove)p.Codes[2]).Layer = LayerType.Display,
p => ((ArcMove)p.Codes[2]).Rotation = RotationType.CW,
p => ((ArcMove)p.Codes[2]).CenterPoint = Nudge(((ArcMove)p.Codes[2]).CenterPoint),
p => ((SubProgramCall)p.Codes[3]).Id = 9,
p => ((SubProgramCall)p.Codes[3]).Offset = Nudge(((SubProgramCall)p.Codes[3]).Offset),
p => ((SubProgramCall)p.Codes[3]).Rotation = 1,
p => ((SubProgramCall)p.Codes[3]).Program.Codes.Add(new Comment("hole")),
p => ((Comment)p.Codes[5]).Value = "other",
p => ((Feedrate)p.Codes[6]).Value = 2,
p => ((Feedrate)p.Codes[6]).VariableRef = "f",
p => ((Kerf)p.Codes[7]).Value = KerfType.Right,
p => p.Mode = Mode.Incremental,
p => p.Rotate(1e-9),
p => p.Variables["v"] = new VariableDefinition("v", "2", 2),
p => p.SubPrograms[5] = new Program(),
// Two calls sharing one hole versus two equal but distinct holes.
p => ((SubProgramCall)p.Codes[4]).BindProgram(OwnedProgramCopy.Copy(((SubProgramCall)p.Codes[4]).Program))
};
var original = Rich();
Assert.True(ProgramContent.Equal(original, OwnedProgramCopy.Copy(original)));
for (var i = 0; i < mutations.Length; i++)
{
var changed = OwnedProgramCopy.Copy(original);
mutations[i](changed);
Assert.False(ProgramContent.Equal(original, changed), $"Mutation {i} was not detected.");
}
}
private static Vector Nudge(Vector v) =>
new(BitConverter.Int64BitsToDouble(BitConverter.DoubleToInt64Bits(v.X) + 1), v.Y);
private static Program Rich()
{
var hole = new Program();
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Cut });
var program = new Program();
program.Variables["v"] = new VariableDefinition("v", "1", 1);
program.Codes.Add(new RapidMove(1, 1));
program.Codes.Add(new LinearMove(2, 1)
{
Layer = LayerType.Cut,
VariableRefs = new Dictionary<string, string> { ["x"] = "v" }
});
program.Codes.Add(new ArcMove(new Vector(3, 2), new Vector(2, 2), RotationType.CCW) { Layer = LayerType.Cut });
program.Codes.Add(new SubProgramCall(hole, 0) { Id = 1, Offset = new Vector(5, 5) });
program.Codes.Add(new SubProgramCall(hole, 0) { Id = 1, Offset = new Vector(7, 5) });
program.Codes.Add(new Comment("note"));
program.Codes.Add(new Feedrate(1));
program.Codes.Add(new Kerf(KerfType.Left));
program.SubPrograms[1] = hole;
return program;
}
private static Func<(int Added, int Removed, int Reordered)> Watch(Plate plate)
{
var added = 0;
var removed = 0;
var reordered = 0;
plate.PartAdded += (_, _) => added++;
plate.PartRemoved += (_, _) => removed++;
plate.PartsReordered += (_, _) => reordered++;
return () => (added, removed, reordered);
}
private static (Nest, Plate, Part[]) FixedPlate()
{
var nest = new Nest();
var plate = nest.CreatePlate();
var parts = Fixture();
foreach (var part in parts)
plate.Parts.Add(part);
return (nest, plate, parts);
}
private static (Nest, Plate, Part, CuttingParameters) RegeneratedPlate(Vector location)
{
var clean = PreparedContourTests.Holes();
var parameters = ExplicitContourTests.Parameters();
var prepared = PreparedContours.Capture(clean, parameters);
var original = prepared.Emit(
[
prepared.Entry(0, 0, new Vector(2, 3)),
prepared.Entry(1, 0, new Vector(8, 3)),
prepared.ClosestEntry(2, new Vector(-2, 3))
]);
var part = new Part(new Drawing("same", clean), location);
Assert.True(part.RestoreLeadInProgram(original, false));
part.CuttingParameters = parameters;
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Parts.Add(part);
return (nest, plate, part, parameters);
}
// Slice 1 fixture: A,B,C in this order crosses completed A; B,A,C does not.
private static Part[] Fixture() =>
[
Rectangle(4, 0, 4, 4),
Rectangle(0, 1, 2, 2),
Rectangle(10, 1, 2, 2)
];
private static Part Rectangle(double x, double y, double width, double height)
{
var clean = new Program();
clean.MoveTo(width, height / 2);
Contour(clean, width, height);
var part = new Part(new Drawing("same", clean), new Vector(x, y));
var placed = new Program();
placed.MoveTo(width + 0.25, height / 2);
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
Contour(placed, width, height);
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
Assert.True(part.RestoreLeadInProgram(placed, false));
part.CuttingParameters = new CuttingParameters();
return part;
}
private static void Contour(Program program, double width, double height)
{
program.LineTo(width, 0);
program.LineTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, height / 2);
}
}
@@ -0,0 +1,413 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanServiceTests
{
[Fact]
public void FixedPrograms_OriginalOrderCrossesA_ReorderedWholeProgramsDoNot()
{
var parts = Fixture();
var original = Analyze(parts);
Assert.Equal(new int?[] { 2, 3 }, original.Findings.Select(f => f.PartNumber));
Assert.All(original.Findings, crossing =>
{
Assert.Equal(PostVerificationKind.RapidCrossing, crossing.Kind);
Assert.Equal(1, crossing.OtherPartNumber);
});
var reordered = Analyze(parts[1], parts[0], parts[2]);
Assert.Empty(reordered.Findings);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Plan_ReordersWholeFixedPrograms_AndIndependentlyReplaysEveryPlacement(bool locked)
{
var parts = Fixture();
foreach (var part in parts)
part.LeadInsLocked = locked;
var unchanged = Unchanged(parts);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(parts.Length, result.ProposedOrder.Select(p => p.SourcePart).Distinct().Count());
Assert.Empty(result.Findings);
Assert.Empty(Analyze(result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
Assert.Equal(new Vector(12.25, 2), result.ProposedOrder[^1].Execution.DeparturePoint);
unchanged();
}
[Fact]
public void Plan_BacktracksRatherThanReturningUnsafeNearestOrder()
{
var parts = Fixture();
var start = new Vector(8.25, 2); // A is nearest but no continuation after A can reach B safely.
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, start));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.True(result.Expansions > parts.Length);
Assert.Empty(AnalyzeFrom(start, result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Plan_LockedFixedInternalCrossingIsConflictWithExactSourceIdentity(Mode mode)
{
var part = Rectangle("holes", 100, 50, 20, 20);
var hole = new Program();
hole.MoveTo(0.5, 0);
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
hole.Mode = mode;
var placed = new Program();
placed.SubPrograms[-7] = hole;
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(5, 5), Id = -7 });
placed.MoveTo(8, 5);
placed.MoveTo(2, 5);
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(15, 5), Id = -7 });
placed.Mode = mode;
Assert.True(part.RestoreLeadInProgram(placed, true));
var parts = new[] { Fixture()[0], part };
var unchanged = Unchanged(parts);
Assert.Contains(Analyze(part).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.NotEmpty(result.Findings);
Assert.All(result.Findings, finding =>
{
Assert.Equal(PostVerificationKind.RapidCrossing, finding.Kind);
Assert.Equal(1, finding.SourceOrdinal);
Assert.Equal(1, finding.OtherSourceOrdinal);
Assert.Same(part, finding.SourcePart);
Assert.Same(part, finding.OtherSourcePart);
});
unchanged();
}
[Theory]
[InlineData("recursive")]
[InlineData("missing-call")]
[InlineData("null-code")]
[InlineData("nonfinite")]
[InlineData("bad-arc")]
[InlineData("empty")]
[InlineData("motionless")]
[InlineData("null-codes")]
public void Plan_MalformedOrEmptyCuttingStateNeverReady(string fault)
{
var part = Fixture()[0];
var program = part.Program;
switch (fault)
{
case "recursive": program.Codes.Add(new SubProgramCall { Program = program }); break;
case "missing-call": program.Codes.Add(new SubProgramCall()); break;
case "null-code": program.Codes.Add(null!); break;
case "nonfinite": program.MoveTo(double.NaN, 0); break;
case "bad-arc": program.ArcTo(1, 1, 0, 0, RotationType.CW); break;
case "empty": program.Codes.Clear(); break;
case "motionless": program.Codes.Clear(); program.MoveTo(1, 1); break;
case "null-codes": program.Codes = null!; break;
}
var beforeCodes = program.Codes?.ToArray();
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(CuttingPlanStatus.InvalidInput, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.Same(program, part.Program);
Assert.Equal(beforeCodes, program.Codes?.ToArray());
}
[Theory]
[InlineData("suppressed")]
[InlineData("missing-lead")]
[InlineData("retention-unknown")]
[InlineData("cutoff")]
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
{
var part = Fixture()[0];
var expected = CuttingPlanStatus.UnsupportedGeometry;
switch (fault)
{
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
case "missing-lead":
part.Program.Codes.RemoveAt(1);
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
expected = CuttingPlanStatus.ConstraintConflict; break;
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
}
var unchanged = Unchanged([part]);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(expected, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.NotEmpty(result.Findings);
unchanged();
}
[Fact]
public void Plan_BudgetExhaustionHasNoFallbackOrMutation()
{
var parts = Fixture();
var unchanged = Unchanged(parts);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 1));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
Assert.Equal(1, result.Expansions);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
unchanged();
}
[Fact]
public void Plan_CancelledCaptureAndWorkerHaveNoMutation()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var unchanged = Unchanged(parts);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Equal(CuttingPlanStatus.Cancelled,
CuttingPlanService.Plan(new CuttingPlanRequest(parts), cancellation.Token).Status);
Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancellation.Token).Status);
unchanged();
}
[Fact]
public void Snapshot_OwnsMotionsPosesLocksAndOrder_NotLiveProgramsOrSettings()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var ownedEnds = snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)).ToArray();
foreach (var part in parts)
{
part.Program.Codes.Clear();
part.BaseDrawing.Program.Codes.Clear();
part.Location = new Vector(999, 999);
part.CuttingParameters.TabsEnabled = true;
part.LeadInsLocked = true;
}
Array.Reverse(parts);
var result = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(ownedEnds, snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)));
Assert.Equal(new Vector(4, 0), snapshot.Placements[0].Location);
Assert.All(snapshot.Placements, p => Assert.False(p.LeadInsLocked));
Assert.True(((ICollection<FixedProgramPlacement>)snapshot.Placements).IsReadOnly);
Assert.True(((ICollection<ExecutionMotion>)snapshot.Placements[0].Execution.Motions).IsReadOnly);
Assert.True(((ICollection<FixedProgramPlacement>)result.ProposedOrder).IsReadOnly);
}
[Fact]
public void Plan_DeterministicReferenceIdentity_NotDrawingNamesOrNameEquality()
{
var parts = Fixture();
foreach (var part in parts)
part.BaseDrawing.Name = "same";
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
for (var repeat = 0; repeat < 5; repeat++)
Assert.Equal(new[] { parts[1], parts[0], parts[2] },
CuttingPlanService.Plan(snapshot).ProposedOrder.Select(p => p.SourcePart));
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest([parts[0], parts[0]])).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest([])).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 0)).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(double.NaN, 0))).Status);
}
[Fact]
public void ExplicitStart_UsesActualLeadoutDeparture_AndDefaultRemainsZero()
{
var part = Fixture()[1];
var defaultResult = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
var explicitResult = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(2.25, 2)));
Assert.Equal(CuttingPlanStatus.Ready, defaultResult.Status);
Assert.Equal(CuttingPlanStatus.Ready, explicitResult.Status);
Assert.Equal(Vector.Zero.DistanceTo(new Vector(2.25, 2)), defaultResult.RapidDistance, 10);
Assert.Equal(0, explicitResult.RapidDistance);
Assert.Equal(new Vector(2.25, 2), explicitResult.ProposedOrder[0].Execution.DeparturePoint);
Assert.Empty(Analyze(part).Findings);
Assert.Empty(AnalyzeFrom(Vector.Zero, part).Findings);
}
[Theory]
[InlineData("unsafe")]
[InlineData("duplicate")]
[InlineData("omitted")]
[InlineData("unknown")]
public void IndependentReplay_RefusesUnsafeOrIncompleteProposals(string fault)
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var unchanged = Unchanged(parts);
var order = fault switch
{
"unsafe" => new[] { 0, 1, 2 },
"duplicate" => [1, 0, 0],
"omitted" => [1, 0],
_ => [1, 0, 3]
};
var result = CuttingPlanService.Replay(snapshot, order, 0, default);
Assert.Equal(fault == "unsafe" ? CuttingPlanStatus.ConstraintConflict : CuttingPlanStatus.InvalidInput,
result.Status);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
if (fault == "unsafe")
{
Assert.Equal(new int?[] { 1, 2 }, result.Findings.Select(f => f.SourceOrdinal));
Assert.All(result.Findings, f => Assert.Same(parts[0], f.OtherSourcePart));
}
unchanged();
}
[Fact]
public void EqualRapidDistance_TiesKeepSourceOrdinal()
{
var parts = new[] { Rectangle("same", 10, -3, 2, 2), Rectangle("same", 10, 1, 2, 2) };
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(parts, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Empty(Analyze(parts).Findings);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void OwnedReader_SharedSignedSubcallsPreserveTrueLeadoutDepartureAndOwnership(Mode mode)
{
var sub = new Program();
sub.MoveTo(1.25, 0);
sub.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
sub.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
sub.Codes.Add(new LinearMove(1.25, 0) { Layer = LayerType.Leadout });
sub.Mode = mode;
var program = new Program();
program.SubPrograms[-7] = sub;
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(5, 5) });
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(15, 5) });
var before = sub.ToString();
var owned = ExecutionMotionReader.Read(program, new Vector(100, 50), Vector.Zero, default);
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
Assert.Equal(new Vector(106.25, 55), owned.Motions[3].End);
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
sub.Codes.Clear();
program.Codes.Clear();
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
Assert.NotEmpty(before);
}
[Fact]
public void Check_CopiedReleasedStateDoesNotLeakBranchObstacles()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var parent = new ReleasedContourState();
var branch = parent.Copy();
Assert.Empty(branch.Check(snapshot.Placements[0].Execution, Vector.Zero, 1));
Assert.NotEmpty(branch.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
Assert.Empty(parent.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
}
private static Action Unchanged(Part[] parts)
{
var order = parts.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var codeRefs = parts.Select(p => p.Program.Codes.ToArray()).ToArray();
var text = parts.Select(p => p.Program.ToString()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var rotations = parts.Select(p => p.Rotation).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var parameters = parts.Select(p => p.CuttingParameters).ToArray();
var locks = parts.Select(p => p.LeadInsLocked).ToArray();
var manual = parts.Select(p => p.HasManualLeadIns).ToArray();
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var subs = parts.SelectMany(p => p.Program.Codes.OfType<SubProgramCall>())
.Select(call => (call, call.Program, text: call.Program.ToString(), call.Offset, call.Rotation, call.Id)).ToArray();
return () =>
{
Assert.Equal(order, parts);
for (var index = 0; index < parts.Length; index++)
{
var part = parts[index];
Assert.Same(programs[index], part.Program);
Assert.Equal(codeRefs[index], part.Program.Codes);
Assert.Equal(text[index], part.Program.ToString());
Assert.Equal(locations[index], part.Location);
Assert.Equal(rotations[index], part.Rotation);
Assert.Same(bounds[index], part.BoundingBox);
Assert.Same(parameters[index], part.CuttingParameters);
Assert.Equal(locks[index], part.LeadInsLocked);
Assert.Equal(manual[index], part.HasManualLeadIns);
Assert.Equal(quantities[index], part.BaseDrawing.Quantity.Nested);
}
foreach (var sub in subs)
{
Assert.Same(sub.Program, sub.call.Program);
Assert.Equal(sub.text, sub.call.Program.ToString());
Assert.Equal(sub.Offset, sub.call.Offset);
Assert.Equal(sub.Rotation, sub.call.Rotation);
Assert.Equal(sub.Id, sub.call.Id);
}
};
}
private static Part[] Fixture() =>
[
Rectangle("A", 4, 0, 4, 4),
Rectangle("B", 0, 1, 2, 2),
Rectangle("C", 10, 1, 2, 2)
];
private static Part Rectangle(string name, double x, double y, double width, double height)
{
var clean = new Program();
clean.MoveTo(width, height / 2);
Contour(clean, width, height);
var part = new Part(new Drawing(name, clean), new Vector(x, y));
var placed = new Program();
placed.MoveTo(width + 0.25, height / 2);
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
Contour(placed, width, height);
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
Assert.True(part.RestoreLeadInProgram(placed, false));
part.CuttingParameters = new CuttingParameters();
return part;
}
private static void Contour(Program program, double width, double height)
{
program.LineTo(width, 0);
program.LineTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, height / 2);
}
// Independent replay uses private placements/drawings, never an event-wired plate
// around the source drawings (which would change their quantity accounting).
private static PostVerificationReport Analyze(params Part[] parts) => AnalyzeFrom(Vector.Zero, parts);
private static PostVerificationReport AnalyzeFrom(Vector start, params Part[] parts)
{
var nest = new Nest();
var plate = nest.CreatePlate();
foreach (var source in parts)
{
var copy = new Part(new Drawing("replay", (Program)source.BaseDrawing.Program.Clone()), source.Location);
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
plate.Parts.Add(copy);
}
return PostVerificationAnalyzer.Analyze(nest, start);
}
}
@@ -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,357 @@
using System.Globalization;
using System.Reflection;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// Settings freshness for plate-scoped plans. Only the exact built-in settings types are
/// captured, each member explicitly; any other runtime type is refused at capture without
/// running its code, and becoming unsupported after capture is Stale. The coverage tests
/// fail when a supported type gains state the fingerprint does not write.
/// </summary>
public class SettingsFreshnessTests
{
private static readonly Type[] SupportedTypes =
[
typeof(CuttingParameters), typeof(SequenceParameters), typeof(AssignmentParameters),
typeof(NoLeadIn), typeof(LineLeadIn), typeof(ArcLeadIn), typeof(LineArcLeadIn),
typeof(LineLineLeadIn), typeof(CleanHoleLeadIn),
typeof(NoLeadOut), typeof(LineLeadOut), typeof(ArcLeadOut),
typeof(NormalTab), typeof(BreakerTab), typeof(MachineTab)
];
[Theory]
[InlineData("dictionary")]
[InlineData("struct")]
[InlineData("cycle")]
[InlineData("date")]
[InlineData("array")]
[InlineData("mutating-getter")]
[InlineData("failing-enumerable")]
public void Plan_CustomSettingsType_IsUnsupportedWithoutRunningItsCode(string shape)
{
CuttingParameters settings = shape switch
{
"dictionary" => new DictionarySettings { Values = { ["leadLength"] = 0.3 } },
"struct" => new StructSettings { Extra = new ScalarSettings { Length = 0.3 } },
"cycle" => CycleSettings.Create(),
"date" => new DateSettings { When = new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc) },
"array" => new ArraySettings { Values = new int[2, 3] },
"mutating-getter" => new MutatingSettings(),
"failing-enumerable" => new EnumerableSettings { Fail = true },
_ => throw new ArgumentOutOfRangeException(nameof(shape))
};
var (plate, part) = SinglePart(settings);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
Assert.Contains(result.Findings, f => f.Message.Contains("cannot be captured exactly"));
Assert.Equal(0, settings.Kerf); // A Bump => ++Kerf getter never ran.
Assert.Same(settings, part.CuttingParameters);
}
[Theory]
[InlineData("lead-in")]
[InlineData("lead-out")]
[InlineData("tab")]
[InlineData("tab-lead-in")]
[InlineData("sequence")]
[InlineData("assignment")]
[InlineData("plate")]
public void Plan_CustomNestedOrPlateSettingsType_IsUnsupported(string slot)
{
var settings = new CuttingParameters();
var (plate, _) = SinglePart(settings);
switch (slot)
{
case "lead-in": settings.InternalLeadIn = new CustomLeadIn(); break;
case "lead-out": settings.ArcCircleLeadOut = new CustomLeadOut(); break;
case "tab": settings.TabConfig = new CustomTab(); break;
case "tab-lead-in": settings.TabConfig = new NormalTab { TabLeadIn = new CustomLeadIn() }; break;
case "sequence": settings.Sequencing = new CustomSequence(); break;
case "assignment": settings.Assignment = new CustomAssignment(); break;
case "plate": plate.CuttingParameters = new MutatingSettings(); break;
}
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry,
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate)).Status);
}
[Theory]
[InlineData("lead-in")]
[InlineData("tab")]
[InlineData("plate")]
public void Apply_SettingsReplacedWithUnsupportedTypeAfterCapture_IsStaleWithoutException(string slot)
{
var settings = new CuttingParameters();
var (plate, _) = SinglePart(settings);
plate.CuttingParameters = new CuttingParameters();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
switch (slot)
{
case "lead-in": settings.ExternalLeadIn = new CustomLeadIn(); break;
case "tab": settings.TabConfig = new CustomTab(); break;
case "plate": plate.CuttingParameters.Sequencing = new CustomSequence(); break;
}
CuttingCommitResult? commit = null;
Assert.Null(Record.Exception(() => commit = CuttingPlanService.Apply([result])));
Assert.Equal(CuttingCommitStatus.Stale, commit!.Status);
}
[Fact]
public void Plan_DetachedRequestWithCustomSettings_IsUnaffected()
{
// Only plate-scoped capture records settings; detached part lists never fingerprint them.
var (plate, _) = SinglePart(new MutatingSettings());
var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate.Parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
}
[Fact]
public void Fingerprint_SeesAnEditToEveryPublicPropertyOfEverySupportedType()
{
var edits = 0;
foreach (var type in SupportedTypes)
{
Assert.Empty(type.GetFields(BindingFlags.Public | BindingFlags.Instance));
var instance = Activator.CreateInstance(type)!;
var settings = Graph(instance);
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
Assert.True(property.CanRead && property.CanWrite && property.GetIndexParameters().Length == 0,
$"{type.Name}.{property.Name}: extend StateFingerprint for non-settable state.");
var before = StateFingerprint.Of(settings);
property.SetValue(instance, Different(property.GetValue(instance), property.PropertyType,
$"{type.Name}.{property.Name}"));
Assert.NotEqual(before, StateFingerprint.Of(settings));
edits++;
}
}
Assert.True(edits > 40, $"Only {edits} property edits were exercised.");
}
[Fact]
public void Fingerprint_SupportedTypesAreEveryBuiltInLeadAndTab()
{
var builtIn = typeof(LeadIn).Assembly.GetTypes()
.Where(t => !t.IsAbstract && (typeof(LeadIn).IsAssignableFrom(t)
|| typeof(LeadOut).IsAssignableFrom(t) || typeof(Tab).IsAssignableFrom(t)))
.OrderBy(t => t.FullName).ToArray();
var supported = SupportedTypes
.Where(t => typeof(LeadIn).IsAssignableFrom(t) || typeof(LeadOut).IsAssignableFrom(t)
|| typeof(Tab).IsAssignableFrom(t))
.OrderBy(t => t.FullName).ToArray();
Assert.Equal(builtIn, supported);
foreach (var type in builtIn)
StateFingerprint.Of(Graph(Activator.CreateInstance(type)!)); // Must not throw.
}
[Fact]
public void Fingerprint_BuiltInSettingsAreDeterministicAndDetectSignedZero()
{
LeadIn[] leads = [new NoLeadIn(), new LineLeadIn(), new ArcLeadIn(), new LineArcLeadIn(),
new LineLineLeadIn(), new CleanHoleLeadIn()];
LeadOut[] outs = [new NoLeadOut(), new LineLeadOut(), new ArcLeadOut()];
Tab[] tabs = [new NormalTab(), new BreakerTab(), new MachineTab()];
foreach (var lead in leads)
foreach (var leadOut in outs)
foreach (var tab in tabs)
{
// Shared instances across slots are allowed: they are values, not cycles.
tab.TabLeadIn = lead;
tab.TabLeadOut = leadOut;
var settings = new CuttingParameters
{
ExternalLeadIn = lead,
InternalLeadIn = lead,
ArcCircleLeadIn = lead,
ExternalLeadOut = leadOut,
InternalLeadOut = leadOut,
ArcCircleLeadOut = leadOut,
TabConfig = tab,
TabsEnabled = true
};
var one = StateFingerprint.Of(settings);
Assert.Equal(one, StateFingerprint.Of(settings));
settings.Kerf = BitConverter.Int64BitsToDouble(unchecked((long)0x8000000000000000));
Assert.NotEqual(one, StateFingerprint.Of(settings));
}
}
[Fact]
public void Fingerprint_TextFieldsCannotShiftIntoNeighbours()
{
var a = new CuttingParameters { MachineName = "a;", MaterialName = "b" };
var b = new CuttingParameters { MachineName = "a", MaterialName = ";b" };
var n = new CuttingParameters { MachineName = null };
var e = new CuttingParameters { MachineName = "" };
Assert.NotEqual(StateFingerprint.Of(a), StateFingerprint.Of(b));
Assert.NotEqual(StateFingerprint.Of(n), StateFingerprint.Of(e));
}
[Fact]
public void Commit_BuiltInSettingsAreCultureIndependent()
{
var before = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
var settings = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = -90 }
};
var (plate, _) = SinglePart(settings);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("ar-SA");
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
}
finally
{
CultureInfo.CurrentCulture = before;
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Commit_ConfirmedSettingsEdit_StalesOnlyWhenAlsoLiveState(bool aliasesLive)
{
var method = typeof(CuttingPlanCommitTests).GetMethod("RegeneratedPlate",
BindingFlags.Static | BindingFlags.NonPublic)!;
var (_, plate, _, live) = ((Nest, Plate, Part, CuttingParameters))method
.Invoke(null, [Vector.Zero])!;
var confirmed = aliasesLive ? live : OwnedCuttingParameters.Copy(live);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: confirmed));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
((LineLeadIn)confirmed.ExternalLeadIn).Length = 9;
Assert.Equal(aliasesLive ? CuttingCommitStatus.Stale : CuttingCommitStatus.Applied,
CuttingPlanService.Apply([result]).Status);
}
// Places one supported settings object somewhere the fingerprint of the root reaches it.
private static CuttingParameters Graph(object instance) => instance switch
{
CuttingParameters p => p,
SequenceParameters s => new CuttingParameters { Sequencing = s },
AssignmentParameters a => new CuttingParameters { Assignment = a },
LeadIn l => new CuttingParameters { ExternalLeadIn = l },
LeadOut l => new CuttingParameters { ExternalLeadOut = l },
Tab t => new CuttingParameters { TabConfig = t },
_ => throw new ArgumentOutOfRangeException(nameof(instance))
};
private static object? Different(object? value, Type type, string member)
{
if (type == typeof(double))
return (double)value! + 1.25;
if (type == typeof(int))
return (int)value! + 1;
if (type == typeof(bool))
return !(bool)value!;
if (type == typeof(string))
return ((string?)value ?? "") + "x";
if (type.IsEnum)
return Enum.GetValues(type).Cast<object>().First(v => !v.Equals(value));
if (type == typeof(LeadIn))
return new ArcLeadIn { Radius = 7.77 };
if (type == typeof(LeadOut))
return new ArcLeadOut { Radius = 7.77 };
if (type == typeof(Tab))
return new MachineTab { MachineTabId = 77 };
if (type == typeof(SequenceParameters))
return new SequenceParameters { SmallCutoutWidth = 7.77 };
if (type == typeof(AssignmentParameters))
return new AssignmentParameters { MinGeometryLength = 7.77 };
Assert.Fail($"{member}: type {type.Name} is not fingerprinted; extend StateFingerprint.");
return null;
}
private static (Plate Plate, Part Part) SinglePart(CuttingParameters settings)
{
var rectangle = typeof(CuttingDependencyTests).GetMethod("Rectangle",
BindingFlags.Static | BindingFlags.NonPublic)!;
var part = (Part)rectangle.Invoke(null, [10.0, 10.0, 2.0, 2.0])!;
part.CuttingParameters = settings;
var plate = new Nest().CreatePlate();
plate.Parts.Add(part);
return (plate, part);
}
private sealed class DictionarySettings : CuttingParameters
{
public Dictionary<string, double> Values { get; } = [];
}
private struct ScalarSettings
{
public double Length { get; set; }
}
private sealed class StructSettings : CuttingParameters
{
public ScalarSettings Extra { get; set; }
}
private sealed class Node
{
public Node? Next { get; set; }
public double Length { get; set; }
}
private sealed class CycleSettings : CuttingParameters
{
public Node? Root { get; set; }
public static CycleSettings Create()
{
var root = new Node();
root.Next = new Node { Next = root };
return new CycleSettings { Root = root };
}
}
private sealed class DateSettings : CuttingParameters
{
public DateTime When { get; set; }
}
private sealed class ArraySettings : CuttingParameters
{
public int[,]? Values { get; set; }
}
private sealed class MutatingSettings : CuttingParameters
{
public double Bump => ++Kerf;
}
private sealed class EnumerableSettings : CuttingParameters
{
public bool Fail { get; set; }
public IEnumerable<int> Values
{
get
{
yield return 1;
if (Fail)
throw new IOException("changed enumerable");
}
}
}
private sealed class CustomLeadIn : LineLeadIn { }
private sealed class CustomLeadOut : ArcLeadOut { }
private sealed class CustomTab : NormalTab { }
private sealed class CustomSequence : SequenceParameters { }
private sealed class CustomAssignment : AssignmentParameters { }
}
@@ -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()
@@ -1,6 +1,6 @@
using OpenNest.Geometry;
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.Jobs.Placement.Fillers; using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Geometry;
namespace OpenNest.Tests.Engine; namespace OpenNest.Tests.Engine;
@@ -70,6 +70,27 @@ public class EngineRefactorSmokeTests
Assert.True(parts.Count > 0, "ForceFullAngleSweep should still produce results"); Assert.True(parts.Count > 0, "ForceFullAngleSweep should still produce results");
} }
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DefaultPlateFiller_StopAtQuantity_StillDeliversExactlyTheQuantity(bool stopAtQuantity)
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var filler = new DefaultPlateFiller(plate) { StopAtQuantity = stopAtQuantity };
var item = new NestItem { Drawing = MakeRectDrawing(7, 3), Quantity = 23 };
var parts = filler.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
Assert.Equal(23, parts.Count);
plate.Parts.AddRange(parts);
Assert.False(plate.HasOverlappingParts(out _), "Fill overlaps");
var area = plate.WorkArea();
Assert.All(parts, p => Assert.True(
p.BoundingBox.Left >= area.Left - 1e-6 && p.BoundingBox.Right <= area.Right + 1e-6
&& p.BoundingBox.Bottom >= area.Bottom - 1e-6 && p.BoundingBox.Top <= area.Top + 1e-6,
"Part outside the work area"));
}
[Fact] [Fact]
public void StripPlateFiller_Nest_ProducesResults() public void StripPlateFiller_Nest_ProducesResults()
{ {
@@ -0,0 +1,103 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest.Tests.Fill;
public class FillLinearMaxPartsTests
{
// 10 x 5 rectangles at 0.5 spacing in a 100 x 100 area: 9 per row, 18 rows.
private static readonly Box Area = new(0, 0, 100, 100);
private const double Spacing = 0.5;
private static Drawing Rectangle()
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(10, 5)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 5)));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
return new Drawing("rect", pgm);
}
private static List<(double X, double Y)> Poses(List<Part> parts) =>
parts.Select(p => (p.Location.X, p.Location.Y)).OrderBy(p => p.Y).ThenBy(p => p.X).ToList();
[Fact]
public void Uncapped_FillsTheWholeArea()
{
var parts = new FillLinear(Area, Spacing).Fill(Rectangle(), 0, NestDirection.Horizontal);
Assert.Equal(9 * 18, parts.Count);
}
[Fact]
public void Cap_StopsAddingRowsOnceReached_AndKeepsTheFullGridPrefix()
{
var drawing = Rectangle();
var full = new FillLinear(Area, Spacing).Fill(drawing, 0, NestDirection.Horizontal);
var capped = new FillLinear(Area, Spacing) { MaxParts = 20 }.Fill(drawing, 0, NestDirection.Horizontal);
// Whole rows only: 20 parts need three rows of nine.
Assert.Equal(27, capped.Count);
var rows = Poses(full).Select(p => p.Y).Distinct().OrderBy(y => y).Take(3).ToHashSet();
Assert.Equal(Poses(full).Where(p => rows.Contains(p.Y)).ToList(), Poses(capped));
}
[Fact]
public void Cap_SmallerThanTheFirstRow_ReturnsTheCompleteFirstRow()
{
var capped = new FillLinear(Area, Spacing) { MaxParts = 5 }.Fill(Rectangle(), 0, NestDirection.Horizontal);
Assert.Equal(9, capped.Count);
Assert.Single(capped.Select(p => p.Location.Y).Distinct());
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void NonpositiveCap_MatchesTheUncappedFill(int maxParts)
{
var drawing = Rectangle();
var full = new FillLinear(Area, Spacing).Fill(drawing, 0, NestDirection.Horizontal);
var capped = new FillLinear(Area, Spacing) { MaxParts = maxParts }.Fill(drawing, 0, NestDirection.Horizontal);
Assert.Equal(Poses(full), Poses(capped));
}
[Theory]
[InlineData(NestDirection.Horizontal, 10, 100)]
[InlineData(NestDirection.Vertical, 100, 5)]
public void Cap_PerpendicularOnlyFill_StopsAtQuantity(NestDirection direction, double length, double width)
{
var area = new Box(0, 0, length, width);
var parts = new FillLinear(area, Spacing) { MaxParts = 3 }.Fill(Rectangle(), 0, direction);
Assert.Equal(3, parts.Count);
var plate = new Plate(width, length) { PartSpacing = Spacing };
plate.Parts.AddRange(parts);
Assert.False(plate.HasOverlappingParts(out _));
Assert.All(parts, part => Assert.True(part.BoundingBox.Right <= area.Right
&& part.BoundingBox.Top <= area.Top));
}
[Fact]
public void Cap_DoesNotPlaceAPartThatCannotFit()
{
Assert.Empty(new FillLinear(new Box(0, 0, 4, 4), Spacing) { MaxParts = 1 }
.Fill(Rectangle(), 0, NestDirection.Horizontal));
}
[Fact]
public void Cap_LargerThanTheAreaHolds_MatchesTheUncappedFill()
{
var drawing = Rectangle();
var full = new FillLinear(Area, Spacing).Fill(drawing, 0, NestDirection.Horizontal);
var capped = new FillLinear(Area, Spacing) { MaxParts = 1000 }.Fill(drawing, 0, NestDirection.Horizontal);
Assert.Equal(Poses(full), Poses(capped));
}
}
@@ -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()
{ {
+32 -1
View File
@@ -1,9 +1,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Engine;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies; using OpenNest.Engine.Strategies;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Engine;
namespace OpenNest.Tests.Strategies; namespace OpenNest.Tests.Strategies;
@@ -227,6 +227,37 @@ public class StripeFillerTests
} }
} }
private static List<Part> StripeFill(int maxQuantity, NestDirection axis)
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
var context = new OpenNest.Engine.Strategies.FillContext
{
Item = new NestItem { Drawing = drawing, Quantity = maxQuantity },
WorkArea = new Box(0, 0, 120, 60),
Plate = plate,
Token = System.Threading.CancellationToken.None,
MaxQuantity = maxQuantity,
};
context.SharedState["BestFits"] = MakeSideBySideBestFits(drawing, 0.5);
return new StripeFiller(context, axis).Fill();
}
[Theory]
[InlineData(NestDirection.Horizontal)]
[InlineData(NestDirection.Vertical)]
public void Fill_WithMaxQuantity_StopsOnceTheQuantityIsHeld(NestDirection axis)
{
var full = StripeFill(0, axis);
var capped = StripeFill(6, axis);
Assert.True(full.Count > 6, $"Control fill should exceed the cap, got {full.Count}");
Assert.InRange(capped.Count, 6, full.Count - 1);
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
plate.Parts.AddRange(capped);
Assert.False(plate.HasOverlappingParts(out _), "Capped stripe fill overlaps");
}
[Fact] [Fact]
public void Fill_ReturnsEmpty_WhenNoBestFits() public void Fill_ReturnsEmpty_WhenNoBestFits()
{ {
@@ -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.");
}
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more