Author SHA1 Message Date
aj ea1270e1e7 fix(cutting): check every lead against all other material again
The second delta review compared the filtered checks with the previous
implementation on 5,400 generated cases. Rapid checks matched in every
case, but 114 lead checks differed: a long lead passing a small circle
or arc well over 0.001 away was reported clear, while the native
line/circle query, through rounding in its squared terms, reports a
contact there. A coordinate limit and a fixed margin cannot bound that
cancellation.

Lead checks therefore examine every other part's material again,
exactly as before the filter; LeadMaterialSnapshot no longer keeps an
extent. Rapid checks, including the pre-post review's, keep skipping
completed contours more than 0.001 clear of the rapid. The 900000-long
lead beside a radius-0.0001 circle is a regression test.

Planning a dense 144-part grid now takes about 19 s again (lead checks
dominate); a new part order is still found where the old search gave
up.
2026-10-06 00:47:46 -04:00
aj 8c21c62ec3 fix(cutting): skip checks only for well-clear, well-conditioned extents
The delta review found two more ways the extent filter could skip a
check a native query would have flagged:

- Native line intersections accept points 0.00001 outside each line's
  bounding box, so a lead 0.000003 from another part touched it while
  their extents were 0.000003 apart, beyond the 1e-6 margin.
- Around a circle of radius 5e11, rounding let the native query count
  a rapid at x = -0.00001 as touching although the circle's extent
  started at x = 0; the margin scaled only with the rapid's own size.

Rather than chase each tolerance, the filter now has one narrow rule:
Extent.IsClearOf skips only when both extents are finite, lie within
1e6 and are more than 0.001 apart on some axis. 0.001 is ten times the
widest absolute band of any native contact query (the 0.00001 box
allowance and the 0.0001 contact reach of tiny arcs), and within 1e6
rounding stays far below it. Larger geometry is always checked in
full, as before the filter. Planning speed is unchanged (144-part grid
about 0.5-0.8 s).

All three reproductions are regression tests, with a test of the rule
itself.
2026-10-06 00:20:41 -04:00
aj 1c0320d17e docs(cutting): describe the arc contact band in the extent filter
Follows c1c8d5f and c21f687: an arc's extent is its supporting circle widened to the native contact reach, and an extent with a nonfinite bound is never skipped.
2026-10-06 00:07:39 -04:00
aj 4e6419fd2c fix(cutting): fall back to a full order search when learning stops
Review found a plate the previous search planned that the tour now
refused. Left leads in on its left and right on its right, so right
straight after left crosses left and left straight after right crosses
right. Learning "right before left" then contradicted "left before
right" and the search returned ConstraintConflict, although cutting a
third part above them in between is safe.

A blocked approach only proves that one part cannot follow the parts
cut so far from that position, not a global order, so learned rules
stay a heuristic. Once nothing new can be learned, the remaining budget
now goes to a full search over every ready part, nearest first (the
search used before the tour). A rule contradicting an order already
required is still skipped rather than ending learning early.
2026-10-06 00:04:05 -04:00
aj c21f687987 fix(cutting): never treat an extent with a nonfinite bound as separated
IsSeparatedFrom promised that NaN bounds are never separated, but a
finite axis could still compare as separated when the other axis held
NaN. Program reading already refuses nonfinite coordinates, so no
public path was affected; the helper now checks every bound and the
margin are finite before comparing, which also covers the empty
extent of an incomplete material snapshot.
2026-10-06 00:03:24 -04:00
aj c1c8d5fc36 fix(cutting): widen arc extents to their native contact band
Review of the extent filter (bfe2e51) found it could skip checks the
native queries would have flagged. ContactAfterStart counts a point as
touching an arc while its squared distance from the centre is within
1e-8 x max(1, 2r) of r^2, which for small arcs reaches well past the
radius (up to 1e-4 as r -> 0), more than the filter's margin. A rapid
0.000002 outside a radius-0.001 circle lost its crossing, the pre-post
review lost the same finding, and a lead grazing a radius-0.003 circle
turned from an uncertain check into a clear one.

An arc's extent now uses that contact reach, computed from the same
slack expression as ContactAfterStart, so every point a native query
can count as contact lies inside it. The three cases above are
regression tests.
2026-10-06 00:03:00 -04:00
aj 2419a9e2e3 fix(cutting): give a new part order the plate's expansion budget
The desktop batch searched for a new part order with the flat 20000
default and kept the current order with 400 expansions per part. A new
order now also plans contour order and entries for every part (about
260 expansions per part on a dense grid), so a 100-part plate ran out
of budget and fell back to the current order. Both attempts now get
PlateBudget: 400 per part, at least 20000. The constants are renamed
MinimumExpansionBudget and ExpansionsPerPart to match.
2026-10-05 23:41:16 -04:00
aj b8f3d3bf0a fix(cutting): order parts as a short tour and learn blocked approaches
Free-order planning was one depth-first search over parts, contours and
entries. A dead end at one part backtracked through every entry
combination of the part before it (about 1,450 for a square with two
holes) before trying another part order, so a 4 x 4 grid of such parts
ran out of its 20000 expansions (and 200000) although cutting it row by
row is safe.

The whole-part order is now an open travelling-salesman path over part
centres from the start point: nearest neighbour, then 2-opt reversals
and Or-opt moves of one to three parts, never placing a part before a
cutoff or nested-part prerequisite. The existing search then plans
contour order and entries along that order. If a part cannot be
reached without crossing parts already cut, the search learns "cut it
before those", backs up to just before the earliest of them, keeps the
parts cut before that point and re-plans the rest from the tool
position there. An attempt stops backtracking after 8 x entries x
contours expansions without getting further, so it learns instead of
retrying the entries of every earlier part. When nothing new can be
learned the result is a refusal, as before. A preserved order is
planned exactly as before.

16- and 36-part grids, in row order and shuffled, are now ready within
the default budget (they were NoSolutionWithinBudget); a 144-part grid
plans in about half a second.
2026-10-05 23:41:11 -04:00
aj bfe2e518e6 perf(cutting): skip lead and rapid checks against distant material
Every lead was checked against every placed part's material and every
rapid against every completed contour, so each check cost O(parts) and
planning a plate cost O(parts^2). Lead checks were 88% of planning time
on a 144-part grid.

LeadMaterialSnapshot and each completed contour now keep a conservative
extent (an arc counts as its whole supporting circle). A lead or rapid
skips material or a contour only when the extents are farther apart
than 1e-6 x (1 + coordinate size), far above the contact tolerance, so
results are unchanged: anything touching or closer still gets the full
native check. The rapid filter also applies to pre-post verification,
which shares ReleasedContourState.

New tests cover the cases just inside the skip: an arc lead and a
completed arc whose bulge reaches past their endpoints, and a lead and
a rapid that only touch another part's extent.
2026-10-05 23:41:02 -04:00
aj 48367da820 fix(diagnostics): refuse absolute subprograms instead of normalizing
1b422ef read absolute-mode hole subprograms by converting an
incremental-mode copy of every clean program. Rebuilding absolute
endpoints from incremental deltas is not exact: after a rapid at
1e12 a 1x10 rectangle moved by about 2.4e-5 and a real 2e-5 overlap
was reported clear, in the overlap overlay and pre-post verification
as well as Plan Cutting.

Convert programs directly again, which reads absolute coordinates
exactly, and refuse an absolute-mode subprogram as an incomplete
check instead: the converter adds a call's frame offset to
incremental moves only, so it would read such a hole at its frame
origin. OpenNest writes hole subprograms in incremental mode. The
null-list and unknown-instruction refusals from 1b422ef stay, and
CopyForGeometry is private to the planner again.
2026-10-05 22:04:47 -04:00
aj 65d320d2e5 fix(ui): report capture failures in the Plan Cutting dialog
Capturing plates runs inside the dialog's message loop, where only
argument, operation and not-supported exceptions were caught, so any
other capture failure escaped to the editor. Show every capture
failure in the summary with Apply disabled instead.
2026-10-05 21:52:30 -04:00
aj 1b422ef99a fix(diagnostics): read absolute subprogram holes and refuse unknown codes
PlateOverlapAnalyzer converted clean programs directly with
ConvertProgram.ToGeometry, which adds call offsets only to
incremental moves, so holes in absolute-mode subprograms were read at
their frame origin. A square with two absolute holes reported
"Native material contours cross or touch", and the Plan Cutting
overlap gate blocked it while the incremental twin passed. Convert an
owned incremental-mode copy instead (PreparedContours.CopyForGeometry,
now internal, the same normalization the cutting planner uses).

The analyzer also threw on a program whose Codes list was null and
cast or cloned instructions it does not know. It now refuses a
missing list and anything other than the exact built-in instruction
types as an incomplete check, before any copy or conversion runs.
Pre-post verification and the plate overlap overlay share this path.
2026-10-05 21:52:30 -04:00
aj faf3c9bcc4 fix(ui): detach disposed plate views and keep repeated layout parts
A disposed PlateView stayed subscribed to its plate, so a plate that
outlived the view kept rebuilding the disposed view's layout and
raising its events; Dispose now detaches the part added, removed and
reordered handlers.

ObservableList.Reorder keeps repeated references, but the reorder
handler mapped each part to one layout, so [a, a, b] collapsed to one
shared layout for both occurrences of a. It now reuses each existing
layout once and creates one only when none is left.
2026-10-05 21:39:58 -04:00
aj 8590f21c9c fix(cutting): block overlapping plates and preview only fresh plans
Review fixes for the Plan Cutting batch and dialog:

- A plate whose clean part material overlaps, or cannot be checked
  for overlap, is no longer ready, whatever its route. The batch
  captures each plate's material with PlateOverlapAnalyzer on the
  owner thread, analyzes it on the worker, and names both parts.
  Before, two overlapping squares were Ready and Apply regenerated
  them (plan section 4.2: overlap warnings are not waived).
- BuildPreview returns null for a refused plate, whose program graphs
  may be unsafe to copy (an unsupported instruction's Clone ran, and a
  cyclic subprogram overflowed the stack), and for a plate that
  changed after planning, which drew the replayed program at the live
  pose. The dialog then shows no preview and says why.
- The dialog plans with its own copy of the caller's settings, shows
  a failure message if a plan cannot be presented, and has a worker
  seam so the close-while-planning test holds the worker instead of
  racing a slow search. Form tests now observe the planning task.
2026-10-05 21:39:58 -04:00
aj 1769b8907d fix(ui): post cutting-plan progress and results to the dialog thread
The dialog relied on Progress<T> and an await continuation capturing
SynchronizationContext.Current when planning started. WinForms
uninstalls its ambient context when the outermost DoEvents loop ends,
so a plan started outside a message loop reported progress from a
thread-pool thread and crashed the test host with "Error creating
window handle" (windows-desktop job of run 37398775280,
CuttingPlanFormTests.ApplyAfterALiveEdit_ChangesNothingAndReplanRecovers).

Capture the dialog's context once when it is built and post progress
and the finished plan to it explicitly. The busy-editor test now
starts planning after a DoEvents loop to cover that path.
2026-10-05 21:27:42 -04:00
aj 187234f459 feat(ui): preview and apply unified cutting plans
Plate > Plan Cutting... and Nest > Plan Cutting (All Plates)... open
one dialog over CuttingPlanBatch. It starts from the plate's (or the
last-used) cutting settings and plans at once; Cutting Settings and
Keep the current part order replan. The summary lists every plate
with its status and findings, and a read-only preview shows the
active plate in the proposed order with its proposed programs.

Apply is enabled only when every plate is ready and installs all of
them or none; a stale plan keeps the dialog open and asks for a
replan. Closing while planning cancels the worker and waits for it.
The menu commands share the busy guard of the other plate tools, and
after a successful Apply the confirmed settings become the saved
defaults. The older automatic sequencing and lead-in assignment
commands stay until they are migrated and retired.
2026-10-05 21:21:38 -04:00
aj a0ac7e5756 feat(ui): redraw the plate view when its plate is reordered
PlateView now follows Plate.PartsReordered, which an applied cutting
plan raises once per changed plate: it puts its layout parts in the
plate's order (the drawn part numbers are the cutting order), marks
them dirty so new programs are redrawn, and raises its own
PartsReordered. The overlap overlay treats a reorder like any other
part change and marks its report out of date.
2026-10-05 21:21:38 -04:00
aj 9a4f73b924 feat(cutting): plan and apply plates as one desktop batch
CuttingPlanBatch captures every scoped plate on the owner thread with
an owned copy of the confirmed cutting parameters, plans them on a
worker with per-plate progress and cancellation, and returns a
proposal that applies all or nothing through CuttingPlanService.Apply.

A free-order search that ends NoSolutionWithinBudget is retried once
with the current part order, captured up front so the worker never
reads live plates; the proposal reports that the order was kept. A
kept order gets 400 expansions per part (at least 20000), since it
still searches contour order and entries.

Only after Apply succeeds does each plate keep its own copy of the
settings. The proposal builds detached preview plates (quantity zero)
and describes each plate's status and findings with part numbers.
2026-10-05 21:21:38 -04:00
aj 248972c6e7 fix(ui): keep refused BOM cell text out of the rows on every commit path
The first windows-desktop run of BomImportFormTests (PR #4, run
37379965924) failed four cases:

- InvalidQty_IsRefusedAndTheOldValueKept("0" / "-1" / ""): the tests
  committed with DataGridView.EndEdit(), which never raises
  CellValidating, so the grid's default parser stored 0, -1 or null.
- BlankingMaterialOrThickness...: a null value from CellParsing is
  ignored by the grid, so a blank material was stored as " ".

Operator commits (Enter, leaving the cell or the grid) do validate, but
a programmatic EndEdit() must not store text validation refuses either.
CellParsing now keeps the row's current quantity or thickness for text
validation would refuse, trims the material (a blank one is ""), and a
cleared thickness or quantity cell stores null through the columns'
DataSourceNullValue. A thickness of only spaces is refused like any
other invalid text.

The tests now commit by pressing Enter (ProcessEnterKey), as the
operator does, and a new ProgrammaticCommitOfInvalidText test covers
the EndEdit() path.
2026-10-05 18:09:45 -04:00
aj 124d70afe7 feat(ui): edit quantities in the BOM part table
The BOM import dialog's Parts table now binds to the BomPartRow objects
themselves (a BindingList with Designer-defined columns) instead of a
DataTable of strings:

- Columns: Item #, File Name, Description, Material, Thickness, Qty,
  Status. Only Material, Thickness and Qty are editable, and only on
  rows that have a drawing; those rows are grey and locked. Rows that
  still need input are tinted amber.
- Qty is editable. Anything but a whole number of 1 or more is refused
  in the cell with an error icon; Esc restores the old value. A
  blank-in-BOM quantity shows a tooltip saying 1 is used.
- A thickness that is not a number above 0 is refused the same way;
  blanking it marks the row "Needs thickness".
- Every edit goes through the row's bound part, so the Groups tab
  (part count and Total Qty) and the summary follow at once, and an
  edit can never land on a different part. Rows stay in BOM order:
  the headers no longer sort, which removes the sort-then-edit defect
  where index mapping applied an edit to another part.
- Create Nests first finishes the cell being edited.

BomImportFormTests (OpenNest.WinForms.Tests) cover the columns, the
quantity edit and its group total, refused quantities, edits after
the rows are reloaded in another order, locked rows and blanked
material/thickness. They first run on the windows-desktop CI job; the
sort defect's red evidence is source review, not an executed test.

Project Memory: 5b0808d1-7353-4a34-87df-d1fbfc38f2b4
2026-10-05 18:01:54 -04:00
aj 81d3253580 feat(bom): validate edited part quantities
Prepare BOM rows for quantity editing:

- BomQuantity.TryParse accepts only a whole number of at least 1
  (surrounding spaces allowed; no sign, decimal, exponent, separator or
  overflow).
- BomPartRow keeps the BOM's own quantity (BomQty), refuses invalid
  typed quantities in TrySetQuantity, and raises PropertyChanged for
  Material, Thickness and Qty plus the status they affect, so a bound
  grid can refresh.
- A blank BOM quantity becomes 1, as it was imported before, but is now
  visible in the row and counted in the summary ("had no BOM quantity
  (1 used)"). A BOM quantity below 1 is no longer imported as 0: the
  row reads "Needs quantity" until the operator enters one.

BomImportFlowTests covers BOM items to rows to an edited quantity to the
group total and the created nest's required quantity. Mutation reds:
a zero floor, signed or decimal input, ignoring the quantity in the
status, not assuming 1 for blanks, setting unparsed text and dropping
the status notification each fail the Bom tests.
2026-10-05 17:56:35 -04:00
aj 0ac72fad70 fix(bom): combine BOM rows that use the same drawing in one nest
When two BOM rows named the same drawing file and shared a material and
thickness (the same part in two subassemblies), Create Nests imported
the file twice. Nest.Drawings is a set keyed by drawing name, so the
second drawing was dropped with its quantity: PT01 x2 plus PT01 x3
gave a nest needing 2, while the Groups tab showed 5.

Each drawing file is now imported once and needs the total of its
rows. Build_CombinesRowsThatUseTheSameDrawing required [2, 1] where
[5, 1] was expected against the previous commit.

Project Memory: 44293f97-a027-4c29-bc2d-bb789c796746
2026-10-05 17:52:32 -04:00
aj b83aeb0e41 fix(bom): find the drawing for rows that still need a thickness
A BOM row with a file name and a matching drawing but a blank thickness
was reported "No DXF" and locked, so its thickness could not be entered
and the part was dropped: BomAnalyzer skips such items before looking
for a drawing, and the form only knew drawings the analyzer matched.
A row with a blank material read "Matched" but was silently left out
of every group.

Rows now resolve their drawing through a DrawingFileIndex shared with
BomAnalyzer (whose behavior and tests are unchanged), and a row's status
is computed from its values:

  Ready | Needs material | Needs thickness | No drawing found | No file name

Rows with a drawing stay editable, and an edit updates the status cell.
Groups take only Ready rows (a zero, negative or non-finite thickness is
not Ready). The summary line counts ready rows and each problem.

Build_RowWithoutThickness_StillFindsItsDrawing failed at its DxfPath
assertion against the previous commit. Mutation reds: removing the
material check, accepting zero or non-finite thickness, skipping the
drawing for rows without thickness, and grouping without the status
each fail the Bom tests.

Project Memory: b8e3a978-2cfe-4fe7-b2a5-eb01c6c54679
2026-10-05 17:50:54 -04:00
aj f4d1d45c81 fix(bom): match BOM file names that include the drawing extension
A BOM row whose File Name carried its extension ("PT01.dxf") showed
"No DXF", was locked and was never imported, although BomAnalyzer had
found the file: the matched paths were stored under the raw BOM name
and looked up by the name without the extension. Both sides now use
the extension-less name.

The new BomImportRowsTests row failed at its IsEditable assertion
against the previous commit.

Project Memory: f3be7a4b-e679-470c-bb52-7057899228e4
2026-10-05 17:46:48 -04:00
aj ca153a1c20 refactor(bom): build import rows, groups and nests in OpenNest.IO
Move the BOM import dialog's row building, grouping and per-group nest
construction out of BomImportForm into OpenNest.IO/Bom so they can be
tested on Linux:

- BomPartRow (now public) and BomImportRows.Build, a verbatim move of
  the form's BuildPartRows (still via BomAnalyzer).
- BomImportGroups.Build: one grouping used by both the Groups tab and
  Create Nests, which each carried their own copy. Create Nests now
  creates nests in the Groups tab's order (material, then thickness).
- BomNestBuilder.Build: the nest for one group (saved defaults first,
  then the group's plate size, spacing, material and thickness; one
  drawing per row with the row's quantity, 1 when blank).

The form keeps the file dialogs, grids, EditNestForm windows and the
completion message. Behavior is otherwise unchanged; the two row-status
defects found while planning are fixed in the following commits.
2026-10-05 17:45:16 -04:00
aj 03b3ed47b2 fix(nest-info): keep material grade and density when the name is unchanged
Nest Info edits only the material name, but SaveNestInfo replaced the
nest's material with new Material(name), so pressing OK without touching
the material silently dropped its grade and density.

SaveNestInfo now goes through NestMaterialSelection.Apply in OpenNest.Data:
a name with the same SharedListNames.Key as the current material (trimmed,
whitespace collapsed, invariant upper case) keeps its grade and density
and takes the typed name; any other name gets a name-only material, as
before. It always returns a new Material. SharedListNames.Key is the
name normalizer the shared customer/material lists will use.

Behavioral reds (each restored byte-identically): restoring the old
name-only path fails both keep-grade tests; exact-string matching,
dropping the upper-casing or the whitespace collapse fails the
normalized-name test; returning the current instance fails the copy test.
EditNestInfoFormTests cover Load -> Save through the real form; they are
cross-compiled here and execute in the windows-desktop CI job.
2026-10-05 16:31:00 -04:00
aj 1b5a6bf6da ci(release): kill and name hung Windows tests in the release build
The release workflow is the only hosted job that runs the Core, Engine,
IO and Server suites on Windows, and it had no hang guard: a silent test
would hold the runner until the 30-minute cap with no clue which test
hung. Apply the guard the windows-desktop CI job already uses:
--blame-hang-timeout 5m with a mini dump, and upload all of TestResults
rather than only the TRX files.

The suites have roughly doubled since the last release run (7.5 min),
so raise the job cap to 45 minutes. Validated with actionlint.
2026-10-05 16:26:22 -04:00
aj 3a8419e6e9 fix(ui): show the newly selected plate when a Plates row is chosen
The plate preview read DataGridView.CurrentRow in SelectionChanged, but
that event runs before CurrentCell moves, so CurrentRow still named the
previous row. Choosing a row showed the plate that had been selected
before it, and the next-plate button snapped the preview back to the
old plate. Read the selected row instead.

The Windows job of run 37341610182 caught it:
PlatePreview_StepsThroughThePlates_AndFollowsThePlatesTable expected
"Plate 2 of 2" after the next-plate button and got "Plate 1 of 2". The
test now also checks that the selected row follows the button.
2026-10-05 12:47:56 -04:00
aj da0c54f280 feat(ui): preview the highlighted nest's plates in the Open dialog
The Details area of the Database-mode Open dialog now has a plate
preview beside the Plates/Drawings tabs, with a draggable divider. It
draws one plate of the highlighted nest; the arrow buttons below it step
through the plates ("Plate 2 of 5"), and choosing a row on the Plates tab
shows that plate, so the table and the preview stay on the same plate.
The preview is read-only (no selection or drop) and refits when resized.

NestDetails keeps the downloaded nest's plates (PlateLayouts) beside its
rows, so the preview draws the copy already read for the tables with no
second download.

Tests: NestDetails keeps every plate in nest order (fails with the
assignment removed). The Windows form test steps forward with the
button, checks the row follows, and selects a row back.
2026-10-05 11:44:22 -04:00
aj 9920aba6eb feat(ui): open Database-mode nests from a larger saved-nest browser
File > Open in Database mode now shows a resizable browser instead of
the small list dialog. The upper Nests grid keeps the server-side
search, sort and paging (row numbers count through the filtered list;
the title shows the server and the range). Below it, Details tabs list
the highlighted nest's plates and drawings, read from its archive after
the highlight rests for 250 ms; moving back to a nest already shown
reuses its result, and a missing archive is reported in the details
line.

Enter opens the highlighted nest (in the Find box it runs the search at
once instead) and Esc closes. Double-click opens a nest, and a context
menu offers Open, Delete (after confirmation) and Refresh. It only
browses saved nests; new nests are still created from the main window.

Windows tests drive the real form: page load, details for two nests,
Enter opening the highlighted one, the missing-archive message and Esc.
2026-10-05 11:38:28 -04:00
aj 0446127e11 feat(data): build saved-nest plate and drawing details
The server stores only nest-level metadata, so a browser that shows a
saved nest's plates and drawings has to read them from its archive.
NestDetails.FromNest turns a nest into one row per plate (duplicates,
size, parts and distinct drawings without cutoffs, utilization; 0 for a
zero-size plate rather than NaN) and one row per non-cutoff drawing
(required, nested across every plate's duplicates, remaining, area),
plus the nest's units.

NestDetailsSession loads the details of the highlighted nest. A new
load or Clear supersedes the previous one before cancelling it, so a
load that completes synchronously on cancellation and any late result
or failure of a superseded load are discarded; IsLoading follows only
the latest load.
2026-10-05 11:26:02 -04:00
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
192 changed files with 22966 additions and 1083 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
- name: Run OpenNest.Engine.Tests
run: dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
- name: Check six synthetic Irregular nests
timeout-minutes: 6
run: |
python3 scripts/test_check_synthetic_nests.py -v
python3 scripts/check-synthetic-nests.py --parallel 2
- name: Run OpenNest.IO.Tests
run: dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
- name: Run OpenNest.Server.Tests
run: dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj
windows-desktop:
# WinForms tests need a Windows desktop runtime; the Linux job can only
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
runs-on: windows-2022
timeout-minutes: 20
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Run Windows desktop and front-end tests
# A test silent for 5 minutes is killed and named in the log, with a
# mini dump in TestResults, instead of holding the runner until the
# job timeout.
run: |
$failed = @()
foreach ($project in @('OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { $failed += $project }
}
if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" }
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-desktop-test-results
path: TestResults/
if-no-files-found: warn
retention-days: 14
+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
+10 -5
View File
@@ -17,7 +17,9 @@ permissions:
jobs:
windows:
runs-on: windows-2022
timeout-minutes: 30
# The suites have roughly doubled since the v0.3.0 run (7.5 min); the hang
# guard below names a stuck test long before this cap.
timeout-minutes: 45
defaults:
run:
shell: pwsh
@@ -48,12 +50,15 @@ jobs:
dotnet build OpenNest.sln -c Release
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
- name: Run all test projects on Windows
# A test silent for 5 minutes is killed and named in the log, with a
# mini dump in TestResults, instead of holding the runner until the
# job timeout.
run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
}
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --blame-hang-timeout 5m --blame-hang-dump-type mini --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw 'Debug tests failed.' }
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION
@@ -85,6 +90,6 @@ jobs:
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-test-results
path: TestResults/*.trx
path: TestResults/
if-no-files-found: warn
retention-days: 14
+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
# Cross-platform suites: run independently on Linux/macOS/Windows
dotnet test OpenNest.Tests/OpenNest.Tests.csproj
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
# Use Release for routine runs; use Debug explicitly for DEBUG-only work counters.
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
# Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using Curve = OpenNest.Diagnostics.PostVerificationGeometry.Curve;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Independently constructed expected geometry, frozen before final replay.
/// It is not a search verdict or the payload returned to the caller.</summary>
internal sealed class SelectedContourProgram
{
private readonly PreparedContours owner;
private readonly ContourChoice[] choices;
internal OwnedExecution Expected { get; }
internal double TabSize { get; }
internal SelectedContourProgram(PreparedContours owner, IReadOnlyList<ContourChoice> choices,
OwnedExecution expected, double tabSize)
{
this.owner = owner;
this.choices = choices.ToArray();
Expected = expected;
TabSize = tabSize;
}
internal bool Matches(PreparedContours prepared, IReadOnlyList<ContourChoice> selected)
{
if (!ReferenceEquals(owner, prepared) || !choices.SequenceEqual(selected)) return false;
prepared.ValidateCompleteChoices(selected);
return true;
}
}
/// <summary>Directed native boundary accounting and selected-emission correspondence.
/// No tessellation, topology inference, live source reads or final emission.</summary>
internal static class ContourProgramVerifier
{
private const double Epsilon = PostVerificationGeometry.Epsilon;
internal static bool Verify(OwnedExecution actual, LeadMaterialSnapshot material,
SelectedContourProgram selected, CancellationToken token)
{
var budget = 1000000;
if (material == null || !material.IsComplete) return false;
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
var visited = new HashSet<int>();
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
{
token.ThrowIfCancellationRequested();
if (run.Curves.Count == 0) return false;
var first = run.Curves[0];
var ringIndex = -1;
var entityIndex = -1;
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
for (var e = 0; e < material.Rings[r].Length; e++)
{
Query(token, ref budget);
var curve = material.Rings[r][e];
if (curve.SameDirection(first) && curve.Contains(first.Start)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
ringIndex = r;
entityIndex = e;
break;
}
}
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
var ring = material.Rings[ringIndex];
var nominalLength = ring.Sum(c => c.Length);
var cutLength = run.Curves.Sum(c => c.Length);
var missing = nominalLength - cutLength;
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
if (missing > Epsilon)
{
// Fixed legacy payloads have no certified selected tab/entry metadata.
// A merely open contour or a caller's current tab switch proves nothing.
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon)
return false;
// SameEmission has already bound this exact terminal gap to the owned
// settings' independently emitted, rounded/clamped selected geometry.
}
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
true, token, ref budget)) return false;
}
return visited.Count == material.Rings.Count;
}
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
CancellationToken token, ref int budget)
{
var left = Runs(actual);
var right = Runs(expected);
if (left.Count != right.Count) return false;
for (var i = 0; i < left.Count; i++)
{
Query(token, ref budget);
var a = left[i];
var b = right[i];
if (a.Kind != b.Kind || a.Rapid != b.Rapid) return false;
if (a.Rapid)
{
// Arrival is supplied anew by replay, not captured expected geometry.
if (a.End.DistanceTo(b.End) > Epsilon) return false;
}
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|| !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false;
}
return true;
}
// Consume directed native arc-length from both streams. Subdivision, merged
// collinear moves and a cyclic ring's reindexing do not change coverage.
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
int index, double offset, bool cyclic, CancellationToken token, ref int budget)
{
foreach (var curve in actual)
{
var consumed = 0.0;
while (consumed < curve.Length - Epsilon)
{
Query(token, ref budget);
if (index >= expected.Count)
{
if (!cyclic) return false;
index = 0;
}
var nominal = expected[index];
var remaining = nominal.Length - offset;
if (remaining <= 0)
{
index++;
offset = 0;
continue;
}
var length = System.Math.Min(remaining, curve.Length - consumed);
if (!nominal.SameDirection(curve)
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
return false;
consumed += length;
offset += length;
if (offset >= nominal.Length - Epsilon)
{
index++;
offset = 0;
}
}
}
return cyclic || index == expected.Count || index == expected.Count - 1
&& expected[index].Length - offset <= Epsilon;
}
private static List<Run> Runs(OwnedExecution execution)
{
var runs = new List<Run>();
foreach (var motion in execution.Motions)
{
var kind = motion.Layer is LayerType.Cut or LayerType.Display ? LayerType.Cut : motion.Layer;
if (motion.Rapid)
{
runs.Add(new Run(true, kind, motion.End));
continue;
}
if (motion.Length <= Epsilon) continue; // Zero travel contributes no coverage.
if (runs.Count == 0 || runs[^1].Rapid || runs[^1].Kind != kind)
runs.Add(new Run(false, kind, motion.End));
runs[^1].Curves.Add(motion.Curve);
}
return runs;
}
private static void Query(CancellationToken token, ref int budget)
{
token.ThrowIfCancellationRequested();
if (--budget < 0) throw new NotSupportedException("Replay contour accounting exceeds the native query limit.");
}
private sealed record Run(bool Rapid, LayerType Kind, Vector End)
{
internal List<Curve> Curves { get; } = new();
}
}
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>An owned planned program for one unlocked part; ownership passes to the part on Apply.</summary>
internal sealed class PlannedPartProgram
{
public PlannedPartProgram(Part part, Program program, CuttingParameters parameters)
{
Part = part;
Program = program;
Parameters = parameters;
}
public Part Part { get; }
public Program Program { get; }
public CuttingParameters Parameters { get; }
}
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
internal sealed class PlateCuttingPlan
{
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null)
{
Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray());
}
public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; }
}
public enum CuttingCommitStatus
{
Applied,
Stale,
InvalidInput,
Cancelled,
Failed
}
/// <summary>
/// Applied means every plate in scope holds its new state. RefreshErrors are observer failures
/// raised after that consistent state was published; they are not a rollback. Every other
/// status leaves every plate exactly as it was.
/// </summary>
public sealed class CuttingCommitResult
{
internal CuttingCommitResult(CuttingCommitStatus status, string message = null, Plate plate = null,
Exception error = null, IEnumerable<Exception> refreshErrors = null)
{
Status = status;
Message = message;
Plate = plate;
Error = error;
RefreshErrors = Array.AsReadOnly((refreshErrors ?? []).ToArray());
}
public CuttingCommitStatus Status { get; }
public string Message { get; }
public Plate Plate { get; }
public Exception Error { get; }
public IReadOnlyList<Exception> RefreshErrors { get; }
}
/// <summary>
/// Installs verified cutting plans for a whole scope at once. Nothing is searched, emitted,
/// rotated or regenerated here: inputs are validated and checked for freshness, bounds are
/// staged, then order and programs are installed synchronously and published once per plate.
/// Internal: it checks root program references only, so payloads must be owned copies of
/// independently replayed proposals. CuttingPlanService.Apply is the public entry point.
/// </summary>
internal static class CuttingPlanCommit
{
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token = default) =>
Apply(plans, token, null);
// beforeInstall is a test seam that runs inside the install boundary, before each program.
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token,
Action<Plate, Part> beforeInstall)
{
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var scope = plans?.ToArray();
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
return Invalid(null, "A nonempty set of captured plate plans is required.");
var plates = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
foreach (var plan in scope)
if (!plates.Add(plan.Expected.Plate))
return Invalid(plan.Expected.Plate, "A plate appears more than once in the commit scope.");
// Freshness for the whole scope before any validation that reads live geometry.
foreach (var plan in scope)
{
string difference;
try
{
difference = plan.Expected.Difference(token);
}
catch (OperationCanceledException)
{
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
}
if (difference != null)
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
}
var staged = new List<Staged>(scope.Length);
var targets = new HashSet<Part>(ReferenceEqualityComparer.Instance);
var members = new HashSet<Part>(ReferenceEqualityComparer.Instance);
var installed = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var live = new HashSet<Program>(scope.SelectMany(p => p.Expected.Order).Select(p => p.Program),
ReferenceEqualityComparer.Instance);
foreach (var plan in scope)
{
var plate = plan.Expected.Plate;
Part[] order;
try
{
order = plate.Parts.ValidateReorder(plan.Order);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Invalid(plate, "The planned order is not exactly the plate's current parts.");
}
// A part on two plates would let one plate's install change the other's verified program.
if (order.Any(part => !members.Add(part)))
return Invalid(plate, "A part appears on more than one plate in the commit scope.");
var onPlate = new HashSet<Part>(order, ReferenceEqualityComparer.Instance);
var programs = new List<(Part, Program, Box, CuttingParameters)>();
foreach (var planned in plan.Programs)
{
if (planned?.Part == null || !onPlate.Contains(planned.Part) || !targets.Add(planned.Part))
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
if (planned.Part.LeadInsLocked)
return Invalid(plate, "A locked part's program is retained exactly and cannot be replaced.");
if (planned.Program == null || planned.Parameters == null || live.Contains(planned.Program)
|| !installed.Add(planned.Program))
return Invalid(plate, "Planned programs and settings must be present, owned and unshared.");
if (!planned.Program.Codes.Any(code => code is Motion
|| code is SubProgramCall call && call.Program?.Codes.Any(sub => sub is Motion) == true))
return Invalid(plate, "A planned program has no motion.");
Box bounds;
try
{
bounds = planned.Program.BoundingBox();
bounds.Offset(planned.Part.Location);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Invalid(plate, "A planned program's bounds cannot be computed.");
}
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
}
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
}
// Last cancellation point. The boundary below has no await, search or geometry work.
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
try
{
foreach (var item in staged)
{
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
item.Plate.Parts.SetOrder(item.Order);
foreach (var (part, program, bounds, parameters) in item.Programs)
{
beforeInstall?.Invoke(item.Plate, part);
part.InstallPlannedProgram(program, bounds, parameters);
}
}
}
catch (Exception ex)
{
for (var i = undo.Count - 1; i >= 0; i--)
{
foreach (var (part, state) in undo[i].States)
part.RestoreCuttingState(state);
undo[i].Plate.Parts.SetOrder(undo[i].Order);
}
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
undo.Count == 0 ? null : undo[^1].Plate, ex);
}
// Publish only after the whole scope is consistent.
var errors = new List<Exception>();
foreach (var item in staged.Where(s => s.Changed))
item.Plate.Parts.RaiseItemsReordered(errors);
return new(CuttingCommitStatus.Applied, errors.Count == 0 ? null
: "Applied; one or more views failed to refresh.", refreshErrors: errors);
}
private static CuttingCommitResult Invalid(Plate plate, string message) =>
new(CuttingCommitStatus.InvalidInput, message, plate);
private sealed record Staged(Plate Plate, Part[] Order,
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
}
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Guarded read of stable placed programs; never clones or mutates their graphs.</summary>
public static class ExecutionMotionReader
{
public static OwnedExecution Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
=> ReadCore(program, origin, previous, token, false);
/// <summary>Read only exact built-in runtime types and defined program modes.
/// Validate the original graph before cloning: Clone can erase unsupported subclasses.
/// Inputs must remain stable during this bounded, cancellation-aware read.</summary>
public static OwnedExecution ReadSupported(Program program, Vector origin, Vector? previous,
CancellationToken token = default)
=> ReadCore(program, origin, previous, token, true);
private static OwnedExecution ReadCore(Program program, Vector origin, Vector? previous,
CancellationToken token, bool requireSupportedTypes)
{
var moves = new List<ExecutionMotion>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return new OwnedExecution(moves);
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
if (requireSupportedTypes && (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode)))
throw new NotSupportedException("Unsupported program runtime type or mode.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (requireSupportedTypes)
{
var type = code.GetType();
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
throw new NotSupportedException("Unsupported instruction runtime type.");
}
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
}
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Owned nominal material: one simple perimeter minus disjoint simple holes.</summary>
public sealed class LeadMaterialSnapshot
{
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, string reason)
{
Rings = rings;
Reason = reason;
}
public bool IsComplete => Reason == null;
public string Reason { get; }
internal IReadOnlyList<PostVerificationGeometry.Curve[]> Rings { get; }
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
CancellationToken token = default)
{
try
{
var execution = ExecutionMotionReader.ReadSupported(cleanProgram, location, null, token);
var rings = new List<PostVerificationGeometry.Curve[]>();
var chain = new List<PostVerificationGeometry.Curve>();
foreach (var motion in execution.Motions)
{
token.ThrowIfCancellationRequested();
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout)
{
Finish();
continue;
}
if (motion.Layer is not (LayerType.Cut or LayerType.Display))
throw new ArgumentException("Material capture contains an unsupported layer.");
var curve = motion.Curve;
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contains a degenerate motion.");
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve);
if (PostVerificationGeometry.Closed(chain))
Finish();
}
Finish();
if (rings.Count == 0)
throw new ArgumentException("Material has no closed contour.");
var budget = 1000000;
// Certify simple rings and mutually disjoint boundaries before containment.
for (var r = 0; r < rings.Count; r++)
for (var s = r; s < rings.Count; s++)
for (var i = 0; i < rings[r].Length; i++)
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Material validation exceeds the native query limit.");
var a = rings[r][i];
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon)
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon)))
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
var outer = -1;
for (var i = 0; i < rings.Count; i++)
{
token.ThrowIfCancellationRequested();
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
{
if (outer >= 0)
throw new ArgumentException("Material perimeter is ambiguous.");
outer = i;
}
}
if (outer < 0)
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
var holes = rings.Where((_, i) => i != outer).ToArray();
for (var i = 0; i < holes.Length; i++)
for (var j = i + 1; j < holes.Length; j++)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), null);
void Finish()
{
if (chain.Count == 0)
return;
if (!PostVerificationGeometry.Closed(chain))
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
rings.Add(chain.ToArray());
chain.Clear();
}
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), ex.Message);
}
}
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
{
var inside = false;
foreach (var curve in ring)
{
token.ThrowIfCancellationRequested();
if (curve.CrossesRay(point))
inside = !inside;
}
return inside;
}
}
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>A complete unsafe result is distinct from an incomplete/unsupported check.</summary>
public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, string Reason);
/// <summary>Certifies actual emitted native lead paths against owned nominal material.</summary>
public static class LeadPathValidator
{
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (execution == null || target == null || otherMaterials == null)
return new(false, false, "Missing execution or material snapshots.");
if (!target.IsComplete || otherMaterials.Any(m => m == null || !m.IsComplete))
return new(false, false, "Material snapshot is incomplete.");
try
{
var budget = 1000000;
for (var i = 0; i < execution.Motions.Count; i++)
{
token.ThrowIfCancellationRequested();
var move = execution.Motions[i];
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
continue;
if (move.Rapid || move.Start is not { } start || move.Curve == null
|| !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon
|| start.DistanceTo(move.Curve.Start) > PostVerificationGeometry.Epsilon
|| move.End.DistanceTo(move.Curve.End) > PostVerificationGeometry.Epsilon)
return new(false, false, "Lead motion is missing, degenerate or inconsistent.");
Vector? allowed = null;
var groupEdge = i;
var step = move.Layer == LayerType.Leadin ? 1 : -1;
while (groupEdge + step >= 0 && groupEdge + step < execution.Motions.Count
&& execution.Motions[groupEdge + step].Layer == move.Layer)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
groupEdge += step;
}
var adjacentIndex = groupEdge + step;
var genuineJoint = false;
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
{
var adjacent = execution.Motions[adjacentIndex];
if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
{
var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
foreach (var boundary in target.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
if (joint.DistanceTo(contourJoint) <= PostVerificationGeometry.Epsilon
&& boundary.SameSupport(adjacent.Curve)
&& boundary.Contains(adjacent.Curve.Start) && boundary.Contains(adjacent.End)
&& boundary.Contains(adjacent.Curve.Midpoint)
&& adjacent.Length <= boundary.Length + PostVerificationGeometry.Epsilon)
{
genuineJoint = true;
if (groupEdge == i)
allowed = joint;
break;
}
}
}
}
if (!genuineJoint)
return new(true, false, "Lead chain has no genuine adjacent target contour entry or exit.");
if (allowed is { } jointPoint
&& (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon)
return new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only.");
var failure = CheckMaterial(target, allowed);
if (failure != null)
return new(true, false, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure}).");
foreach (var material in otherMaterials)
{
token.ThrowIfCancellationRequested();
if (ReferenceEquals(material, target))
continue;
if (CheckMaterial(material, null) != null)
return new(true, false, "Lead contacts or enters another placed material.");
}
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
{
foreach (var boundary in material.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = move.Curve.Contacts(boundary, out var overlap);
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
}
// With all other boundary contacts excluded, the connected open path
// has constant material membership. Use the native arc midpoint, not
// the chord midpoint or endpoints (which can both lie in scrap).
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
}
}
// Missing leads are the ReleasedContourState check's responsibility.
return new(true, true, null);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return new(false, false, ex.Message);
}
}
}
@@ -0,0 +1,139 @@
using System;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Copies only supported, exact setting types; never slices custom subclasses.</summary>
internal static class OwnedCuttingParameters
{
internal static CuttingParameters Copy(CuttingParameters source)
{
Exact<CuttingParameters>(source);
Exact<SequenceParameters>(source.Sequencing);
Exact<AssignmentParameters>(source.Assignment);
var sequence = source.Sequencing;
var assignment = source.Assignment;
if (!Enum.IsDefined(sequence.Method) || !Enum.IsDefined(assignment.Method))
throw new ArgumentException("Unsupported sequence method.");
if (assignment.Preference == null)
throw new ArgumentException("Missing assignment preference.");
if (!double.IsFinite(source.LeadInAngleIncrement) || source.LeadInAngleIncrement <= 0
|| source.LeadInAngleIncrement > 360)
throw new ArgumentException("Angle increment must be in (0, 360] degrees.");
return new CuttingParameters
{
Id = source.Id,
MachineName = source.MachineName,
MaterialName = source.MaterialName,
Grade = source.Grade,
Thickness = Dimension(source.Thickness),
Kerf = Dimension(source.Kerf),
PartSpacing = Dimension(source.PartSpacing),
PierceClearance = Dimension(source.PierceClearance),
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
RoundLeadInAngles = source.RoundLeadInAngles,
LeadInAngleIncrement = source.LeadInAngleIncrement,
AutoTabMinSize = Dimension(source.AutoTabMinSize),
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
TabConfig = source.TabConfig == null
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
: CopyTab(source.TabConfig),
TabsEnabled = source.TabsEnabled,
Sequencing = new SequenceParameters
{
Method = sequence.Method,
SmallCutoutWidth = Dimension(sequence.SmallCutoutWidth),
SmallCutoutHeight = Dimension(sequence.SmallCutoutHeight),
MediumCutoutWidth = Dimension(sequence.MediumCutoutWidth),
MediumCutoutHeight = Dimension(sequence.MediumCutoutHeight),
DistanceMediumSmall = Dimension(sequence.DistanceMediumSmall),
AlternateRowsColumns = sequence.AlternateRowsColumns,
AlternateCutoutsWithinRowColumn = sequence.AlternateCutoutsWithinRowColumn,
MinDistanceBetweenRowsColumns = Dimension(sequence.MinDistanceBetweenRowsColumns)
},
Assignment = new AssignmentParameters
{
Method = assignment.Method,
Preference = assignment.Preference,
MinGeometryLength = Dimension(assignment.MinGeometryLength)
}
};
}
private static LeadIn CopyLeadIn(LeadIn source) => source switch
{
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
{
Length1 = Dimension(n.Length1),
Length2 = Dimension(n.Length2),
ApproachAngle1 = Angle(n.ApproachAngle1),
ApproachAngle2 = Angle(n.ApproachAngle2)
},
CleanHoleLeadIn n when n.GetType() == typeof(CleanHoleLeadIn) => new CleanHoleLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), Kerf = Dimension(n.Kerf) },
null => throw new ArgumentException("Missing lead-in settings."),
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
};
private static LeadOut CopyLeadOut(LeadOut source) => source switch
{
NoLeadOut n when n.GetType() == typeof(NoLeadOut) => new NoLeadOut(),
LineLeadOut n when n.GetType() == typeof(LineLeadOut) => new LineLeadOut
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadOut n when n.GetType() == typeof(ArcLeadOut) => new ArcLeadOut { Radius = Dimension(n.Radius) },
null => throw new ArgumentException("Missing lead-out settings."),
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
};
private static Tab CopyTab(Tab source)
{
Tab copy = source switch
{
NormalTab n when n.GetType() == typeof(NormalTab) => new NormalTab
{
CutoutMinWidth = Dimension(n.CutoutMinWidth),
CutoutMinHeight = Dimension(n.CutoutMinHeight),
CutoutMaxWidth = Dimension(n.CutoutMaxWidth),
CutoutMaxHeight = Dimension(n.CutoutMaxHeight)
},
BreakerTab n when n.GetType() == typeof(BreakerTab) => new BreakerTab
{
BreakerDepth = Dimension(n.BreakerDepth),
BreakerLeadInLength = Dimension(n.BreakerLeadInLength),
BreakerAngle = Angle(n.BreakerAngle)
},
MachineTab n when n.GetType() == typeof(MachineTab) => new MachineTab { MachineTabId = n.MachineTabId },
_ => throw new NotSupportedException("Unsupported tab runtime type.")
};
copy.Size = Dimension(source.Size);
copy.TabLeadIn = source.TabLeadIn == null ? null : CopyLeadIn(source.TabLeadIn);
copy.TabLeadOut = source.TabLeadOut == null ? null : CopyLeadOut(source.TabLeadOut);
return copy;
}
private static void Exact<T>(T value) where T : class
{
if (value == null)
throw new ArgumentException("Missing cutting settings.");
if (value.GetType() != typeof(T))
throw new NotSupportedException("Unsupported settings runtime type.");
}
private static double Dimension(double value) => double.IsFinite(value) && value >= 0
? value : throw new ArgumentException("Dimensions must be finite and nonnegative.");
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
private static double Angle(double value) => double.IsFinite(value)
? value : throw new ArgumentException("Angles must be finite.");
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Owned value motions; no references to live programs, instructions or settings.</summary>
public sealed class OwnedExecution
{
internal OwnedExecution(IEnumerable<ExecutionMotion> motions)
{
Motions = Array.AsReadOnly(motions.ToArray());
}
public IReadOnlyList<ExecutionMotion> Motions { get; }
public Vector DeparturePoint => Motions[^1].End;
public bool HasCuttingContour => Motions.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Length > PostVerificationGeometry.Epsilon);
public double RapidDistanceFrom(Vector arrival)
{
PostVerificationGeometry.Validate(arrival);
var total = 0.0;
for (var index = 0; index < Motions.Count; index++)
{
var move = Motions[index];
if (move.Rapid && (index == 0 ? arrival : move.Start) is { } start)
total += start.DistanceTo(move.End);
}
return total;
}
}
/// <summary>An immutable motion value; native line/arc geometry stays private to Core.</summary>
public sealed class ExecutionMotion
{
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve)
{
Start = start;
End = end;
Rapid = rapid;
Layer = layer;
Curve = curve;
}
public Vector? Start { get; }
public Vector End { get; }
public bool Rapid { get; }
public LayerType Layer { get; }
public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
}
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Lossless owned copies at the cutting-plan boundary, not a general Clone change.</summary>
internal static class OwnedProgramCopy
{
private const int Limit = 1000000;
private const int MaxDepth = 64;
internal static Program Copy(Program original, CancellationToken token = default)
{
Validate(original, token);
// Built-in Clone preserves mode/rotation and binds calls without rotating setters.
// Its per-parent maps can duplicate diamonds; restore one global owned graph below.
var cloned = (Program)original.Clone();
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
var bindings = new Dictionary<Dictionary<string, string>, Dictionary<string, string>>(ReferenceEqualityComparer.Instance);
return Restore(original, cloned);
Program Restore(Program source, Program copy)
{
token.ThrowIfCancellationRequested();
if (programs.TryGetValue(source, out var existing)) return existing;
programs.Add(source, copy);
for (var i = 0; i < source.Codes.Count; i++)
{
token.ThrowIfCancellationRequested();
var authored = source.Codes[i];
if (codes.TryGetValue(authored, out var owned))
{
copy.Codes[i] = owned;
continue;
}
owned = copy.Codes[i];
codes.Add(authored, owned);
if (authored is Motion motion)
{
var target = (Motion)owned;
// All other built-in fields are retained by their Clone implementations.
target.UseExactStop = motion.UseExactStop;
target.Feedrate = motion.Feedrate;
if (motion.VariableRefs != null)
{
if (!bindings.TryGetValue(motion.VariableRefs, out var refs))
{
refs = new Dictionary<string, string>(motion.VariableRefs, motion.VariableRefs.Comparer);
bindings.Add(motion.VariableRefs, refs);
}
target.VariableRefs = refs;
}
}
if (authored is SubProgramCall call)
{
var target = (SubProgramCall)owned;
target.BindProgram(Restore(call.Program, target.Program));
}
}
foreach (var (id, child) in source.SubPrograms)
{
token.ThrowIfCancellationRequested();
copy.SubPrograms[id] = Restore(child, copy.SubPrograms[id]);
}
return copy;
}
}
// Unlike the execution reader, inactive registered graphs can be motionless or
// contain suppressed motions. They still must be supported, acyclic and bounded.
internal static void Validate(Program program, CancellationToken token)
{
var active = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var done = new Dictionary<Program, (int Cost, int Depth)>(ReferenceEqualityComparer.Instance);
var budget = Limit;
Visit(program);
(int Cost, int Depth) Visit(Program current)
{
token.ThrowIfCancellationRequested();
if (current == null || active.Contains(current) || active.Count >= MaxDepth)
throw new ArgumentException("Missing, recursive or excessively nested clone graph.");
if (done.TryGetValue(current, out var previous))
{
if (active.Count + previous.Depth > MaxDepth)
throw new ArgumentException("Excessively nested clone graph.");
return previous;
}
if (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode))
throw new NotSupportedException("Unsupported program runtime type or mode.");
if (current.Codes == null)
throw new ArgumentException("Missing clone graph instructions.");
active.Add(current);
var children = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var cost = 1;
var depth = 1;
foreach (var code in current.Codes)
{
Step();
cost++;
if (code == null)
throw new ArgumentException("Missing clone graph instruction.");
var type = code.GetType();
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
throw new NotSupportedException("Unsupported instruction runtime type.");
if (code is SubProgramCall call) Child(call.Program);
}
foreach (var child in current.SubPrograms.Values)
{
Step();
Child(child);
}
if (cost > Limit)
throw new ArgumentException("Clone graph exceeds the verification limit.");
active.Remove(current);
var result = (cost, depth);
done.Add(current, result);
return result;
void Child(Program child)
{
var summary = Visit(child);
// Program.Clone shares children only within one parent; bound its real
// expanded work as well as the distinct original graph before calling it.
if (!children.Add(child)) return;
if (summary.Cost > Limit - cost)
throw new ArgumentException("Clone expansion exceeds the verification limit.");
cost += summary.Cost;
depth = System.Math.Max(depth, summary.Depth + 1);
}
}
void Step()
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Clone graph exceeds the verification limit.");
}
}
}
@@ -0,0 +1,9 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Every Part field a cutting commit can change, held by reference for exact rollback.</summary>
internal sealed record PartCuttingState(Program Program, bool OwnsProgram, double PreLeadInRotation,
bool HasManualLeadIns, bool LeadInsLocked, CuttingParameters CuttingParameters, Vector Location,
Box BoundingBox);
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact caller-thread record of everything a cutting proposal for one plate depends on:
/// part list instance and order, plate quantity/size/quadrant/settings, cutoff definitions, and
/// each part's complete cutting state with owned copies of its placed and drawing programs,
/// drawing cutoff classification and exact settings content.
/// A commit compares it with the live plate and refuses when anything differs.
/// </summary>
public sealed class PlateCuttingState
{
private readonly ObservableList<Part> partList;
private readonly PartRecord[] parts;
private readonly int quantity;
private readonly Size size;
private readonly int quadrant;
private readonly ObservableList<CutOff> cutOffList;
private readonly CutOffRecord[] cutOffs;
private readonly Dictionary<Drawing, (Program Program, Program Copy, bool IsCutOff)> drawings;
private readonly Dictionary<CuttingParameters, string> settings;
private readonly CuttingParameters plateSettings;
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
Dictionary<Drawing, (Program, Program, bool)> drawings, Dictionary<CuttingParameters, string> settings)
{
this.settings = settings;
plateSettings = plate.CuttingParameters;
Plate = plate;
partList = plate.Parts;
this.parts = parts;
quantity = plate.Quantity;
size = plate.Size;
quadrant = plate.Quadrant;
cutOffList = plate.CutOffs;
this.cutOffs = cutOffs;
this.drawings = drawings;
Order = Array.AsReadOnly(parts.Select(p => p.Part).ToArray());
}
public Plate Plate { get; }
/// <summary>Captured part order (reference identity).</summary>
public IReadOnlyList<Part> Order { get; }
/// <summary>
/// Captures on the caller thread. Unsupported or malformed programs throw
/// <see cref="ArgumentException"/> or <see cref="NotSupportedException"/>; so do part or
/// plate settings that are not exact built-in settings types, whose state cannot be
/// captured exactly.
/// </summary>
public static PlateCuttingState Capture(Plate plate, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(plate);
if (plate.Parts == null || plate.CutOffs == null)
throw new ArgumentException("Plate part and cutoff lists are required.");
var drawings = new Dictionary<Drawing, (Program, Program, bool)>(ReferenceEqualityComparer.Instance);
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
Fingerprint(plate.CuttingParameters);
var records = new List<PartRecord>(plate.Parts.Count);
foreach (var part in plate.Parts)
{
token.ThrowIfCancellationRequested();
if (part?.BaseDrawing == null || part.Program == null)
throw new ArgumentException("Plate contains a missing part, drawing or program.");
var drawing = part.BaseDrawing;
if (!drawings.ContainsKey(drawing))
drawings.Add(drawing, (drawing.Program, drawing.Program == null ? null
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff));
var state = part.CaptureCuttingState();
Fingerprint(state.CuttingParameters);
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox)));
}
var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters)
{
if (parameters != null && !settings.ContainsKey(parameters))
settings.Add(parameters, StateFingerprint.Of(parameters));
}
}
/// <summary>True when the live plate still has exactly the captured state.</summary>
public bool IsCurrent(CancellationToken token = default) => Difference(token) == null;
/// <summary>Null when current; otherwise the first observed difference.</summary>
public string Difference(CancellationToken token = default)
{
var checkedSettings = new Dictionary<CuttingParameters, bool>(ReferenceEqualityComparer.Instance);
var plate = Plate;
if (!ReferenceEquals(plate.Parts, partList) || !ReferenceEquals(plate.CutOffs, cutOffList))
return "The plate's part or cutoff list was replaced.";
if (plate.Quantity != quantity || !Bits(plate.Size.Width, size.Width)
|| !Bits(plate.Size.Length, size.Length) || plate.Quadrant != quadrant)
return "Plate quantity, size or quadrant changed.";
if (!ReferenceEquals(plate.CuttingParameters, plateSettings) || !SameSettings(plateSettings))
return "Plate cutting settings changed.";
if (plate.Parts.Count != parts.Length)
return "Parts were added or removed.";
for (var i = 0; i < parts.Length; i++)
{
token.ThrowIfCancellationRequested();
var record = parts[i];
var part = plate.Parts[i];
if (!ReferenceEquals(part, record.Part))
return $"Part order changed at position {i + 1}.";
var live = part.CaptureCuttingState();
var was = record.State;
if (!ReferenceEquals(live.Program, was.Program) || live.OwnsProgram != was.OwnsProgram
|| !ReferenceEquals(live.CuttingParameters, was.CuttingParameters)
|| !ReferenceEquals(live.BoundingBox, was.BoundingBox))
return $"Part {i + 1} program, settings or bounds were replaced.";
if (!Bits(live.Location.X, was.Location.X) || !Bits(live.Location.Y, was.Location.Y)
|| !Bits(live.PreLeadInRotation, was.PreLeadInRotation)
|| live.HasManualLeadIns != was.HasManualLeadIns || live.LeadInsLocked != was.LeadInsLocked)
return $"Part {i + 1} pose, lead-in or lock state changed.";
if (!BoxValues(live.BoundingBox).SequenceEqual(record.Bounds))
return $"Part {i + 1} bounds changed.";
if (!ProgramContent.Equal(part.Program, record.Program, token))
return $"Part {i + 1} program was edited in place.";
if (!SameSettings(live.CuttingParameters))
return $"Part {i + 1} cutting settings were edited in place.";
var (drawingProgram, drawingCopy, isCutOff) = drawings[part.BaseDrawing];
if (!ReferenceEquals(part.BaseDrawing.Program, drawingProgram)
|| !ProgramContent.Equal(drawingProgram, drawingCopy, token))
return $"Part {i + 1} drawing program changed.";
if (part.BaseDrawing.IsCutOff != isCutOff)
return $"Part {i + 1} cutoff classification changed.";
}
if (plate.CutOffs.Count != cutOffs.Length)
return "Cutoffs were added or removed.";
for (var i = 0; i < cutOffs.Length; i++)
{
var record = cutOffs[i];
var cutOff = plate.CutOffs[i];
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
return $"Cutoff {i + 1} definition changed.";
}
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
// all compared exactly above.
return null;
// References were compared already; this catches in-place edits of the same object.
// Each distinct settings object is fingerprinted at most once per check. A nested
// settings object replaced since capture by an unsupported type cannot equal the
// captured built-in state: Stale, not an exception.
bool SameSettings(CuttingParameters parameters)
{
if (parameters == null)
return true;
if (checkedSettings.TryGetValue(parameters, out var known))
return known;
bool current;
try
{
current = settings.TryGetValue(parameters, out var captured)
&& captured == StateFingerprint.Of(parameters);
}
catch (NotSupportedException)
{
current = false;
}
checkedSettings[parameters] = current;
return current;
}
}
private static long[] BoxValues(Box box) => box == null ? [] :
[BitConverter.DoubleToInt64Bits(box.X), BitConverter.DoubleToInt64Bits(box.Y),
BitConverter.DoubleToInt64Bits(box.Width), BitConverter.DoubleToInt64Bits(box.Length)];
private static bool Bits(double a, double b) =>
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
private static bool Bits(double? a, double? b) => a.HasValue == b.HasValue
&& (!a.HasValue || Bits(a.Value, b.Value));
private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit);
}
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>A nominal native entry owned by one preparation, not an actual emitted pierce.</summary>
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
{
internal PreparedContours Owner { get; init; }
}
/// <summary>
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
/// </summary>
public sealed class PreparedContours
{
private readonly Shape[] shapes;
private readonly CuttingParameters parameters;
private readonly List<Entity> scribes;
private PreparedContours(Shape[] shapes, List<Entity> scribes, CuttingParameters parameters)
{
this.shapes = shapes;
this.scribes = scribes;
this.parameters = parameters;
}
public int Count => shapes.Length;
public int PerimeterOrdinal => Count - 1;
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
ExecutionMotionReader.ReadSupported(program, Vector.Zero, null, token);
// ToGeometry constructs fresh native entities. Normalize an owned motion graph
// to incremental mode so absolute subprograms retain their frame offsets too.
// Retain execution boundaries: ShapeBuilder can join duplicate closed contours.
var geometryProgram = CopyForGeometry(program, token);
var contours = new List<Shape>();
var scribes = new List<Entity>();
var current = new Shape();
foreach (var entity in geometryProgram.ToGeometry())
{
token.ThrowIfCancellationRequested();
if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle))
throw new NotSupportedException("Unsupported native geometry.");
if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Preparation requires closed execution contours.");
if (entity.Layer == SpecialLayers.Scribe)
scribes.Add(entity);
continue;
}
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display)
throw new ArgumentException("Preparation requires clean cut/display contours.");
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate contour entity.");
if (entity is Circle)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Circle interrupts an open contour.");
current.Entities.Add(entity);
FinishContour();
continue;
}
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Discontinuous native contour.");
current.Entities.Add(entity);
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
FinishContour();
}
if (current.Entities.Count != 0 || contours.Count == 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
var perimeter = 0;
for (var i = 1; i < contours.Count; i++)
{
var box = contours[i].BoundingBox;
var best = contours[perimeter].BoundingBox;
if (box.Width * box.Length > best.Width * best.Length)
perimeter = i;
}
var outer = contours[perimeter];
contours.RemoveAt(perimeter);
contours.Add(outer);
return new(contours.ToArray(), scribes, ownedParameters);
void FinishContour()
{
current.UpdateBounds();
contours.Add(current);
current = new Shape();
}
}
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
{
PostVerificationGeometry.Validate(approach);
var shape = GetShape(contourOrdinal);
var point = shape.ClosestPointTo(approach, out var entity);
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
}
/// <summary>
/// Closest native point first, then source-order vertices/midpoints or eight circle
/// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
/// Emit before validating actual paths: circle rounding/clamping may move this point.
/// </summary>
public IReadOnlyList<ContourChoice> Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (maxEntries <= 0)
throw new ArgumentOutOfRangeException(nameof(maxEntries));
var shape = GetShape(contourOrdinal);
var entries = new List<ContourChoice>();
entries.Add(ClosestEntry(contourOrdinal, approach));
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
{
token.ThrowIfCancellationRequested();
var entity = shape.Entities[i];
if (entity is Circle circle)
{
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius);
}
else
{
Add(i, Start(entity));
Add(i, entity is Line line ? line.MidPoint : ((Arc)entity).MidPoint());
Add(i, End(entity));
}
}
token.ThrowIfCancellationRequested();
return entries.AsReadOnly();
void Add(int entityOrdinal, Vector point)
{
token.ThrowIfCancellationRequested();
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
}
}
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
ValidateChoice(choice);
return choice;
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
public Program Emit(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, true);
return EmitPrefix(choices);
}
// Each prefix is a standalone owned program, including the same scribes once.
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, false);
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
}
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
// Build expected-emission metadata independently of the selected payload BEFORE
// replay. Final verification only reads this immutable execution and clean geometry.
internal SelectedContourProgram CaptureSelectedProgram(IReadOnlyList<ContourChoice> choices,
Vector location, CancellationToken token)
{
token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
}
private void Validate(IReadOnlyList<ContourChoice> choices, bool complete)
{
if (choices == null || (complete && choices.Count != Count) || choices.Count > Count)
throw new ArgumentException("Every contour must be represented exactly once.");
var visited = new HashSet<int>();
foreach (var choice in choices)
{
ValidateChoice(choice);
if (!visited.Add(choice.ContourOrdinal) || choice.ContourOrdinal == PerimeterOrdinal && visited.Count != Count)
throw new ArgumentException("Duplicate contour or perimeter before its internal contours.");
}
}
private void ValidateChoice(ContourChoice choice)
{
if (choice == null || !ReferenceEquals(choice.Owner, this))
throw new ArgumentException("Foreign contour choice.");
PostVerificationGeometry.Validate(choice.Point);
var shape = GetShape(choice.ContourOrdinal);
if (choice.EntityOrdinal < 0 || choice.EntityOrdinal >= shape.Entities.Count)
throw new ArgumentException("Foreign entity ordinal.");
var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Entry is not on the selected native entity.");
}
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
private static Program CopyForGeometry(Program source, CancellationToken token)
{
var copies = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
return Copy(source);
Program Copy(Program current)
{
token.ThrowIfCancellationRequested();
if (copies.TryGetValue(current, out var existing))
return existing;
var copy = new Program(current.Mode);
copies.Add(current, copy);
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
var ownedCode = code.Clone();
if (ownedCode is SubProgramCall call)
call.BindProgram(Copy(((SubProgramCall)code).Program));
copy.Codes.Add(ownedCode);
}
copy.Mode = Mode.Incremental;
return copy;
}
}
}
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact structural equality of two program graphs: instruction runtime types, scalar bits,
/// motion metadata, variables and the same instruction/sub-program sharing shape. This is
/// a freshness check, not geometric equivalence; unsupported instruction types never match.
/// </summary>
internal static class ProgramContent
{
internal static bool Equal(Program left, Program right, CancellationToken token = default)
{
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
var programOwners = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
var codeOwners = new HashSet<ICode>(ReferenceEqualityComparer.Instance);
return SameProgram(left, right);
bool SameProgram(Program a, Program b)
{
token.ThrowIfCancellationRequested();
if (a == null || b == null)
return a == null && b == null;
// Pair graphs one-to-one so shared and distinct sub-programs cannot be confused.
if (programs.TryGetValue(a, out var paired))
return ReferenceEquals(paired, b);
if (!programOwners.Add(b))
return false;
programs.Add(a, b);
// Codes is a writable field: a missing list is a difference, never an exception.
if (a.Codes == null || b.Codes == null)
return a.Codes == null && b.Codes == null && a.GetType() == b.GetType();
if (a.GetType() != b.GetType() || a.Mode != b.Mode || !Bits(a.Rotation, b.Rotation)
|| a.Codes.Count != b.Codes.Count || a.SubPrograms.Count != b.SubPrograms.Count
|| !SameVariables(a.Variables, b.Variables))
return false;
for (var i = 0; i < a.Codes.Count; i++)
if (!SameCode(a.Codes[i], b.Codes[i]))
return false;
foreach (var (id, child) in a.SubPrograms)
if (!b.SubPrograms.TryGetValue(id, out var other) || !SameProgram(child, other))
return false;
return true;
}
bool SameCode(ICode a, ICode b)
{
token.ThrowIfCancellationRequested();
if (a == null || b == null)
return a == null && b == null;
if (codes.TryGetValue(a, out var paired))
return ReferenceEquals(paired, b);
if (!codeOwners.Add(b))
return false;
codes.Add(a, b);
if (a.GetType() != b.GetType())
return false;
return (a, b) switch
{
(RapidMove x, RapidMove y) => SameMotion(x, y),
(LinearMove x, LinearMove y) => SameMotion(x, y) && x.Layer == y.Layer,
(ArcMove x, ArcMove y) => SameMotion(x, y) && x.Layer == y.Layer
&& x.Rotation == y.Rotation && Bits(x.CenterPoint, y.CenterPoint),
(SubProgramCall x, SubProgramCall y) => x.Id == y.Id && Bits(x.Offset, y.Offset)
&& Bits(x.Rotation, y.Rotation) && SameProgram(x.Program, y.Program),
(Comment x, Comment y) => string.Equals(x.Value, y.Value, StringComparison.Ordinal),
(Feedrate x, Feedrate y) => Bits(x.Value, y.Value)
&& string.Equals(x.VariableRef, y.VariableRef, StringComparison.Ordinal),
(Kerf x, Kerf y) => x.Value == y.Value,
_ => false
};
}
}
private static bool SameMotion(Motion a, Motion b) => Bits(a.EndPoint, b.EndPoint)
&& a.UseExactStop == b.UseExactStop && a.Feedrate == b.Feedrate && a.Suppressed == b.Suppressed
&& SameRefs(a.VariableRefs, b.VariableRefs);
private static bool SameRefs(Dictionary<string, string> a, Dictionary<string, string> b)
{
if (a == null || b == null)
return a == null && b == null;
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
return false;
foreach (var (key, value) in a)
if (!b.TryGetValue(key, out var other) || !string.Equals(value, other, StringComparison.Ordinal))
return false;
return true;
}
// A case-insensitive dictionary finds a respelled key; authored spelling must still match.
private static bool SameSpelling(IEnumerable<string> a, IEnumerable<string> b) =>
new HashSet<string>(a, StringComparer.Ordinal).SetEquals(b);
private static bool SameVariables(Dictionary<string, VariableDefinition> a, Dictionary<string, VariableDefinition> b)
{
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
return false;
foreach (var (key, value) in a)
{
if (!b.TryGetValue(key, out var other))
return false;
if (ReferenceEquals(value, other))
continue;
if (value == null || other == null
|| !string.Equals(value.Name, other.Name, StringComparison.Ordinal)
|| !string.Equals(value.Expression, other.Expression, StringComparison.Ordinal)
|| !Bits(value.Value, other.Value) || value.Inline != other.Inline || value.Global != other.Global)
return false;
}
return true;
}
private static bool Bits(Vector a, Vector b) => Bits(a.X, b.X) && Bits(a.Y, b.Y);
private static bool Bits(double a, double b) =>
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
}
@@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Direct XY completed-contour checker. This is not physical machine safety.</summary>
public sealed class ReleasedContourState
{
private readonly List<Obstacle> obstacles = new();
public ReleasedContourState Copy()
{
var copy = new ReleasedContourState();
copy.obstacles.AddRange(obstacles);
return copy;
}
/// <summary>Consumes one whole owned program; part numbers are caller identity keys.</summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, CancellationToken token = default) => Check(execution, arrival, partNumber, false, token);
/// <summary>
/// As the analyzer does for cutoffs: rapids are checked, but cutoff cuts need no lead-in and,
/// being open, never become obstacles.
/// </summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, bool cutoff, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(execution);
if (arrival is { } point)
PostVerificationGeometry.Validate(point);
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
return findings.AsReadOnly();
}
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
var hasLead = false;
var contourNumber = 0;
foreach (var move in moves)
{
token.ThrowIfCancellationRequested();
if (move.Rapid)
{
Finish();
hasLead = false;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
System.Math.Max(start.Y, move.End.Y));
foreach (var obstacle in obstacles)
{
token.ThrowIfCancellationRequested();
// A rapid well clear of a contour's extent can neither cross nor touch it.
if (reach.IsClearOf(obstacle.Extent))
continue;
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}."));
}
}
else if (move.Layer == LayerType.Leadin)
{
Finish();
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
}
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
{
if (move.Layer == LayerType.Leadout && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."));
Finish();
hasLead = false;
}
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
{
if (contour.Count == 0)
{
contourNumber++;
if (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var previous = unfinished.FindIndex(chain =>
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
{
contour.AddRange(unfinished[previous]);
unfinished.RemoveAt(previous);
}
}
contour.Add(move.Curve);
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
Finish();
}
}
Finish();
if (unfinished.Count > 1)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
void Finish()
{
if (contour.Count == 0)
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
else if (!cutoff)
unfinished.Add(contour.ToArray());
contour.Clear();
}
}
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
{
internal PostVerificationGeometry.Extent Extent { get; } =
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
}
}
@@ -0,0 +1,216 @@
using System;
using System.Globalization;
using System.Text;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact text record of cutting settings for detecting in-place edits after capture. It is a
/// freshness fingerprint, not a serializer.
/// Only the exact built-in settings types are supported — the same set
/// <see cref="OwnedCuttingParameters"/> copies: <see cref="CuttingParameters"/>,
/// <see cref="SequenceParameters"/>, <see cref="AssignmentParameters"/> and the built-in
/// lead-in, lead-out and tab types. Every member is written explicitly by type, so nothing is
/// discovered reflectively and no code of another type ever runs: each object's runtime type
/// is checked before any of its members is read, and any other runtime type (a subclass
/// included) throws <see cref="NotSupportedException"/>. Doubles are written by bit pattern
/// and every number is formatted invariantly, so the text is exact and culture-free.
/// </summary>
internal static class StateFingerprint
{
/// <summary>Fingerprint of <paramref name="settings"/>; null renders as a marker.</summary>
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
internal static string Of(CuttingParameters settings)
{
var text = new StringBuilder();
Settings(settings);
return text.ToString();
void Settings(CuttingParameters p)
{
if (!Begin(p, typeof(CuttingParameters)))
return;
Int(p.Id);
Text(p.MachineName);
Text(p.MaterialName);
Text(p.Grade);
Number(p.Thickness);
Number(p.Kerf);
Number(p.PartSpacing);
Lead(p.ExternalLeadIn);
Out(p.ExternalLeadOut);
Lead(p.InternalLeadIn);
Out(p.InternalLeadOut);
Lead(p.ArcCircleLeadIn);
Out(p.ArcCircleLeadOut);
Number(p.PierceClearance);
Flag(p.RoundLeadInAngles);
Number(p.LeadInAngleIncrement);
Number(p.AutoTabMinSize);
Number(p.AutoTabMaxSize);
TabSettings(p.TabConfig);
Flag(p.TabsEnabled);
Sequence(p.Sequencing);
Assignment(p.Assignment);
text.Append('}');
}
void Sequence(SequenceParameters s)
{
if (!Begin(s, typeof(SequenceParameters)))
return;
Int((int)s.Method);
Number(s.SmallCutoutWidth);
Number(s.SmallCutoutHeight);
Number(s.MediumCutoutWidth);
Number(s.MediumCutoutHeight);
Number(s.DistanceMediumSmall);
Flag(s.AlternateRowsColumns);
Flag(s.AlternateCutoutsWithinRowColumn);
Number(s.MinDistanceBetweenRowsColumns);
text.Append('}');
}
void Assignment(AssignmentParameters a)
{
if (!Begin(a, typeof(AssignmentParameters)))
return;
Int((int)a.Method);
Text(a.Preference);
Number(a.MinGeometryLength);
text.Append('}');
}
void Lead(LeadIn lead)
{
if (lead == null)
{
text.Append("null;");
return;
}
switch (lead)
{
case NoLeadIn when Begin(lead, typeof(NoLeadIn)):
break;
case LineLeadIn l when Begin(lead, typeof(LineLeadIn)):
Number(l.Length);
Number(l.ApproachAngle);
break;
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
Number(l.Radius);
break;
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
Number(l.LineLength);
Number(l.ApproachAngle);
Number(l.ArcRadius);
break;
case LineLineLeadIn l when Begin(lead, typeof(LineLineLeadIn)):
Number(l.Length1);
Number(l.ApproachAngle1);
Number(l.Length2);
Number(l.ApproachAngle2);
break;
case CleanHoleLeadIn l when Begin(lead, typeof(CleanHoleLeadIn)):
Number(l.LineLength);
Number(l.ArcRadius);
Number(l.Kerf);
break;
default:
throw Unsupported(lead);
}
text.Append('}');
}
void Out(LeadOut lead)
{
if (lead == null)
{
text.Append("null;");
return;
}
switch (lead)
{
case NoLeadOut when Begin(lead, typeof(NoLeadOut)):
break;
case LineLeadOut l when Begin(lead, typeof(LineLeadOut)):
Number(l.Length);
Number(l.ApproachAngle);
break;
case ArcLeadOut l when Begin(lead, typeof(ArcLeadOut)):
Number(l.Radius);
break;
default:
throw Unsupported(lead);
}
text.Append('}');
}
void TabSettings(Tab tab)
{
if (tab == null)
{
text.Append("null;");
return;
}
switch (tab)
{
case NormalTab t when Begin(tab, typeof(NormalTab)):
Number(t.CutoutMinWidth);
Number(t.CutoutMinHeight);
Number(t.CutoutMaxWidth);
Number(t.CutoutMaxHeight);
break;
case BreakerTab t when Begin(tab, typeof(BreakerTab)):
Number(t.BreakerDepth);
Number(t.BreakerLeadInLength);
Number(t.BreakerAngle);
break;
case MachineTab t when Begin(tab, typeof(MachineTab)):
Int(t.MachineTabId);
break;
default:
throw Unsupported(tab);
}
Number(tab.Size);
Lead(tab.TabLeadIn);
Out(tab.TabLeadOut);
text.Append('}');
}
// Writes the exact type tag and returns true; null writes a marker and returns false.
// A runtime type other than the expected exact type throws before any member is read.
bool Begin(object value, Type exact)
{
if (value == null)
{
text.Append("null;");
return false;
}
if (value.GetType() != exact)
throw Unsupported(value);
text.Append(exact.Name).Append('{');
return true;
}
void Number(double value) =>
text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
void Flag(bool value) => text.Append(value ? "1;" : "0;");
// Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
void Text(string value)
{
if (value == null)
text.Append("~;");
else
text.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value).Append(';');
}
}
private static NotSupportedException Unsupported(object value) =>
new($"Cutting settings of type {value.GetType().FullName} cannot be captured exactly; " +
"only the built-in settings types are supported.");
}
@@ -234,6 +234,22 @@ namespace OpenNest.CNC.CuttingStrategy
return null;
}
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
foreach (var choice in choices)
{
var shape = shapes[choice.ContourOrdinal];
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
exactCirclePrograms: true);
}
result.Mode = Mode.Incremental;
return result;
}
private void EmitRawContour(Program program, Shape shape)
{
var startPoint = GetShapeStartPoint(shape);
@@ -300,12 +316,57 @@ namespace OpenNest.CNC.CuttingStrategy
return HashCode.Combine(r, a);
}
// Prepared emission compares the actual resolved, generated motions, not a
// rounded geometry hash. Labels are deterministic encounter-order identifiers;
// legacy Apply/ApplySingle retain their existing cache and labels unchanged.
private static int RegisterPreparedCircleProgram(Program owner, Program generated)
{
CuttingPlanning.ExecutionMotionReader.ReadSupported(generated, Vector.Zero, null);
if (generated.Variables.Count != 0 || generated.SubPrograms.Count != 0)
throw new NotSupportedException("Unsupported generated circle program metadata.");
foreach (var code in generated.Codes)
if (code is not (RapidMove or LinearMove or ArcMove) || ((Motion)code).VariableRefs != null)
throw new NotSupportedException("Unsupported generated circle instruction.");
foreach (var pair in owner.SubPrograms)
if (SameGeneratedCircleProgram(pair.Value, generated))
return pair.Key;
var key = checked(owner.SubPrograms.Count + 1);
owner.SubPrograms.Add(key, generated);
return key;
}
private static bool SameGeneratedCircleProgram(Program a, Program b)
{
if (a.Mode != b.Mode || !SameBits(a.Rotation, b.Rotation) || a.Codes.Count != b.Codes.Count)
return false;
for (var i = 0; i < a.Codes.Count; i++)
{
var left = (Motion)a.Codes[i];
var right = (Motion)b.Codes[i];
if (left.GetType() != right.GetType() || left.Suppressed != right.Suppressed
|| left.UseExactStop != right.UseExactStop || left.Feedrate != right.Feedrate
|| !SameVector(left.EndPoint, right.EndPoint))
return false;
if (left is LinearMove line && line.Layer != ((LinearMove)right).Layer)
return false;
if (left is ArcMove arc && (arc.Layer != ((ArcMove)right).Layer
|| arc.Rotation != ((ArcMove)right).Rotation
|| !SameVector(arc.CenterPoint, ((ArcMove)right).CenterPoint)))
return false;
}
return true;
}
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
private void EmitContour(
Program program,
Shape shape,
Vector point,
Entity entity,
ContourType? forceType = null
ContourType? forceType = null,
bool exactCirclePrograms = false
)
{
var contourType = forceType ?? DetectContourType(shape);
@@ -351,7 +412,9 @@ namespace OpenNest.CNC.CuttingStrategy
subPgm.Mode = Mode.Incremental;
// Deduplicate: check if an identical sub-program already exists
var key = ComputeSubProgramKey(circle.Radius, normal);
var key = exactCirclePrograms
? RegisterPreparedCircleProgram(program, subPgm)
: ComputeSubProgramKey(circle.Radius, normal);
if (!program.SubPrograms.ContainsKey(key))
program.SubPrograms[key] = subPgm;
@@ -376,17 +439,31 @@ namespace OpenNest.CNC.CuttingStrategy
var tabbed = Parameters.TabsEnabled
&& Parameters.TabConfig != null
&& contourType == ContourType.External;
if (tabbed)
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
var leadOutPoint = point;
var leadOutNormal = normal;
if (!tabbed)
if (tabbed)
{
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
// Leave from where the trimmed cut actually ends, on that entity's normal:
// an arc generated at the nominal entry would not start on its own radius.
if (reindexedShape.Entities.Count > 0 && reindexedShape.Entities[^1] is Line or Arc)
{
var last = reindexedShape.Entities[^1];
leadOutPoint = EntityEndPoint(last);
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
}
}
else
{
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
Parameters.PierceClearance, out leadOutNormal);
}
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(point, leadOutNormal, winding));
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
}
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
@@ -13,6 +13,9 @@ namespace OpenNest.Collections
public event EventHandler<ItemRemovedEventArgs<T>> ItemRemoved;
public event EventHandler<ItemChangedEventArgs<T>> ItemChanged;
/// <summary>Raised once after <see cref="Reorder"/> (or a cutting commit) installs a new order.</summary>
public event EventHandler ItemsReordered;
public ObservableList()
{
items = new List<T>();
@@ -64,6 +67,68 @@ namespace OpenNest.Collections
RemoveAt(i);
}
/// <summary>
/// Changes only the order of the current items. <paramref name="order"/> must hold exactly
/// the current non-null items: the same references with the same multiplicity. No
/// ItemAdded/ItemRemoved is raised, so quantity accounting is untouched; ItemsReordered is
/// raised once after the new order is installed. Invalid input throws before any change.
/// </summary>
public void Reorder(IEnumerable<T> order)
{
SetOrder(ValidateReorder(order));
ItemsReordered?.Invoke(this, EventArgs.Empty);
}
internal T[] ValidateReorder(IEnumerable<T> order)
{
ArgumentNullException.ThrowIfNull(order);
var proposed = order.ToArray();
if (proposed.Length != items.Count)
throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
var comparer = typeof(T).IsValueType
? EqualityComparer<T>.Default
: (IEqualityComparer<T>)(object)ReferenceEqualityComparer.Instance;
var counts = new Dictionary<T, int>(comparer);
foreach (var item in items)
{
if (item == null)
throw new InvalidOperationException("A list holding null items cannot be reordered.");
counts[item] = counts.TryGetValue(item, out var count) ? count + 1 : 1;
}
foreach (var item in proposed)
{
if (item == null || !counts.TryGetValue(item, out var count) || count == 0)
throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
counts[item] = count - 1;
}
return proposed;
}
// Validated order only; raises nothing so a commit can publish after a whole scope installs.
internal void SetOrder(T[] order)
{
items.Clear();
items.AddRange(order);
}
// Invokes every observer even if one throws, so a refresh failure cannot starve the rest.
internal void RaiseItemsReordered(ICollection<Exception> errors)
{
if (ItemsReordered == null)
return;
foreach (var handler in ItemsReordered.GetInvocationList())
{
try
{
((EventHandler)handler)(this, EventArgs.Empty);
}
catch (Exception ex)
{
errors.Add(ex);
}
}
}
public int IndexOf(T item)
{
return items.IndexOf(item);
@@ -32,6 +32,20 @@ public sealed class OverlapGeometryStamp
return true;
}
/// <summary>
/// Conservative display reuse: only unchanged ordered slots on the same plate survive.
/// Index-changing edits may hide extra pairs, but cannot attach old geometry to new parts.
/// </summary>
internal bool[] UnchangedSlots(Plate current)
{
var matches = new bool[entries.Length];
if (!ReferenceEquals(plate, current))
return matches;
for (var i = 0; i < entries.Length && i < current.Parts.Count; i++)
matches[i] = entries[i].Matches(current.Parts[i]);
return matches;
}
private readonly struct Entry
{
private readonly Part part;
@@ -43,7 +43,7 @@ internal sealed class OverlapSource
internal OverlapSource(Program program, List<Entity> entities, string error)
{
codeCount = program.Codes.Count;
codeCount = CodeCount(program);
rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
Entities = entities;
Error = error;
@@ -54,7 +54,10 @@ internal sealed class OverlapSource
internal string Error { get; }
internal bool Matches(Program program) =>
program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
CodeCount(program) == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
// Program.Codes is a writable field; a missing list is a refused source, not a crash.
private static int CodeCount(Program program) => program.Codes?.Count ?? -1;
/// <summary>
/// Prepares once and shares the result. A geometry failure is cached like a success;
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Diagnostics;
@@ -9,12 +11,16 @@ public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Fail
public sealed class OverlapReportState
{
private OverlapGeometryStamp stamp;
private OverlapGeometryStamp displayStamp;
private OverlapGeometryStamp observedDisplayStamp;
private int uncheckedPartCount;
public long Generation { get; private set; }
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
public PlateOverlapReport Report { get; private set; }
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
public bool IsRunning => Status == OverlapCheckStatus.Checking;
public string Message => Status switch
@@ -36,7 +42,8 @@ public sealed class OverlapReportState
/// </summary>
public long Begin(Plate plate, bool automatic = false)
{
Clear(OverlapCheckStatus.Checking);
RefreshDisplayPairs(plate);
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
stamp = OverlapGeometryStamp.Capture(plate);
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
DisplayMode = OverlapDisplayMode.Areas;
@@ -48,6 +55,9 @@ public sealed class OverlapReportState
if (!CanComplete(generation, plate))
return false;
Report = report;
DisplayPairs = report.Pairs;
displayStamp = stamp;
observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues);
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
return true;
@@ -66,20 +76,52 @@ public sealed class OverlapReportState
public bool EnsureFresh(Plate plate)
{
RefreshDisplayPairs(plate);
if (stamp == null)
return false;
if (stamp.Matches(plate))
return true;
Invalidate();
Invalidate(plate);
return false;
}
/// <summary>Layout edit: retain only pairs whose two ordered slots still match exactly.</summary>
public void Invalidate(Plate plate)
{
RefreshDisplayPairs(plate);
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
Clear(OverlapCheckStatus.Stale, preserveDisplay: true);
}
/// <summary>In-place geometry edits and teardown must forget every cached display pair.</summary>
public void Invalidate()
{
ClearDisplayPairs();
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
Clear(OverlapCheckStatus.Stale);
}
private void RefreshDisplayPairs(Plate plate)
{
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
return;
var unchanged = displayStamp.UnchangedSlots(plate);
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
if (retained.Count != DisplayPairs.Count)
DisplayPairs = retained.AsReadOnly();
if (DisplayPairs.Count == 0)
ClearDisplayPairs();
else
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
}
private void ClearDisplayPairs()
{
DisplayPairs = Array.Empty<PlateOverlapPair>();
displayStamp = null;
observedDisplayStamp = null;
}
public void Cancel()
{
if (IsRunning)
@@ -88,8 +130,10 @@ public sealed class OverlapReportState
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
private void Clear(OverlapCheckStatus status)
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
{
if (!preserveDisplay)
ClearDisplayPairs();
Generation++;
Report = null;
uncheckedPartCount = 0;
@@ -71,7 +71,7 @@ public static class PlateOverlapAnalyzer
{
try
{
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
// Conversion creates fresh geometry, including expanded shared hole calls;
// no cloning/rotation of a live program or subprogram is necessary.
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
@@ -267,14 +267,32 @@ public static class PlateOverlapAnalyzer
internal static bool IsGeometryFailure(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
private static void ValidateProgram(Program program, HashSet<Program> visiting)
// Exact built-in instruction types. Anything else, subclasses included, is refused before the
// graph is copied or converted, so no unknown Clone or cast runs.
private static readonly HashSet<Type> SupportedCodeTypes =
[
typeof(RapidMove), typeof(LinearMove), typeof(ArcMove), typeof(SubProgramCall),
typeof(Comment), typeof(Feedrate), typeof(Kerf),
];
private static void ValidateProgram(Program program, HashSet<Program> visiting, bool subprogram)
{
if (program == null || !visiting.Add(program) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive, or excessively nested subprogram.");
if (program.Codes == null)
throw new ArgumentException("Program has no instruction list.");
// The converter adds a call's frame offset to incremental moves only, so an absolute
// subprogram would be read at its frame origin. Converting it exactly needs a lossless
// frame transform; until then it is refused rather than misread (OpenNest writes hole
// subprograms in incremental mode).
if (subprogram && program.Mode == Mode.Absolute)
throw new NotSupportedException("Absolute-mode subprograms are not supported by the overlap check.");
foreach (var code in program.Codes)
{
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (!SupportedCodeTypes.Contains(code.GetType()))
throw new NotSupportedException("Program contains an unsupported instruction.");
if (code is Motion motion && !OverlapMaterial.IsFinite(motion.EndPoint)
|| code is ArcMove arc && !OverlapMaterial.IsFinite(arc.CenterPoint))
throw new ArgumentException("Program coordinates must be finite.");
@@ -282,7 +300,7 @@ public static class PlateOverlapAnalyzer
{
if (!OverlapMaterial.IsFinite(call.Offset) || !double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram pose must be finite.");
ValidateProgram(call.Program, visiting);
ValidateProgram(call.Program, visiting, true);
}
}
visiting.Remove(program);
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
@@ -27,9 +28,14 @@ public static class PostVerificationAnalyzer
return new PostVerificationReport(findings);
}
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default)
public static PostVerificationReport Analyze(Nest nest, CancellationToken cancellationToken = default) =>
Analyze(nest, Vector.Zero, cancellationToken);
public static PostVerificationReport Analyze(Nest nest, Vector startPoint,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(nest);
PostVerificationGeometry.Validate(startPoint);
cancellationToken.ThrowIfCancellationRequested();
var findings = new List<PostVerificationFinding>();
if (nest.Plates == null)
@@ -50,8 +56,8 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List<Part>();
var indices = new List<int>();
var obstacles = new List<Obstacle>();
Vector? position = Vector.Zero;
var obstacles = new ReleasedContourState();
Vector? position = startPoint;
for (var index = 0; index < plate.Parts.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -65,7 +71,7 @@ public static class PostVerificationAnalyzer
{
try
{
var clean = Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken);
var clean = ExecutionMotionReader.Read(part?.BaseDrawing?.Program, Vector.Zero, null, cancellationToken).Motions;
expectsCuts = clean.Any(move => !move.Rapid && move.Layer != LayerType.Scribe);
if (!clean.Any(move => move.Layer == LayerType.Scribe)
|| expectsCuts)
@@ -83,12 +89,12 @@ public static class PostVerificationAnalyzer
{
if (part == null || !double.IsFinite(part.Rotation))
throw new ArgumentException("Missing part or invalid rotation.");
var moves = Read(part.Program, part.Location, position, cancellationToken);
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
if (expectsCuts && !moves.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
Incomplete("Placed program has no cutting contour motions for this drawing.");
AnalyzeMoves(moves, cutoff, obstacles, findings, plateNumber, partNumber, cancellationToken);
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
position = moves[^1].End;
}
catch (Exception exception) when (IsInvalid(exception))
@@ -119,172 +125,4 @@ public static class PostVerificationAnalyzer
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
private static List<Move> Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
{
var moves = new List<Move>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return moves;
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
private static void AnalyzeMoves(List<Move> moves, bool cutoff, List<Obstacle> obstacles,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
var hasLead = false;
var contourNumber = 0;
foreach (var move in moves)
{
token.ThrowIfCancellationRequested();
if (move.Rapid)
{
Finish();
hasLead = false;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
foreach (var obstacle in obstacles)
{
token.ThrowIfCancellationRequested();
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}."));
}
}
else if (move.Layer == LayerType.Leadin)
{
Finish();
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
}
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
{
if (move.Layer == LayerType.Leadout && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."));
Finish();
hasLead = false;
}
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
{
if (contour.Count == 0)
{
contourNumber++;
if (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var previous = unfinished.FindIndex(chain =>
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
{
contour.AddRange(unfinished[previous]);
unfinished.RemoveAt(previous);
}
}
contour.Add(move.Curve);
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
Finish();
}
}
Finish();
if (unfinished.Count > 1)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."));
void Finish()
{
if (contour.Count == 0)
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
else if (!cutoff)
unfinished.Add(contour.ToArray());
contour.Clear();
}
}
private sealed record Move(Vector? Start, Vector End, bool Rapid, LayerType Layer,
PostVerificationGeometry.Curve Curve);
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves);
}
@@ -11,6 +11,46 @@ internal static class PostVerificationGeometry
internal const double Epsilon = 1e-8;
private const double TwoPi = 2 * System.Math.PI;
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
internal readonly record struct Extent(double MinX, double MinY, double MaxX, double MaxY)
{
/// <summary>No extent: unions with anything give the other extent.</summary>
internal static Extent None => new(double.PositiveInfinity, double.PositiveInfinity,
double.NegativeInfinity, double.NegativeInfinity);
internal Extent Union(Extent other) => new(System.Math.Min(MinX, other.MinX),
System.Math.Min(MinY, other.MinY), System.Math.Max(MaxX, other.MaxX), System.Math.Max(MaxY, other.MaxY));
/// <summary>
/// The gap two extents need before their checks may be skipped: ten times the widest
/// absolute band any native contact query allows beyond an extent (the 0.00001 bounding-box
/// allowance of native intersections, the 0.0001 contact reach of tiny arcs).
/// </summary>
internal const double ClearMargin = 1e-3;
/// <summary>
/// Coordinates up to which extents may be skipped at all. Within it rounding stays far below
/// <see cref="ClearMargin"/>; beyond it, where rounding of large supports can exceed any fixed
/// margin, every check runs.
/// </summary>
internal const double WellConditionedLimit = 1e6;
/// <summary>
/// True only when both extents are finite, lie within <see cref="WellConditionedLimit"/> and
/// are more than <see cref="ClearMargin"/> apart on some axis, so no native query can count
/// anything in one as touching or entering the other. Everything else must be checked.
/// </summary>
internal bool IsClearOf(Extent other) => IsWellConditioned && other.IsWellConditioned
&& (MaxX + ClearMargin < other.MinX || other.MaxX + ClearMargin < MinX
|| MaxY + ClearMargin < other.MinY || other.MaxY + ClearMargin < MinY);
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
// (including the empty extent's) fail this test too.
private bool IsWellConditioned =>
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
}
internal static void Validate(Vector point)
{
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
@@ -75,6 +115,134 @@ internal static class PostVerificationGeometry
private double Sweep { get; }
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
// Native entities are freshly allocated; the immutable curve never exposes state.
internal Entity ToEntity() => Center is { } center
? System.Math.Abs(Sweep) >= TwoPi
? new Circle(center, Radius)
: new Arc(center, Radius, Normalize(StartAngle), Normalize(StartAngle + Sweep), Sweep < 0)
: new Line(Start, End);
/// <summary>
/// A conservative axis-aligned extent of everything a native query may count as touching
/// this curve: for an arc, its whole supporting circle widened by the contact band of
/// <see cref="ContactAfterStart"/>; for a line, its endpoints. Nonfinite geometry yields NaN
/// bounds, which no separation test can pass.
/// </summary>
internal Extent Extent => Center is { } center
? new(center.X - ContactReach, center.Y - ContactReach, center.X + ContactReach, center.Y + ContactReach)
: new(System.Math.Min(Start.X, End.X), System.Math.Min(Start.Y, End.Y),
System.Math.Max(Start.X, End.X), System.Math.Max(Start.Y, End.Y));
internal Vector Midpoint => Center is { } center
? new Vector(center.X + Radius * System.Math.Cos(StartAngle + Sweep / 2),
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
: (Start + End) * 0.5;
internal bool SameSupport(Curve other)
{
if (Center is { } center)
return other.Center is { } c && center.DistanceTo(c) <= Epsilon
&& System.Math.Abs(Radius - other.Radius) <= Epsilon;
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon)
return false;
var direction = (End - Start) * (1 / Length);
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
}
// Directed native arc-length coordinates for contour replay, not collision queries.
internal bool SameDirection(Curve other) => SameSupport(other)
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
: Dot(End - Start, other.End - other.Start) > 0);
internal double DistanceAlong(Vector point) => Center is { } center
? point.DistanceTo(Start) <= Epsilon ? 0
: Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
: Dot(point - Start, (End - Start) * (1 / Length));
internal Vector PointAtLength(double distance)
{
if (distance <= 0) return Start;
if (distance >= Length) return End;
return Center is { } center
? center + new Vector(System.Math.Cos(StartAngle + System.Math.Sign(Sweep) * distance / Radius),
System.Math.Sin(StartAngle + System.Math.Sign(Sweep) * distance / Radius)) * Radius
: Start + (End - Start) * (distance / Length);
}
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
{
var entity = ToEntity();
var candidate = other.ToEntity();
// Native arc filters discard NaN supporting-circle intersections. Inspect
// both unfiltered queries first: roundoff can differ by operand direction.
// Coincident supports have separate overlap handling below.
if (Center is { } center && other.Center is { } otherCenter && !SameSupport(other))
{
var support = new Circle(center, Radius);
var otherSupport = new Circle(otherCenter, other.Radius);
support.Intersects(otherSupport, out var forward);
otherSupport.Intersects(support, out var reverse);
foreach (var point in forward)
Validate(point);
foreach (var point in reverse)
Validate(point);
}
List<Vector> points;
bool intersects;
switch (candidate)
{
case Line line: intersects = entity.Intersects(line, out points); break;
case Arc arc: intersects = entity.Intersects(arc, out points); break;
case Circle circle: intersects = entity.Intersects(circle, out points); break;
default: throw new NotSupportedException("Unsupported native boundary.");
}
if (!intersects)
points.Clear();
// Coincident circles produce NaNs in the native discrete-contact query.
// Their support overlap is handled separately, without inventing crossings.
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other)
|| InteriorWitness(other.Midpoint, this)
|| InteriorWitness(Start, other) || InteriorWitness(End, other)
|| InteriorWitness(other.Start, this) || InteriorWitness(other.End, this));
if (SameSupport(other))
points.Clear();
foreach (var point in points)
Validate(point);
var nativeContactCount = points.Count;
if (Center is { } c && other.Center is { } oc && SameSupport(other)
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius))
overlap = true; // Nearly coincident supports are uncertain, never clear.
foreach (var point in new[] { Start, End, other.Start, other.End })
if (Contains(point) && other.Contains(point)
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
points.Add(point);
// Existing exact line/ray contact semantics guard native queries which
// suppress very short or nearly parallel intersections. Uncertainty refuses.
if (Center is null && !SameSupport(other))
{
var direction = (End - Start) * (1 / Length);
if ((other.ContactAfterStart(Start, direction, Length)
|| other.ContactAfterStart(End, direction * -1, Length)) && nativeContactCount == 0)
throw new NotSupportedException("Native contact query is numerically uncertain.");
}
if (other.Center is null && Center.HasValue)
{
var direction = (other.End - other.Start) * (1 / other.Length);
if ((ContactAfterStart(other.Start, direction, other.Length)
|| ContactAfterStart(other.End, direction * -1, other.Length)) && nativeContactCount == 0)
throw new NotSupportedException("Native contact query is numerically uncertain.");
}
return points;
static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point)
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
|| (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon));
}
internal static Curve Create(Vector start, Vector end, Vector? center, bool clockwise)
{
if (center is not { } c)
@@ -97,6 +265,13 @@ internal static class PostVerificationGeometry
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
// How far past r^2 a squared distance from the centre still counts as touching the arc.
// For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0).
private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2);
// The largest distance from the centre that ContactAfterStart can count as contact.
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
{
if (Center is { } center)
@@ -107,7 +282,7 @@ internal static class PostVerificationGeometry
var projection = Dot(relative, direction);
var perpendicular = Cross(relative, direction);
var square = Radius * Radius - perpendicular * perpendicular;
if (square < -Epsilon * System.Math.Max(1, Radius * 2))
if (square < -ContactSlack)
return false;
var offset = System.Math.Sqrt(System.Math.Max(0, square));
return Hit(projection - offset) || Hit(projection + offset);
+17 -2
View File
@@ -1,17 +1,32 @@
using System;
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Drawing;
using System.Threading;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public abstract class Entity : IBoundable
{
private static long idCounter;
private static readonly Guid idSalt = Guid.NewGuid();
protected Box boundingBox;
protected Entity()
{
Id = Guid.NewGuid();
// Retain a process-random prefix; allocate the final six bytes atomically.
// IDs are opaque persistence keys, not unpredictable security tokens.
var counter = Interlocked.Increment(ref idCounter);
if ((ulong)counter > 0xFFFFFFFFFFFFUL)
throw new InvalidOperationException("Entity identifier sequence exhausted.");
Span<byte> bytes = stackalloc byte[16];
idSalt.TryWriteBytes(bytes);
Span<byte> counterBytes = stackalloc byte[8];
BinaryPrimitives.WriteInt64LittleEndian(counterBytes, counter);
counterBytes[..6].CopyTo(bytes[10..]);
Id = new Guid(bytes);
Layer = OpenNest.Geometry.Layer.Default;
boundingBox = new Box();
}
+1
View File
@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
<InternalsVisibleTo Include="OpenNest.Engine" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Clipper2" Version="2.0.0" />
+33
View File
@@ -135,6 +135,39 @@ namespace OpenNest
return true;
}
/// <summary>Exact cutting-state record for freshness and rollback; references are not copied.</summary>
internal CNC.CuttingPlanning.PartCuttingState CaptureCuttingState() =>
new(Program, ownsProgram, preLeadInRotation, HasManualLeadIns, LeadInsLocked,
CuttingParameters, location, BoundingBox);
/// <summary>Reinstates a state captured from this part, field for field.</summary>
internal void RestoreCuttingState(CNC.CuttingPlanning.PartCuttingState state)
{
Program = state.Program;
ownsProgram = state.OwnsProgram;
preLeadInRotation = state.PreLeadInRotation;
HasManualLeadIns = state.HasManualLeadIns;
LeadInsLocked = state.LeadInsLocked;
CuttingParameters = state.CuttingParameters;
location = state.Location;
BoundingBox = state.BoundingBox;
}
/// <summary>
/// Installs an owned, prevalidated planned program and its precomputed placed bounds.
/// Pose and lock are unchanged; nothing is regenerated or rotated here.
/// </summary>
internal void InstallPlannedProgram(Program program, Box bounds,
CNC.CuttingStrategy.CuttingParameters parameters)
{
preLeadInRotation = Rotation;
Program = program;
ownsProgram = true;
CuttingParameters = parameters;
HasManualLeadIns = true;
BoundingBox = bounds;
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
+11
View File
@@ -30,6 +30,17 @@ namespace OpenNest
remove { Parts.ItemChanged -= value; }
}
/// <summary>
/// Raised once after the part order changes without adding or removing parts, including
/// a committed cutting plan that also installed planned programs. Refresh order- and
/// program-derived views; quantity accounting is unchanged.
/// </summary>
public event EventHandler PartsReordered
{
add { Parts.ItemsReordered += value; }
remove { Parts.ItemsReordered -= value; }
}
public Plate()
: this(60, 120) { }
+7 -1
View File
@@ -7,9 +7,15 @@ namespace OpenNest.Data;
/// </summary>
public interface INestRepository
{
/// <summary>All stored nests, newest saved first.</summary>
/// <summary>
/// All stored nests, newest saved first. This is the full-enumeration contract for
/// backup and verification; browsing uses <see cref="QueryAsync"/>.
/// </summary>
Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default);
/// <summary>One bounded page of nests matching the query, filtered and ordered by the server.</summary>
Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default);
Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Downloaded .nest archive bytes, or null when the id does not exist.</summary>
+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
/// file while still returning usable values.
/// </summary>
public static NestDefaults Load(string path, out NestDefaultsStatus status)
public static NestDefaults Load(string path, out NestDefaultsStatus status) =>
Load(path, Fallback.Units, out status);
/// <summary>
/// Loads defaults like <see cref="Load(string, out NestDefaultsStatus)"/>,
/// but a missing file, an unusable file, or a missing/undefined unit
/// field yields <paramref name="fallbackUnits"/> (the caller's existing
/// unit preference) instead of the built-in units. Other valid fields
/// still load. An undefined <paramref name="fallbackUnits"/> is ignored.
/// </summary>
public static NestDefaults Load(
string path,
Units fallbackUnits,
out NestDefaultsStatus status
)
{
var defaults = Fallback;
if (Enum.IsDefined(fallbackUnits))
defaults.Units = fallbackUnits;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = NestDefaultsStatus.Missing;
@@ -88,13 +105,10 @@ public sealed class NestDefaults
var json = File.ReadAllText(path);
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json, JsonOptions);
}
catch (JsonException)
{
status = NestDefaultsStatus.Invalid;
return defaults;
}
catch (IOException)
catch (Exception ex) when (IsUnreadableFile(ex))
{
// A file that exists but cannot be read (locked, access-denied)
// or parsed must never block creating a nest.
status = NestDefaultsStatus.Invalid;
return defaults;
}
@@ -107,10 +121,7 @@ public sealed class NestDefaults
status = NestDefaultsStatus.Ok;
if (
dto.Units is not null
&& Enum.TryParse<Units>(dto.Units, ignoreCase: true, out var units)
)
if (TryParseUnits(dto.Units, out var units))
defaults.Units = units;
if (
@@ -195,7 +206,7 @@ public sealed class NestDefaults
var dto = new NestDefaultsDto
{
Version = CurrentVersion,
Units = Units.ToString().ToLowerInvariant(),
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
Quadrant = Quadrant,
PartSpacing = PartSpacing,
@@ -228,6 +239,38 @@ public sealed class NestDefaults
}
}
/// <summary>
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse
/// would also accept numeric strings ("7") and comma-joined names,
/// producing undefined or unintended values.
/// </summary>
private static bool TryParseUnits(JsonElement? element, out Units units)
{
units = default;
if (element is not { ValueKind: JsonValueKind.String } value)
return false;
var text = value.GetString();
foreach (var candidate in Enum.GetValues<Units>())
{
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
{
units = candidate;
return true;
}
}
return false;
}
private static bool IsUnreadableFile(Exception ex) =>
ex
is JsonException
or IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException;
private static bool IsValidSize(double width, double length) =>
!double.IsNaN(width)
&& !double.IsNaN(length)
@@ -247,7 +290,9 @@ public sealed class NestDefaults
private sealed record NestDefaultsDto
{
public int? Version { get; init; } = CurrentVersion;
public string? Units { get; init; }
// Read as raw JSON so a wrongly typed unit falls back on its own
// instead of failing the whole file.
public JsonElement? Units { get; init; }
public SizeDto? Size { get; init; }
public int? Quadrant { get; init; }
public double? PartSpacing { get; init; }
+88
View File
@@ -0,0 +1,88 @@
using System.Linq;
namespace OpenNest.Data;
/// <summary>
/// Plate and drawing rows for one saved nest, shown below the nest list in the
/// Database-mode nest browser. The server stores only nest-level metadata, so these
/// rows are built from the nest's downloaded archive.
/// </summary>
public sealed class NestDetails
{
public Units Units { get; init; }
/// <summary>One row per plate, in nest order.</summary>
public IReadOnlyList<NestPlateDetail> Plates { get; init; } = Array.Empty<NestPlateDetail>();
/// <summary>One row per drawing (cutoffs excluded), in nest order.</summary>
public IReadOnlyList<NestDrawingDetail> Drawings { get; init; } = Array.Empty<NestDrawingDetail>();
/// <summary>
/// The plates themselves, in the same order as <see cref="Plates"/>, for the plate
/// preview. They belong to the downloaded copy of the nest, never to an open document.
/// </summary>
public IReadOnlyList<Plate> PlateLayouts { get; init; } = Array.Empty<Plate>();
public static NestDetails FromNest(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
var plates = new List<NestPlateDetail>(nest.Plates.Count);
var nested = new Dictionary<Drawing, int>(ReferenceEqualityComparer.Instance);
for (var i = 0; i < nest.Plates.Count; i++)
{
var plate = nest.Plates[i];
var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
foreach (var part in parts)
nested[part.BaseDrawing] = nested.GetValueOrDefault(part.BaseDrawing) + plate.Quantity;
plates.Add(new NestPlateDetail(
Number: i + 1,
Duplicates: plate.Quantity,
Width: plate.Size.Width,
Length: plate.Size.Length,
PartCount: parts.Count,
DrawingCount: parts.Select(part => part.BaseDrawing).Distinct(ReferenceEqualityComparer.Instance).Count(),
// A zero-size plate has no meaningful utilization; report 0 rather than NaN.
Utilization: plate.Area() > 0 ? plate.Utilization() : 0));
}
var drawings = nest.Drawings
.Where(drawing => !drawing.IsCutOff)
.Select(drawing => new NestDrawingDetail(
Name: drawing.Name ?? "",
Customer: drawing.Customer ?? "",
Required: drawing.Quantity.Required,
Nested: nested.GetValueOrDefault(drawing),
Area: drawing.Area))
.ToList();
return new NestDetails
{
Units = nest.Units,
Plates = plates,
Drawings = drawings,
PlateLayouts = nest.Plates.ToList(),
};
}
}
/// <param name="Number">1-based position of the plate in the nest.</param>
/// <param name="Duplicates">How many times the plate is cut (<see cref="Plate.Quantity"/>).</param>
/// <param name="PartCount">Parts on one copy of the plate, cutoffs excluded.</param>
/// <param name="Utilization">Fraction of the plate area used by parts, 0 to 1.</param>
public sealed record NestPlateDetail(
int Number,
int Duplicates,
double Width,
double Length,
int PartCount,
int DrawingCount,
double Utilization);
/// <param name="Nested">Parts placed across all plates, counting each plate's duplicates.</param>
public sealed record NestDrawingDetail(string Name, string Customer, int Required, int Nested, double Area)
{
public int Remaining => System.Math.Max(0, Required - Nested);
}
+108
View File
@@ -0,0 +1,108 @@
namespace OpenNest.Data;
/// <summary>
/// Loads the details of the nest highlighted in the Database-mode nest browser. Moving
/// the highlight starts a new load and cancels the previous one; a superseded load's
/// result or failure is discarded, so details never belong to a nest that is no longer
/// highlighted. Call from one thread (the UI thread).
/// </summary>
public sealed class NestDetailsSession : IDisposable
{
private readonly Func<Guid, CancellationToken, Task<NestDetails>> _load;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
/// <param name="load">
/// Builds the details of one nest; throws when the nest cannot be read (for example
/// when it no longer exists on the server).
/// </param>
public NestDetailsSession(Func<Guid, CancellationToken, Task<NestDetails>> load)
{
ArgumentNullException.ThrowIfNull(load);
_load = load;
}
/// <summary>The nest of the latest load; null before the first load and after <see cref="Clear"/>.</summary>
public Guid? NestId { get; private set; }
/// <summary>Details of <see cref="NestId"/>; null while loading, after a failure and after <see cref="Clear"/>.</summary>
public NestDetails? Details { get; private set; }
/// <summary>True while the latest load is outstanding; superseded loads do not count.</summary>
public bool IsLoading { get; private set; }
/// <summary>
/// Loads one nest's details. Returns true when they were applied, false when a later
/// load or <see cref="Clear"/> superseded this one. A failure of the latest load
/// propagates; failures of superseded loads are ignored.
/// </summary>
public async Task<bool> LoadAsync(Guid id)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a load that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own load.
var cancellation = Supersede();
var generation = _generation;
NestId = id;
IsLoading = true;
try
{
var details = await _load(id, cancellation.Token);
if (generation != _generation)
return false;
Details = details;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
cancellation.Dispose();
}
}
/// <summary>Forgets the current details and discards any load in flight (no nest highlighted).</summary>
public void Clear()
{
if (_disposed)
return;
_generation++;
var pending = _pending;
_pending = null;
NestId = null;
Details = null;
IsLoading = false;
pending?.Cancel();
}
private CancellationTokenSource Supersede()
{
var previous = _pending;
var cancellation = new CancellationTokenSource();
_pending = cancellation;
_generation++;
Details = null;
previous?.Cancel();
return cancellation;
}
/// <summary>Cancels any load in flight (which releases its own source when it ends).</summary>
public void Dispose()
{
if (_disposed)
return;
Clear();
_disposed = true;
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace OpenNest.Data;
/// <summary>
/// Chooses the nest material when the Nest Info dialog is accepted. The dialog
/// edits only the material name, so grade and density must come from the
/// material the nest already has.
/// </summary>
public static class NestMaterialSelection
{
/// <summary>
/// Returns a new material named <paramref name="selectedName"/>. When that
/// name matches the current material's name by <see cref="SharedListNames.Key"/>,
/// the current grade and density are kept; any other name gets a name-only
/// material. The current material is never modified or returned.
/// </summary>
public static Material Apply(Material? current, string? selectedName)
{
var name = selectedName ?? "";
if (current != null && SharedListNames.Key(current.Name) == SharedListNames.Key(name))
return new Material(name, current.Grade, current.Density);
return new Material(name);
}
}
+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.Http;
using System.Net.Http.Headers;
@@ -58,6 +59,40 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return items ?? new List<NestRecord>();
}
public async Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError();
if (error is not null)
throw new ArgumentException(error, nameof(query));
using var response = await _httpClient.GetAsync(
Url("api/nests/query?" + BuildQueryString(query)), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
throw new IOException(
"This nest server does not support filtered browsing (HTTP 404). Update the nest server to this OpenNest version.");
}
await EnsureSuccess(response, "query nests", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestPage>(JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty page.");
if (page.Items.Count > query.Limit)
{
throw new IOException(
$"Nest server returned {page.Items.Count} records for a page limited to {query.Limit}.");
}
return page;
}
private static string BuildQueryString(NestQuery query) =>
"search=" + Uri.EscapeDataString(query.NormalizedSearch)
+ "&sort=" + JsonNamingPolicy.CamelCase.ConvertName(query.Sort.ToString())
+ "&order=" + (query.Descending ? "desc" : "asc")
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
+18
View File
@@ -0,0 +1,18 @@
namespace OpenNest.Data;
/// <summary>
/// Normalized matching key for names in company lists (customers, materials).
/// </summary>
public static class SharedListNames
{
/// <summary>
/// Returns the name trimmed, with internal whitespace runs collapsed to one
/// space, in invariant upper case. Names that differ only in spacing or case
/// share a key. A null or blank name has the empty key.
/// </summary>
public static string Key(string? name) =>
name == null
? ""
: string.Join(' ', name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
.ToUpperInvariant();
}
@@ -0,0 +1,45 @@
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.Tests.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests;
public class CutOffBoundedFillTests
{
public static IEnumerable<object[]> Cases()
{
foreach (var strategy in PlateFillService.BuiltInStrategies)
foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal })
foreach (var quadrant in new[] { 1, 3 })
foreach (var farSide in new[] { false, true })
yield return new object[] { strategy, axis, quadrant, farSide };
}
[Theory]
[MemberData(nameof(Cases))]
public void FillItemCannotCrossSelectedCutOffBoundary(
string strategy, CutOffAxis axis, int quadrant, bool farSide)
{
var plate = new Plate(new Size(20, 20)) { Quadrant = quadrant, PartSpacing = 0.5 };
var bounds = plate.WorkArea();
var position = new Vector(bounds.Left + 10, bounds.Bottom + 10);
plate.CutOffs.Add(new CutOff(position, axis));
// Independent expected selection box: one side of the cutoff, with spacing.
var area = axis == CutOffAxis.Vertical
? new Box(farSide ? position.X + 0.5 : bounds.Left, bounds.Bottom, 9.5, bounds.Width)
: new Box(bounds.Left, farSide ? position.Y + 0.5 : bounds.Bottom, bounds.Length, 9.5);
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(3, 3));
var parts = PlateFillService.FillItem(strategy, plate,
new NestItem { Drawing = drawing }, area, null, CancellationToken.None);
Assert.NotEmpty(parts);
Assert.Empty(plate.Parts);
Assert.All(parts, part =>
{
Assert.Same(drawing, part.BaseDrawing);
var box = part.BoundingBox;
Assert.True(box.Left >= area.Left - 1e-6 && box.Right <= area.Right + 1e-6);
Assert.True(box.Bottom >= area.Bottom - 1e-6 && box.Top <= area.Top + 1e-6);
});
}
}
@@ -0,0 +1,96 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutLatticeFillTests
{
private const double Spacing = 0.25;
// A 20" ring around a 10" round cutout. At 0.25" spacing the usable circle is 9.5" across;
// the largest square inside it is 6.72", which holds a 5 x 5 grid of 1" squares.
private const int InscribedRectangleCount = 25;
private static NestJobPart Ring() => JobBuilder.Part("ring", TestShapes.Ring(20, 10), 1);
private static NestJobPart Squares(double side, int quantity) =>
JobBuilder.Part("square", TestShapes.Rectangle(side, side), quantity);
[Fact]
public void ShiftedLatticeFillsMoreOfARoundCutoutThanItsInscribedRectangle()
{
var poses = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing);
Assert.True(poses.Count > InscribedRectangleCount, $"placed {poses.Count}");
AssertValidLayout(Ring(), Squares(1, poses.Count), poses);
}
[Fact]
public void ShiftingTheLatticeKeepsMoreCopiesThanTheUnshiftedLattice()
{
var shifted = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing);
var unshifted = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 100), 100, Spacing, 0, default);
Assert.True(shifted.Count > unshifted.Count, $"shifted {shifted.Count}, unshifted {unshifted.Count}");
}
[Theory]
[InlineData(7.0)] // diagonal 9.90: wider than the cutout less its wall spacing
[InlineData(6.75)] // corners 4.773 from the centre; the usable radius is 4.75
public void InsertThatCannotClearTheCutoutWallIsNeverPlaced(double side)
{
Assert.Empty(CutoutLatticeFill.Fill(Ring(), 0, Squares(side, 4), 4, Spacing));
}
[Fact]
public void QuantityCapsTheCopiesReturned()
{
var poses = CutoutLatticeFill.Fill(Ring(), 0, Squares(1, 5), 5, Spacing);
Assert.Equal(5, poses.Count);
AssertValidLayout(Ring(), Squares(1, 5), poses);
}
// Both cases fail intermittently; unskip when Project Memory opennest issue c98c21bd is fixed.
private const string FillNondeterminism = "Open defect (Project Memory opennest c98c21bd): Default Fill can "
+ "return different, equally scored lattices between identical calls.";
[Theory]
[InlineData(1.0, 1.0, Skip = FillNondeterminism)]
[InlineData(1.5, 0.75, Skip = FillNondeterminism)]
public void RepeatedCallsReturnIdenticalPoses(double length, double width)
{
var part = JobBuilder.Part("part", TestShapes.Rectangle(length, width), 100);
var first = CutoutLatticeFill.Fill(Ring(), 0, part, 100, Spacing);
var second = CutoutLatticeFill.Fill(Ring(), 0, part, 100, Spacing);
Assert.Equal(first, second);
}
/// <summary>Places the ring 1" in from the sheet corner, moves the copies with it and runs
/// the production layout check over the whole sheet.</summary>
private static void AssertValidLayout(NestJobPart frame, NestJobPart insert, IReadOnlyList<NestJobPlacement> poses)
{
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
var job = JobBuilder.Job(new[] { frame, insert }, new[] { stock });
var bounds = JobPartGeometry.Read(frame.Geometry).Bounds;
var x = 1 - bounds.Left;
var y = 1 - bounds.Bottom;
var placements = new[] { new NestJobPlacement(frame.Id, 0, x, y, 0) }
.Concat(poses.Select((p, i) => new NestJobPlacement(insert.Id, i, p.X + x, p.Y + y, p.Rotation)))
.ToArray();
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, placements) },
new[]
{
new PartFulfillment(frame.Id, 1, 1, 0),
new PartFulfillment(insert.Id, insert.Quantity, poses.Count, insert.Quantity - poses.Count),
},
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
}
}
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularBlockTests
{
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
new Dictionary<int, IReadOnlyList<PairPose>>();
[Theory]
[InlineData(1)]
[InlineData(2)]
public void SmallDemandNeverPreparesAFill(int quantity)
{
var job = Job(new[] { Part("ell", LShape(9, 7, 3), quantity, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
Assert.Empty(catalog.Get(types[0], quantity, new Box(0, 0, 60, 40), CancellationToken.None));
Assert.Equal(0, catalog.PreparationCount);
}
[Fact]
public void FillProposalIsTrimmedAndCertifiedWithoutChangingSingleRotations()
{
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 7, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
var original = types[0].Orientations.ToArray();
using var catalog = new BlockCatalog(0.25, types, NoPairs);
var block = catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None);
Assert.Equal(7, block.Count);
Assert.Equal(original, types[0].Orientations);
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
for (var i = 0; i < block.Count; i++)
for (var j = i + 1; j < block.Count; j++)
Assert.True(NestLayoutCheck.Clears(geometry,
new NestJobPlacement("ell", i, block[i].X, block[i].Y, block[i].Orientation.Rotation), geometry,
new NestJobPlacement("ell", j, block[j].X, block[j].Y, block[j].Orientation.Rotation), 0.25));
Assert.Same(block, catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None));
}
[Theory]
[InlineData(0)]
[InlineData(2.1)]
public void InvalidInternalSpacingRejectsTheWholeProposal(double offset)
{
var program = Shapes.Rectangle(2, 2);
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("box", program);
var members = Enumerable.Range(0, 3).Select(i => new OpenNest.Part(drawing)
{ Location = new Vector(i * offset, 0) }).ToArray();
var orientations = type.Orientations.ToList();
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
Assert.Equal(type.Orientations, orientations);
}
[Fact]
public void PhysicalFreeRectanglesExcludeOccupiedMaterialAndClearance()
{
var job = Job(new[] { Rectangle("box", 4, 10, 1) }, new[] { Stock("sheet", 10, 20) });
var type = PartCatalog.Build(job)[0];
var part = new Placed(type.Orientations[0], 8, 0);
var rectangles = BlockCatalog.Rectangles(new Box(0, 0, 20, 10), new[] { part }, 0.25);
Assert.NotEmpty(rectangles);
Assert.All(rectangles, box => Assert.True(box.Right <= 7.75 || box.Left >= 12.25));
}
[Fact]
public void RepeatedEllBlockCompetesWithPairsOnAnOpenSheet()
{
var stock = Stock("sheet", 30, 40, spacing: 0.25);
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 44, RotationPolicy.Automatic) }, new[] { stock });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 40, 30, CancellationToken.None);
using var blocks = new BlockCatalog(0.25, types, pairs);
var before = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter()).Fill(new[] { 44 }, CancellationToken.None);
var after = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 44 }, CancellationToken.None);
Assert.True(after.Parts.Count > before.Parts.Count,
$"Before {before.Parts.Count}; after {after.Parts.Count}");
var result = new NestJobResultBuilder(job);
result.AddSheet(stock, after.Parts.Select(p => ("ell", p.X, p.Y, p.Orientation.Rotation)));
LayoutAssert.Valid(job, result.Build(NestJobStopReason.StockExhausted));
}
[Fact]
public void UndersizedRectangleKeepsSinglesFallback()
{
var stock = Stock("sheet", 3, 3, spacing: 0.25);
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { stock });
var types = PartCatalog.Build(job);
using var blocks = new BlockCatalog(0.25, types, NoPairs);
Assert.Empty(blocks.Get(types[0], 3, new Box(0, 0, 3, 3), CancellationToken.None));
Assert.Equal(0, blocks.PreparationCount);
var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock,
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 3 }, CancellationToken.None);
Assert.Single(fill.Parts);
}
[Fact]
public void ForbiddenRotationsAreRejectedBeforeAddingGroupOrientations()
{
var program = LShape(9, 7, 3);
var job = Job(new[] { Part("ell", program, 3, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("ell", program);
var members = Enumerable.Range(0, 3).Select(i =>
{
var part = OpenNest.Part.CreateAtOrigin(drawing, System.Math.PI / 2);
part.Offset(new Vector(i * 12, 0));
return part;
}).ToArray();
var orientations = type.Orientations.ToList();
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
Assert.Equal(type.Orientations, orientations);
}
[Fact]
public void CancellationDuringCertificationPropagates()
{
var program = Shapes.Rectangle(2, 2);
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("box", program);
using var cancellation = new CancellationTokenSource();
var members = new CancellingMembers(Enumerable.Range(0, 3)
.Select(i => new OpenNest.Part(drawing) { Location = new Vector(i * 3, 0) }).ToArray(), cancellation);
Assert.Throws<OperationCanceledException>(() => BlockCatalog.Resolve(type, members,
type.Orientations.ToList(), 0.25, cancellation.Token));
}
private sealed class CancellingMembers(OpenNest.Part[] parts, CancellationTokenSource cancellation)
: IReadOnlyList<OpenNest.Part>
{
public int Count => parts.Length;
public OpenNest.Part this[int index] => parts[index];
public IEnumerator<OpenNest.Part> GetEnumerator()
{
foreach (var part in parts)
yield return part;
cancellation.Cancel();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[Fact]
public void CancellationIsNotConvertedIntoAnEmptyProposal()
{
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { Stock("sheet", 10, 20) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
Assert.Throws<OperationCanceledException>(() =>
catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true)));
}
}
@@ -0,0 +1,276 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>
/// A part whose material exactly fills the work area in one allowed rotation. The engine must
/// place it: preparation padding for arc flattening may not shrink a line-only part's room.
/// </summary>
public class IrregularExactContactTests
{
private const double PartLength = 8;
private const double PartWidth = 3;
private const double Edge = 0.25;
private const double Spacing = 0.25;
/// <summary>Gap shortfall the independent oracle tolerates (float noise only).</summary>
private const double OracleNoise = 1e-9;
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void ExactContactRotatedRectangleIsPlacedInEveryQuadrant(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Automatic);
var result = new IrregularNestingEngine().Solve(job);
AssertPlacedSafely(job, result);
}
[Theory]
[InlineData(8.501, 3.501)]
[InlineData(8.5, 3.501)]
[InlineData(8.501, 3.5)]
[InlineData(8.52, 3.52)] // Generator smoke stock: a looser control, not exact-contact acceptance.
public void LargerNeighbouringStockStillPlacesThePart(double width, double length)
{
var job = RectangleJob(width, length, 1, RotationPolicy.Automatic);
AssertPlacedSafely(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(8.499, 3.5, 1)]
[InlineData(8.5, 3.499, 1)]
[InlineData(8.499, 3.499, 3)]
public void StockOneThousandthTooSmallLeavesThePartUnplaced(double width, double length, int quadrant)
{
var job = RectangleJob(width, length, quadrant, RotationPolicy.Automatic);
AssertUnplaced(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void ZeroRotationOnlyCannotFitAndStaysUnplaced(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Fixed(0));
AssertUnplaced(job, new IrregularNestingEngine().Solve(job));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void HandPoseAtExactContactPassesAndOneThousandthOverEdgeFails(int quadrant)
{
var job = RectangleJob(8.5, 3.5, quadrant, RotationPolicy.Automatic);
var stock = job.Plates[0];
var work = stock.WorkArea;
// +90 degrees maps the source rectangle to X in [-3, 0], Y in [0, 8].
var exact = new NestJobPlacement("rect", 0, work.Left + PartWidth, work.Bottom, System.Math.PI / 2);
Assert.True(OracleGaps(stock, exact).Min() >= Edge - OracleNoise);
Assert.Empty(Violations(job, exact));
Validate(job, exact);
foreach (var (dx, dy) in new[] { (0.001, 0.0), (-0.001, 0.0), (0.0, 0.001), (0.0, -0.001) })
{
var over = exact with { X = exact.X + dx, Y = exact.Y + dy };
Assert.True(OracleGaps(stock, over).Min() < Edge - 0.0009);
Assert.NotEmpty(Violations(job, over));
Assert.Throws<InvalidOperationException>(() => Validate(job, over));
}
}
[Fact]
public void LineOnlyBoundsAreExactWhileFlatteningToleranceIsKept()
{
var job = RectangleJob(8.5, 3.5, 1, RotationPolicy.Automatic);
var type = PartCatalog.Build(job).Single();
var rotated = type.Orientations.Single(o => System.Math.Abs(o.Rotation - System.Math.PI / 2) < 1e-9);
// Chord tolerance still drives tessellation and NFP footprints.
Assert.Equal(PartCatalog.ChordTolerance, rotated.Tolerance);
Assert.Equal(-PartWidth, rotated.MinX, 12);
Assert.Equal(0, rotated.MinY, 12);
Assert.Equal(0, rotated.MaxX, 12);
Assert.Equal(PartLength, rotated.MaxY, 12);
Assert.True(job.Plates[0].Fits(rotated.Width, rotated.Height));
}
[Theory]
[InlineData(0.0)]
[InlineData(0.3)]
[InlineData(1.2)]
public void ArcBoundsStillContainTheTruePerimeterAndTheFlattenedOutline(double angle)
{
var job = Job(new[] { Part("obround", Shapes.Obround(9, 4), 1, RotationPolicy.Fixed(angle)) },
new[] { Stock("sheet", 40, 40, spacing: Spacing) });
var type = PartCatalog.Build(job).Single();
var o = type.Orientations.Single();
Assert.True(o.Tolerance >= PartCatalog.ChordTolerance);
// Independent oracle: sample the stadium analytically (straight sides plus semicircles).
const double length = 9, width = 4, r = width / 2;
var points = new List<(double X, double Y)>();
for (var i = 0; i <= 2000; i++)
{
var t = System.Math.PI * i / 2000;
points.Add((1 + length - r + r * System.Math.Sin(t), 1 + r - r * System.Math.Cos(t)));
points.Add((1 + r - r * System.Math.Sin(t), 1 + r + r * System.Math.Cos(t)));
}
var (c, s) = (System.Math.Cos(angle), System.Math.Sin(angle));
foreach (var (x, y) in points)
{
var rx = x * c - y * s;
var ry = x * s + y * c;
Assert.InRange(rx, o.MinX - 1e-9, o.MaxX + 1e-9);
Assert.InRange(ry, o.MinY - 1e-9, o.MaxY + 1e-9);
}
foreach (var p in o.Outline)
{
Assert.InRange(p.x, o.MinX, o.MaxX);
Assert.InRange(p.y, o.MinY, o.MaxY);
}
}
[Theory]
[InlineData(0.0, false)]
[InlineData(System.Math.PI / 2, false)]
[InlineData(System.Math.PI, false)]
[InlineData(3 * System.Math.PI / 2, false)]
[InlineData(0.0, true)]
[InlineData(System.Math.PI / 2, true)]
[InlineData(System.Math.PI, true)]
[InlineData(3 * System.Math.PI / 2, true)]
public void ShortEdgeChainBoundsKeepEveryAnalyticEndpoint(double angle, bool extraOrientation)
{
var job = Job(new[] { Part("rect", Shapes.Polyline(ShortEdgeChainVertices()), 1,
RotationPolicy.Fixed(extraOrientation ? 0 : angle)) }, new[] { Stock("sheet", 20, 20) });
var type = PartCatalog.Build(job).Single();
var orientation = extraOrientation
? PartCatalog.CreateOrientation(type, 100, angle)!
: type.Orientations.Single();
Assert.NotNull(orientation);
var (c, s) = (System.Math.Cos(angle), System.Math.Sin(angle));
var endpoints = ShortEdgeChainVertices()
.Select(p => (X: p.X * c - p.Y * s, Y: p.X * s + p.Y * c)).ToArray();
Assert.Equal(endpoints.Min(p => p.X), orientation.MinX, 12);
Assert.Equal(endpoints.Min(p => p.Y), orientation.MinY, 12);
Assert.Equal(endpoints.Max(p => p.X), orientation.MaxX, 12);
Assert.Equal(endpoints.Max(p => p.Y), orientation.MaxY, 12);
Assert.Equal(PartCatalog.ChordTolerance, orientation.Tolerance);
}
[Fact]
public void ShortEdgeChainCannotEscapeAnExactWorkArea()
{
var job = ShortEdgeChainJob(8.5);
var result = new IrregularNestingEngine().Solve(job);
Assert.Empty(NestLayoutCheck.Violations(job, result));
AssertUnplaced(job, result);
}
[Fact]
public void ShortEdgeChainFitsWhenStockContainsItsTrueExtent()
{
var job = ShortEdgeChainJob(8.500024);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal((1, 1, 0), (result.Fulfillment.Single().Requested,
result.Fulfillment.Single().Placed, result.Fulfillment.Single().Unplaced));
var sheet = Assert.Single(result.Plates);
var pose = Assert.Single(sheet.Placements);
Assert.True(OracleGaps(sheet.Stock, pose, ShortEdgeChainVertices()).Min() >= Edge - OracleNoise);
LayoutAssert.Valid(job, result);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Validate(job, pose);
}
// Polygon cleanup can collapse these individually short edges, but their accumulated
// outward extent is real material and exceeds the unchanged work-area slack.
private static (double X, double Y)[] ShortEdgeChainVertices() =>
[(0, 0), (0, 3), (8, 3), (8.000008, 2.999992),
(8.000016, 2.999984), (8.000024, 2.999976), (8, 0)];
private static NestJob ShortEdgeChainJob(double length) =>
Job(new[] { Part("rect", Shapes.Polyline(ShortEdgeChainVertices()), 1, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 3.5, length, spacing: Spacing, edge: new Spacing(Edge, Edge, Edge, Edge)) });
private static NestJob RectangleJob(double width, double length, int quadrant, RotationPolicy rotation) =>
Job(new[] { Rectangle("rect", PartLength, PartWidth, 1, rotation) },
new[] { Stock("sheet", width, length, spacing: Spacing, edge: new Spacing(Edge, Edge, Edge, Edge), quadrant: quadrant) });
private static void AssertPlacedSafely(NestJob job, NestJobResult result)
{
Assert.Equal(NestJobStatus.Complete, result.Status);
var fulfillment = Assert.Single(result.Fulfillment);
Assert.Equal((1, 1, 0), (fulfillment.Requested, fulfillment.Placed, fulfillment.Unplaced));
var sheet = Assert.Single(result.Plates);
Assert.Equal(1, Assert.Single(result.StockUsage).Used);
var pose = Assert.Single(sheet.Placements);
Assert.True(OracleGaps(sheet.Stock, pose).Min() >= Edge - OracleNoise,
$"pose ({pose.X:R}, {pose.Y:R}, {pose.Rotation:R}) gaps {string.Join(", ", OracleGaps(sheet.Stock, pose))}");
LayoutAssert.Valid(job, result);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Validate(job, pose);
}
private static void AssertUnplaced(NestJob job, NestJobResult result)
{
Assert.NotEqual(NestJobStatus.Complete, result.Status);
Assert.Empty(result.Plates);
var fulfillment = Assert.Single(result.Fulfillment);
Assert.Equal((1, 0, 1), (fulfillment.Requested, fulfillment.Placed, fulfillment.Unplaced));
LayoutAssert.Valid(job, result);
}
/// <summary>Clearance from the rotated nominal rectangle to each physical sheet edge
/// (left, bottom, right, top), computed from corners and the quadrant's sheet origin.</summary>
private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose) =>
OracleGaps(stock, pose, [(0, 0), (PartLength, 0), (PartLength, PartWidth), (0, PartWidth)]);
private static double[] OracleGaps(NestPlateStock stock, NestJobPlacement pose,
(double X, double Y)[] vertices)
{
var (c, s) = (System.Math.Cos(pose.Rotation), System.Math.Sin(pose.Rotation));
var corners = vertices
.Select(p => (X: p.X * c - p.Y * s + pose.X, Y: p.X * s + p.Y * c + pose.Y))
.ToArray();
var sheetLeft = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
var sheetBottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
return new[]
{
corners.Min(p => p.X) - sheetLeft,
corners.Min(p => p.Y) - sheetBottom,
sheetLeft + stock.Size.Length - corners.Max(p => p.X),
sheetBottom + stock.Size.Width - corners.Max(p => p.Y),
};
}
private static IReadOnlyList<string> Violations(NestJob job, NestJobPlacement pose)
{
var stock = job.Plates[0];
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { pose }) },
new[] { new PartFulfillment("rect", 1, 1, 0) }, new[] { new StockUsage(stock.Id, 1, null) });
return NestLayoutCheck.Violations(job, result);
}
private static void Validate(NestJob job, NestJobPlacement pose) =>
NestJobValidator.ValidateCandidate(new PlateCandidate(new[] { pose }), job.Plates[0],
new Dictionary<string, int> { ["rect"] = 1 }, job.Parts.ToDictionary(p => p.Id));
}
@@ -0,0 +1,229 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularPairTests
{
// A 20-long wedge, 8 wide at one end and 6 at the other. Lying down, two copies need
// 8 + 0.25 + 8 = 16.25 across, but nested slant-to-slant they need only about 14.25.
// The 30 x 15 sheet holds the pair only when the two copies interlock.
private static OpenNest.CNC.Program Wedge() => Polyline((0, 0), (8, 0), (6, 20), (0, 20));
private static OpenNest.CNC.Program MirroredWedge() => Polyline((0, 0), (8, 0), (8, 20), (2, 20));
public static TheoryData<string> Wedges => new() { "wedge", "mirrored" };
private static OpenNest.CNC.Program NativeU()
{
// Same native semicircular U as the shared curve-contact regression; no DXF dependency.
var p = new OpenNest.CNC.Program();
p.MoveTo(2.5, 0.625);
p.LineTo(2.5, 0);
p.LineTo(1.5, 0);
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 3, 1.5, 1.5, OpenNest.RotationType.CW));
p.LineTo(2.5, 3);
p.LineTo(2.5, 2.375);
p.LineTo(1.5, 2.375);
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 0.625, 1.5, 1.5, OpenNest.RotationType.CCW));
p.LineTo(2.5, 0.625);
return p;
}
[Fact]
public void NativeUQuantityTwoOffersAndPlacesAnInterlockedClearPair()
{
var job = Job(new[] { Part("native-u", NativeU(), 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 6, 6, spacing: 0.25) });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 6, 6, CancellationToken.None);
Assert.NotEmpty(pairs[0]);
var pair = pairs[0][0];
// Bounding boxes overlap on both axes: this is an interlock, not adjacent boxes.
Assert.True(System.Math.Min(pair.A.MaxX, pair.Dx + pair.B.MaxX)
> System.Math.Max(pair.A.MinX, pair.Dx + pair.B.MinX));
Assert.True(System.Math.Min(pair.A.MaxY, pair.Dy + pair.B.MaxY)
> System.Math.Max(pair.A.MinY, pair.Dy + pair.B.MinY));
var tightJob = Job(job.Parts.ToArray(),
new[] { Stock("tight", pair.Height + 0.01, pair.Width + 0.01, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(tightJob);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
LayoutAssert.Valid(tightJob, result);
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
Assert.Empty(geometry.Cutouts);
Assert.Equal(2.5, geometry.Bounds.Length, 9);
Assert.Equal(3, geometry.Bounds.Width, 9);
var outlines = result.Plates[0].Placements.Select(placement =>
{
var shape = (OpenNest.Geometry.Shape)geometry.Perimeter.Clone();
shape.Rotate(placement.Rotation);
shape.Offset(placement.X, placement.Y);
return shape.ToPolygonWithTolerance(1e-6);
}).ToArray();
// Raw fine boundaries, independent of BestFit's offsets and the NFP free regions.
Assert.True(OpenNest.Geometry.Clearance.Between(outlines[0], outlines[1]).Distance >= 0.25 - 2e-6);
Assert.True(outlines[0].BoundingBox.Right > outlines[1].BoundingBox.Left
&& outlines[1].BoundingBox.Right > outlines[0].BoundingBox.Left);
Assert.True(outlines[0].BoundingBox.Top > outlines[1].BoundingBox.Bottom
&& outlines[1].BoundingBox.Top > outlines[0].BoundingBox.Bottom);
}
[Theory]
[InlineData(0.0)] // Coincident material.
[InlineData(3.1)] // Disjoint outlines, but only 0.1 clearance rather than 0.25.
public void PairResolutionRejectsOverlapAndInsufficientSpacing(double offset)
{
var program = NativeU();
var job = Job(new[] { Part("native-u", program, 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 10, 10, spacing: 0.25) });
var type = Assert.Single(PartCatalog.Build(job));
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
var drawing = new Drawing("native-u", program);
var members = new List<OpenNest.Part>
{
new(drawing),
new(drawing) { Location = new OpenNest.Geometry.Vector(0, offset) },
};
var extra = new List<Orientation>();
Assert.Null(PairCatalog.Resolve(type, extra, geometry, members, 0, 0.25));
Assert.Empty(extra);
}
[Fact]
public void PairOnlyOrientationRemainsEligibleWhenSampledSinglesDoNotFit()
{
var wedge = MirroredWedge();
wedge.Rotate(System.Math.PI / 4);
// A many-type job samples only two single orientations. The pair adds its own
// legal rotations; neither sampled single fits this narrow stock.
var parts = Enumerable.Range(0, 24).Select(i => Rectangle($"oversized-{i}", 100, 100, 1)).ToList();
parts.Insert(0, Part("wedge", wedge, 2, RotationPolicy.Automatic));
var stock = Stock("sheet", 15, 30, spacing: 0.25);
var job = Job(parts.ToArray(), new[] { stock });
var types = PartCatalog.Build(job);
Assert.DoesNotContain(types[0].Orientations, o => stock.Fits(o.Width, o.Height));
var pairs = PairCatalog.Build(types, 0.25, 30, 15, CancellationToken.None);
Assert.Contains(pairs[0], p => stock.Fits(p.Width, p.Height));
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Theory]
[MemberData(nameof(Wedges))]
public void QuantityTwoInterlocksWhenOnlyAPairFits(string shape)
{
var program = shape == "wedge" ? Wedge() : MirroredWedge();
var job = Job(
new[] { Part("wedge", program, 2, RotationPolicy.Fixed(System.Math.PI / 2, allow180Equivalent: true)) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Theory]
[MemberData(nameof(Wedges))]
public void AutomaticRotationAlsoFindsThePair(string shape)
{
var program = shape == "wedge" ? Wedge() : MirroredWedge();
var job = Job(
new[] { Part("wedge", program, 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
}
[Fact]
public void PairsNeverUseARotationThePolicyForbids()
{
// Fixed at 0 with no 180-degree twin: every best-fit pair turns its second copy,
// so no pair is allowed and the parts must be placed as singles.
var job = Job(
new[] { Part("wedge", Wedge(), 2, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 25, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.All(result.Plates.SelectMany(p => p.Placements), p => Assert.Equal(0, p.Rotation, 9));
}
[Fact]
public void OddDemandPlacesTheLastCopyAsASingleWithoutOverdrawing()
{
var job = Job(new[] { Part("wedge", MirroredWedge(), 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new[] { 1, 2 }, result.Plates.Select(p => p.Placements.Count).OrderBy(n => n));
}
[Fact]
public void PairMembersLeaveTheirEnclosedGapAvailableForLaterParts()
{
var stock = Stock("sheet", 6.1, 6.1, spacing: 0.1);
var job = Job(new[] { Rectangle("bar", 2, 6, 2), Rectangle("insert", 1, 1, 1) }, new[] { stock });
var types = PartCatalog.Build(job);
var bar = Assert.Single(types[0].Orientations);
var pair = new PairPose { A = bar, B = bar, Dx = 4, Dy = 0 };
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
// Along Y the pair buys twice the area for the same advance, so it wins first.
var fill = new FrontierPacker(types, new NoFitCache(0.1), pairs, stock,
PackAxis.Y, 1, new WorkCounter()).Fill(new[] { 2, 1 }, CancellationToken.None);
Assert.Equal(3, fill.Parts.Count);
Assert.Equal(0, fill.Parts[0].Orientation.TypeIndex);
Assert.Equal(0, fill.Parts[1].Orientation.TypeIndex);
var insert = fill.Parts[2];
Assert.Equal(1, insert.Orientation.TypeIndex);
Assert.True(insert.Left > fill.Parts[0].Right);
Assert.True(insert.Right < fill.Parts[1].Left);
var geometries = job.Parts.Select(p => JobPartGeometry.Read(p.Geometry)).ToArray();
for (var i = 0; i < fill.Parts.Count; i++)
for (var j = i + 1; j < fill.Parts.Count; j++)
{
var a = fill.Parts[i];
var b = fill.Parts[j];
Assert.True(NestLayoutCheck.Clears(geometries[a.Orientation.TypeIndex],
new NestJobPlacement(job.Parts[a.Orientation.TypeIndex].Id, i, a.X, a.Y, a.Orientation.Rotation),
geometries[b.Orientation.TypeIndex],
new NestJobPlacement(job.Parts[b.Orientation.TypeIndex].Id, j, b.X, b.Y, b.Orientation.Rotation), 0.1));
}
}
[Fact]
public void PairedJobsAreDeterministic()
{
// Best-fit evaluates in parallel; equal-area pairs must still be chosen the same way.
NestJob Build() =>
Job(
new[]
{
Part("wedge", Wedge(), 6, RotationPolicy.Automatic),
Part("ell", LShape(9, 7, 3), 4, RotationPolicy.Automatic),
},
new[] { Stock("sheet", 30, 40, spacing: 0.25) }
);
var first = new IrregularNestingEngine().Solve(Build());
var second = new IrregularNestingEngine().Solve(Build());
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
}
}
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Whole-part cutting prerequisites for one captured plate. A cutoff precedes every part its
/// nominal span crosses (an orphaned cutoff, whose definition is missing, precedes every part),
/// and a part proven to lie inside a cutout of another part precedes that host. Built on the
/// caller thread from owned values; ambiguous containment refuses instead of guessing from bounds.
/// </summary>
internal sealed class CuttingDependencyGraph
{
private const int ContactBudget = 1000000;
private readonly int[][] prerequisites;
private CuttingDependencyGraph(int[][] prerequisites) => this.prerequisites = prerequisites;
internal static CuttingDependencyGraph Empty(int count) =>
new(Enumerable.Range(0, count).Select(_ => Array.Empty<int>()).ToArray());
/// <summary>Exact prerequisites, for tests and callers that already proved them.</summary>
internal static CuttingDependencyGraph FromPrerequisites(IReadOnlyList<int[]> prerequisites)
{
var copy = prerequisites.Select(p => p.Distinct().Order().ToArray()).ToArray();
if (copy.SelectMany(p => p).Any(i => i < 0 || i >= copy.Length))
throw new ArgumentException("Prerequisite ordinal out of range.");
return new(copy);
}
internal int Count => prerequisites.Length;
internal IReadOnlyList<int> PrerequisitesOf(int ordinal) => prerequisites[ordinal];
internal bool IsReady(int ordinal, IEnumerable<int> done)
{
if (prerequisites[ordinal].Length == 0)
return true;
var finished = done as ICollection<int> ?? done.ToHashSet();
return prerequisites[ordinal].All(finished.Contains);
}
/// <summary>First (part, missing prerequisite) in a proposed complete order, or null.</summary>
internal (int Part, int Prerequisite)? FirstViolation(IReadOnlyList<int> order)
{
var position = Enumerable.Repeat(-1, prerequisites.Length).ToArray();
for (var i = 0; i < order.Count; i++)
position[order[i]] = i;
foreach (var part in order)
foreach (var prerequisite in prerequisites[part])
if (position[prerequisite] < 0 || position[prerequisite] > position[part])
return (part, prerequisite);
return null;
}
/// <summary>An ordinal on a prerequisite cycle, or null when the graph is acyclic.</summary>
internal int? FindCycle()
{
var remaining = prerequisites.Select(p => p.Length).ToArray();
var dependents = Enumerable.Range(0, prerequisites.Length).Select(_ => new List<int>()).ToArray();
for (var part = 0; part < prerequisites.Length; part++)
foreach (var prerequisite in prerequisites[part])
dependents[prerequisite].Add(part);
var ready = new Queue<int>(Enumerable.Range(0, prerequisites.Length).Where(i => remaining[i] == 0));
while (ready.Count != 0)
foreach (var dependent in dependents[ready.Dequeue()])
if (--remaining[dependent] == 0)
ready.Enqueue(dependent);
var cyclic = Array.FindIndex(remaining, r => r != 0);
return cyclic < 0 ? null : cyclic;
}
internal static CuttingDependencyGraph Build(IReadOnlyList<DependencyNode> nodes, Box plate,
CancellationToken token)
{
var edges = nodes.Select(_ => new SortedSet<int>()).ToArray();
for (var cut = 0; cut < nodes.Count; cut++)
{
if (!nodes[cut].IsCutOff)
continue;
var definition = nodes[cut].CutOff;
for (var part = 0; part < nodes.Count; part++)
{
token.ThrowIfCancellationRequested();
if (!nodes[part].IsCutOff && (definition == null || CutOffCrosses(definition.Axis,
definition.Position, definition.StartLimit, definition.EndLimit, nodes[part].PlacedBounds, plate)))
edges[part].Add(cut);
}
}
for (var inner = 0; inner < nodes.Count; inner++)
for (var host = 0; host < nodes.Count; host++)
{
token.ThrowIfCancellationRequested();
// Bounds only select candidates; containment itself is proven on native material.
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds)
&& InsideCutout(nodes, inner, host, token))
edges[host].Add(inner);
}
var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray());
if (graph.FindCycle() is { } cyclic)
throw new CuttingDependencyException(CuttingPlanStatus.ConstraintConflict, cyclic, null,
$"Part {cyclic + 1} is on a cutting dependency cycle.");
return graph;
}
/// <summary>
/// Whether a cutoff's nominal line crosses a part's bounds within the cutoff's span. Uses the
/// nominal line, not its trimmed cutting segments (which deliberately skip the parts). Bounds
/// conservatively include edge contacts and concave recesses; limits prevent unrelated
/// dependencies beyond the cutoff's span. Negative coordinates need no special case.
/// </summary>
internal static bool CutOffCrosses(CutOffAxis axis, Vector cutOffPosition, double? startLimit,
double? endLimit, Box part, Box plate)
{
var vertical = axis == CutOffAxis.Vertical;
var position = vertical ? cutOffPosition.X : cutOffPosition.Y;
var acrossMin = vertical ? part.Left : part.Bottom;
var acrossMax = vertical ? part.Right : part.Top;
var alongMin = vertical ? part.Bottom : part.Left;
var alongMax = vertical ? part.Top : part.Right;
var start = startLimit ?? (vertical ? plate.Bottom : plate.Left);
var end = endLimit ?? (vertical ? plate.Top : plate.Right);
return !(position < acrossMin - Tolerance.Epsilon
|| position > acrossMax + Tolerance.Epsilon
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
}
// True when the inner part's perimeter lies strictly inside one cutout of the host. Any
// boundary contact or crossing between them, or incomplete material, is ambiguous.
private static bool InsideCutout(IReadOnlyList<DependencyNode> nodes, int inner, int host,
CancellationToken token)
{
var part = nodes[inner].Material;
var enclosing = nodes[host].Material;
if (!part.IsComplete || !enclosing.IsComplete)
throw Ambiguous(inner, host, part.Reason ?? enclosing.Reason);
var budget = ContactBudget;
foreach (var curve in part.Rings[0])
foreach (var ring in enclosing.Rings)
foreach (var boundary in ring)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw Ambiguous(inner, host, "the containment check exceeds the native query limit");
if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
throw Ambiguous(inner, host, "their boundaries touch or cross");
}
var probe = part.Rings[0][0].Start;
return enclosing.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
}
private static CuttingDependencyException Ambiguous(int inner, int host, string reason) =>
new(CuttingPlanStatus.UnsupportedGeometry, inner, host,
$"Containment of part {inner + 1} relative to part {host + 1} cannot be established: {reason}.");
private static bool Within(Box inner, Box outer) =>
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
}
/// <summary>Owned copy of a cutoff definition's nominal line.</summary>
internal sealed record CutOffDefinition(CutOffAxis Axis, Vector Position, double? StartLimit, double? EndLimit);
/// <summary>Owned per-placement dependency input; material is captured only when a pair needs it.</summary>
internal sealed class DependencyNode
{
private readonly Func<LeadMaterialSnapshot> materialFactory;
private LeadMaterialSnapshot material;
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
Func<LeadMaterialSnapshot> materialFactory)
{
IsCutOff = isCutOff;
CutOff = cutOff;
PlacedBounds = Copy(placedBounds);
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
HostBounds = cleanBounds == null ? PlacedBounds : Union(PlacedBounds, CleanBounds);
this.materialFactory = materialFactory;
}
internal bool IsCutOff { get; }
internal CutOffDefinition CutOff { get; }
internal Box PlacedBounds { get; }
internal Box CleanBounds { get; }
internal Box HostBounds { get; }
internal LeadMaterialSnapshot Material => material ??= materialFactory();
private static Box Copy(Box box) => new(box.X, box.Y, box.Length, box.Width);
private static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
}
}
/// <summary>A dependency refusal naming the part and, when relevant, the other part.</summary>
internal sealed class CuttingDependencyException(CuttingPlanStatus status, int ordinal, int? other, string message)
: Exception(message)
{
internal CuttingPlanStatus Status { get; } = status;
internal int Ordinal { get; } = ordinal;
internal int? Other { get; } = other;
}
@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Proposes a whole-part visiting order as an open travelling-salesman path from the start point
/// over part centres: nearest neighbour, then 2-opt and Or-opt improvement, never placing a part
/// before one of its prerequisites. It is only a proposal; the forward search still checks every
/// rapid and lead along it.
/// </summary>
internal static class CuttingPartOrder
{
private const double Epsilon = 1e-9;
/// <summary>Upper bound on improvement passes, so a large plate cannot loop for long.</summary>
internal const int MaxPasses = 50;
/// <param name="parts">The part ordinals to order; prerequisites outside this set count as done.</param>
/// <param name="centres">One representative point per part ordinal.</param>
/// <param name="start">The modeled tool position before the first of these parts.</param>
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
{
var local = new Dictionary<int, int>();
for (var i = 0; i < parts.Count; i++)
local.Add(parts[i], i);
var localCentres = parts.Select(p => centres[p]).ToArray();
var localPrerequisites = parts
.Select(p => (IReadOnlyCollection<int>)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray())
.ToArray();
return Plan(localCentres, start, localPrerequisites, token).Select(i => parts[i]).ToArray();
}
/// <param name="centres">One representative point per part ordinal.</param>
/// <param name="start">The modeled tool position before the first part.</param>
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
internal static int[] Plan(IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
{
var count = centres.Count;
var order = NearestNeighbour(centres, start, prerequisites, token);
var position = new int[count];
for (var pass = 0; pass < MaxPasses; pass++)
{
token.ThrowIfCancellationRequested();
var improved = TwoOpt(order, centres, start, prerequisites, position, token);
improved |= OrOpt(order, centres, start, prerequisites, position, token);
if (!improved)
break;
}
return order;
}
private static int[] NearestNeighbour(IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
{
var count = centres.Count;
var placed = new bool[count];
var order = new int[count];
var current = start;
for (var step = 0; step < count; step++)
{
token.ThrowIfCancellationRequested();
var best = -1;
var bestDistance = double.MaxValue;
for (var part = 0; part < count; part++)
{
if (placed[part] || prerequisites[part].Any(p => !placed[p]))
continue;
var distance = current.DistanceTo(centres[part]);
if (distance < bestDistance - Epsilon)
{
best = part;
bestDistance = distance;
}
}
if (best < 0)
throw new InvalidOperationException("Part prerequisites form a cycle.");
placed[best] = true;
order[step] = best;
current = centres[best];
}
return order;
}
// Reverses order[i..j] when that shortens the open path and keeps every prerequisite earlier.
private static bool TwoOpt(int[] order, IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
{
var improved = false;
var count = order.Length;
for (var i = 0; i < count - 1; i++)
{
token.ThrowIfCancellationRequested();
for (var j = i + 1; j < count; j++)
{
var before = Point(i - 1).DistanceTo(centres[order[i]])
+ (j + 1 < count ? centres[order[j]].DistanceTo(centres[order[j + 1]]) : 0);
var after = Point(i - 1).DistanceTo(centres[order[j]])
+ (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : 0);
if (after >= before - Epsilon || !CanReverse(order, i, j, prerequisites, position))
continue;
Array.Reverse(order, i, j - i + 1);
improved = true;
}
}
return improved;
Vector Point(int index) => index < 0 ? start : centres[order[index]];
}
// A reversal breaks a prerequisite only when both parts lie inside the reversed span.
private static bool CanReverse(int[] order, int i, int j, IReadOnlyList<IReadOnlyCollection<int>> prerequisites,
int[] position)
{
for (var k = 0; k < order.Length; k++)
position[order[k]] = k;
for (var k = i; k <= j; k++)
foreach (var prerequisite in prerequisites[order[k]])
if (position[prerequisite] >= i && position[prerequisite] <= j)
return false;
return true;
}
// Moves a run of one to three parts to a later or earlier gap when that shortens the path.
private static bool OrOpt(int[] order, IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
{
var improved = false;
var count = order.Length;
for (var length = 1; length <= 3; length++)
for (var i = 0; i + length <= count; i++)
{
token.ThrowIfCancellationRequested();
var last = i + length - 1;
var removal = Gap(i - 1, i) + Gap(last, last + 1) - Gap(i - 1, last + 1);
// Insert between order[gap - 1] and order[gap], outside the run.
for (var gap = 0; gap <= count; gap++)
{
if (gap >= i && gap <= last + 1)
continue;
var insertion = Gap(gap - 1, i) + Gap(last, gap) - Gap(gap - 1, gap);
if (insertion >= removal - Epsilon)
continue;
var candidate = Move(order, i, length, gap);
if (!Valid(candidate, prerequisites, position))
continue;
Array.Copy(candidate, order, count);
improved = true;
break;
}
}
return improved;
// Path length between order[a] and order[b] (a == -1 is the start; b == count is the open end).
double Gap(int a, int b)
{
if (b >= count || b < 0)
return 0;
var from = a < 0 ? start : centres[order[a]];
return from.DistanceTo(centres[order[b]]);
}
}
private static int[] Move(int[] order, int start, int length, int gap)
{
var run = order.Skip(start).Take(length).ToArray();
var rest = order.Take(start).Concat(order.Skip(start + length)).ToList();
var insertAt = gap > start ? gap - length : gap;
rest.InsertRange(insertAt, run);
return rest.ToArray();
}
internal static bool Valid(IReadOnlyList<int> order, IReadOnlyList<IReadOnlyCollection<int>> prerequisites,
int[] position)
{
for (var k = 0; k < order.Count; k++)
position[order[k]] = k;
for (var k = 0; k < order.Count; k++)
foreach (var prerequisite in prerequisites[order[k]])
if (position[prerequisite] > k)
return false;
return true;
}
}
@@ -0,0 +1,371 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>What a batch is doing for one plate, reported from the worker.</summary>
public enum CuttingPlanPhase
{
/// <summary>Searching for a new whole-part order.</summary>
Reordering,
/// <summary>Planning with the plate's current part order.</summary>
KeepingOrder,
/// <summary>Checking the plate's clean part material for overlaps.</summary>
CheckingOverlap,
}
/// <summary>Worker progress: zero-based position in the batch, the plate's display number and phase.</summary>
public sealed record CuttingPlanProgress(int PlateIndex, int PlateCount, int PlateNumber, CuttingPlanPhase Phase);
/// <summary>
/// One desktop planning attempt over one or more plates with caller-confirmed cutting parameters.
/// Capture on the thread that owns the plates, <see cref="Plan"/> on a worker, then apply the
/// returned proposal back on the owner thread. Every plate goes through
/// <see cref="CuttingPlanService"/>; nothing here installs programs or reorders parts itself.
/// </summary>
public sealed class CuttingPlanBatch
{
/// <summary>The smallest budget a plate gets, the service default.</summary>
public const int MinimumExpansionBudget = 20000;
/// <summary>
/// Expansions allowed per part, whether or not the order is kept: both still choose contour
/// order and entries for every part. Dense grids measured near 260 per part with a new order
/// and 210 when kept; the margin keeps 100-150 part plates inside the budget.
/// </summary>
public const int ExpansionsPerPart = 400;
private readonly Entry[] entries;
private readonly CuttingParameters ownedParameters;
private CuttingPlanBatch(Entry[] entries, CuttingParameters ownedParameters)
{
this.entries = entries;
this.ownedParameters = ownedParameters;
}
public int PlateCount => entries.Length;
/// <summary>
/// Captures every plate's exact state with an owned copy of <paramref name="confirmedParameters"/>,
/// and its clean part material for the overlap check. When the order may change, the
/// current-order request is captured too, so a free search that runs out of budget can be
/// retried on the worker without reading live plates again.
/// </summary>
public static CuttingPlanBatch Capture(IReadOnlyList<Plate> plates, CuttingParameters confirmedParameters,
bool preservePartOrder, IReadOnlyList<int> plateNumbers = null, CancellationToken token = default) =>
Capture(plates, confirmedParameters, preservePartOrder, plateNumbers, null, token);
/// <param name="reorderBudget">Overrides the new-order budget (tests); null gives <see cref="PlateBudget"/>.</param>
internal static CuttingPlanBatch Capture(IReadOnlyList<Plate> plates, CuttingParameters confirmedParameters,
bool preservePartOrder, IReadOnlyList<int> plateNumbers, int? reorderBudget, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(confirmedParameters);
if (plates.Count == 0)
throw new ArgumentException("At least one plate is required.", nameof(plates));
if (plates.Any(plate => plate == null)
|| plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
throw new ArgumentException("Plates must be distinct and not null.", nameof(plates));
if (plateNumbers != null && plateNumbers.Count != plates.Count)
throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
CuttingParameters owned;
try
{
owned = OwnedCuttingParameters.Copy(confirmedParameters);
}
catch (NotSupportedException)
{
owned = null; // Each plate's capture reports the unsupported settings itself.
}
var entries = new Entry[plates.Count];
for (var index = 0; index < plates.Count; index++)
{
var plate = plates[index];
var budget = PlateBudget(plate.Parts.Count);
var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
expansionBudget: budget, confirmedParameters: confirmedParameters,
preservePartOrder: true), token);
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap);
}
return new(entries, owned);
}
internal static int PlateBudget(int partCount) =>
(int)System.Math.Min(int.MaxValue,
System.Math.Max((long)MinimumExpansionBudget, (long)partCount * ExpansionsPerPart));
/// <summary>
/// Checks and plans every plate from its captured snapshots. Safe on a worker: live plates are
/// not read. Overlapping material blocks a plate whatever its route. A free search that ends
/// without a complete plan within its budget is retried with the current order; the proposal
/// reports that it kept the order.
/// </summary>
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> progress = null,
CancellationToken token = default)
{
var plans = new CuttingPlanPlateResult[entries.Length];
for (var index = 0; index < entries.Length; index++)
{
var entry = entries[index];
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.CheckingOverlap));
PlateOverlapReport overlap;
try
{
overlap = PlateOverlapAnalyzer.Analyze(entry.Overlap, token);
}
catch (OperationCanceledException)
{
plans[index] = new(entry.Plate, entry.Number, new(CuttingPlanStatus.Cancelled), null, null);
continue;
}
CuttingPlanResult reorder = null;
if (entry.Reorder != null)
{
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
reorder = CuttingPlanService.Plan(entry.Reorder, token);
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
{
plans[index] = new(entry.Plate, entry.Number, reorder, null, overlap);
continue;
}
}
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
plans[index] = new(entry.Plate, entry.Number, CuttingPlanService.Plan(entry.KeepOrder, token), reorder,
overlap);
}
return new(plans, ownedParameters);
}
private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder,
PlateOverlapSnapshot Overlap);
}
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
public sealed class CuttingPlanPlateResult
{
internal CuttingPlanPlateResult(Plate plate, int plateNumber, CuttingPlanResult result,
CuttingPlanResult reorderAttempt, PlateOverlapReport overlap)
{
Plate = plate;
PlateNumber = plateNumber;
Result = result;
ReorderAttempt = reorderAttempt;
Overlap = overlap;
}
/// <summary>The live plate. Read it only on the thread that owns it.</summary>
public Plate Plate { get; }
public int PlateNumber { get; }
/// <summary>The result that would be applied.</summary>
public CuttingPlanResult Result { get; }
/// <summary>The free-order attempt that ran out of budget before the current order was kept; else null.</summary>
public CuttingPlanResult ReorderAttempt { get; }
/// <summary>The clean-material overlap check (part ids are current part positions); null if cancelled first.</summary>
public PlateOverlapReport Overlap { get; }
public bool KeptCurrentOrder => ReorderAttempt != null;
/// <summary>The route was replayed and the overlap check completed without overlaps.</summary>
public bool IsRouteReady => Result.Status == CuttingPlanStatus.Ready && Result.IndependentlyReplayed;
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
public bool IsReady => IsRouteReady && IsOverlapClear;
public int PartCount => Result.ProposedOrder.Count;
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
public bool OrderChanged =>
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
}
/// <summary>
/// The outcome of a batch. Apply is all-or-nothing and is offered only when every plate is ready.
/// </summary>
public sealed class CuttingPlanProposal
{
private const int FindingsPerPlate = 5;
private readonly CuttingParameters ownedParameters;
internal CuttingPlanProposal(IReadOnlyList<CuttingPlanPlateResult> plates, CuttingParameters ownedParameters)
{
Plates = plates;
this.ownedParameters = ownedParameters;
}
public IReadOnlyList<CuttingPlanPlateResult> Plates { get; }
public bool IsCancelled => Plates.Any(p => p.Result.Status == CuttingPlanStatus.Cancelled);
public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
&& Plates.All(p => p.IsReady);
/// <summary>
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
/// of the confirmed parameters as its cutting settings; any other status changes nothing.
/// </summary>
public CuttingCommitResult Apply(CancellationToken token = default)
{
if (!CanApply)
return new(CuttingCommitStatus.InvalidInput,
"Every plate must have a ready plan before anything is applied.");
var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token);
if (commit.Status == CuttingCommitStatus.Applied)
foreach (var plate in Plates)
plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
return commit;
}
/// <summary>
/// A detached copy of a ready plate for display, in the proposed order with the proposed
/// programs, or null. A refused plate is not previewed: its program graphs may be unsafe to
/// copy. Neither is a plate that changed after planning, which would draw replayed programs at
/// poses that were never checked. The copy has quantity zero, so drawing quantities stay put.
/// Call it on the thread that owns the plates.
/// </summary>
public Plate BuildPreview(int index)
{
var planned = Plates[index];
if (!planned.IsReady || planned.Result.Snapshot?.PlateState?.IsCurrent() != true)
return null;
// Unchanged since capture: live poses, plate and programs are the validated, replayed ones.
var source = planned.Plate;
var preview = new Plate(source.Size)
{
Quantity = 0,
Quadrant = source.Quadrant,
PartSpacing = source.PartSpacing,
EdgeSpacing = source.EdgeSpacing,
};
foreach (var proposal in planned.Result.ProposedOrder)
{
var part = (Part)proposal.SourcePart.Clone();
part.RestoreLeadInProgram(proposal.CopyProgram(), proposal.LeadInsLocked);
preview.Parts.Add(part);
}
return preview;
}
/// <summary>Operator-facing summary: one overall line, then each plate and its findings.</summary>
public IReadOnlyList<string> Describe(string unit)
{
var lines = new List<string>();
var ready = Plates.Count(p => p.IsReady);
if (IsCancelled)
lines.Add("Planning was cancelled. Nothing has changed.");
else if (CanApply)
lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
else
lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not "
+ "be planned. No plate changes until every plate is ready.");
foreach (var plate in Plates)
{
lines.Add(string.Empty);
lines.AddRange(DescribePlate(plate, unit));
}
return lines;
}
private static IEnumerable<string> DescribePlate(CuttingPlanPlateResult plate, string unit)
{
var heading = $"Plate {plate.PlateNumber}: ";
var result = plate.Result;
if (plate.IsReady && plate.PartCount == 0)
{
yield return heading + "no parts.";
yield break;
}
if (plate.IsReady)
{
var kept = plate.PartCount - plate.RegeneratedCount;
var order = plate.KeptCurrentOrder
? "No new part order was found within the search limit, so the current order is kept."
: plate.OrderChanged ? "The part order changes." : "The part order is unchanged.";
yield return heading + $"ready. {Count(plate.PartCount, "part")}, {plate.RegeneratedCount} "
+ $"regenerated, {kept} kept as is; rapid travel "
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}";
yield break;
}
yield return heading + (plate.IsRouteReady
? "blocked: parts overlap or could not be checked for overlap."
: StatusText(result.Status));
foreach (var line in Limit(DescribeOverlap(plate.Overlap)))
yield return line;
if (plate.IsRouteReady)
yield break;
if (plate.KeptCurrentOrder)
yield return " No new part order was found within the search limit, and planning with the "
+ "current order was refused:";
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
yield return line;
}
private static IEnumerable<string> Limit(IEnumerable<string> lines)
{
var all = lines.ToList();
foreach (var line in all.Take(FindingsPerPlate))
yield return " - " + line;
if (all.Count > FindingsPerPlate)
yield return $" ... and {all.Count - FindingsPerPlate} more.";
}
private static IEnumerable<string> DescribeOverlap(PlateOverlapReport overlap)
{
if (overlap == null)
yield break;
foreach (var pair in overlap.Pairs)
yield return $"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}.";
foreach (var issue in overlap.Issues)
yield return $"Overlap check incomplete for part {issue.PartAId + 1}"
+ (issue.PartBId is int other ? $" and part {other + 1}" : string.Empty) + $": {issue.Message}";
}
private static string StatusText(CuttingPlanStatus status) => status switch
{
CuttingPlanStatus.Ready => "ready, but the plan was not independently replayed.",
CuttingPlanStatus.ConstraintConflict => "blocked: the fixed programs or required cut order conflict.",
CuttingPlanStatus.UnsupportedGeometry => "blocked: unsupported geometry or an incomplete check.",
CuttingPlanStatus.InvalidInput => "blocked: invalid input.",
CuttingPlanStatus.NoSolutionWithinBudget => "no complete plan was found within the search limit.",
CuttingPlanStatus.Cancelled => "cancelled.",
_ => status.ToString(),
};
private static string DescribeFinding(CuttingPlanFinding finding)
{
var text = finding.SourceOrdinal is int ordinal
? $"Part {ordinal + 1}{Name(finding.SourcePart)}: {finding.Message}"
: finding.Message;
if (finding.OtherSourceOrdinal is int other)
text += $" (with part {other + 1}{Name(finding.OtherSourcePart)})";
return text;
}
private static string Name(Part part) => Name(part?.BaseDrawing?.Name);
private static string Name(string name) => string.IsNullOrWhiteSpace(name) ? string.Empty : $" ({name})";
private static string Count(int count, string noun) => $"{count} {noun}{(count == 1 ? string.Empty : "s")}";
}
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Caller-side direct-XY input. Regeneration requires explicit confirmed parameters.
/// Keep sources/settings stable during Capture. Only plate-scoped requests (<see cref="ForPlate"/>)
/// record the state that CuttingPlanService.Apply checks; a detached part list cannot be applied.
/// </summary>
public sealed class CuttingPlanRequest
{
public CuttingPlanRequest(IEnumerable<Part> parts, Vector startPoint = default, int expansionBudget = 20000,
CuttingParameters confirmedParameters = null, IEnumerable<Part> eligibleParts = null,
bool preservePartOrder = false, int maxEntries = 16)
{
Parts = parts == null ? null : Array.AsReadOnly(parts.ToArray());
StartPoint = startPoint;
ExpansionBudget = expansionBudget;
ConfirmedParameters = confirmedParameters;
EligibleParts = eligibleParts == null ? null : Array.AsReadOnly(eligibleParts.ToArray());
PreservePartOrder = preservePartOrder;
MaxEntries = maxEntries;
}
/// <summary>
/// Plate scope: plans the plate's current parts and captures its exact state, so a Ready
/// result can later be applied through CuttingPlanService.Apply. A factory rather than a
/// constructor overload, so <c>new CuttingPlanRequest(null)</c> stays unambiguous.
/// </summary>
public static CuttingPlanRequest ForPlate(Plate plate, Vector startPoint = default, int expansionBudget = 20000,
CuttingParameters confirmedParameters = null, IEnumerable<Part> eligibleParts = null,
bool preservePartOrder = false, int maxEntries = 16) =>
new(plate?.Parts, startPoint, expansionBudget, confirmedParameters, eligibleParts,
preservePartOrder, maxEntries)
{ Plate = plate };
/// <summary>The plate scope, or null for a detached part list that cannot be applied.</summary>
public Plate Plate { get; private init; }
public CuttingParameters ConfirmedParameters { get; }
public IReadOnlyList<Part> EligibleParts { get; }
public bool PreservePartOrder { get; }
public int MaxEntries { get; }
internal Action<int> ExpansionObserver { get; init; }
public IReadOnlyList<Part> Parts { get; }
public Vector StartPoint { get; }
public int ExpansionBudget { get; }
}
/// <summary>Owned worker input. SourcePart references are identity handles only, never worker data.</summary>
public sealed class CuttingPlanSnapshot
{
internal CuttingPlanSnapshot(IEnumerable<FixedProgramPlacement> placements, Vector startPoint,
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
Action<int> expansionObserver = null, PlateCuttingState plateState = null,
CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null)
{
PlateState = plateState;
OwnedParameters = ownedParameters;
Placements = Array.AsReadOnly(placements.ToArray());
Dependencies = dependencies ?? CuttingDependencyGraph.Empty(Placements.Count);
StartPoint = startPoint;
ExpansionBudget = expansionBudget;
Failure = failure;
Findings = Array.AsReadOnly((findings ?? []).ToArray());
Regeneration = regeneration;
PreservePartOrder = preservePartOrder;
MaxEntries = maxEntries;
ExpansionObserver = expansionObserver;
}
/// <summary>Whole-part prerequisites by source ordinal (cutoffs, inner parts before hosts).</summary>
internal CuttingDependencyGraph Dependencies { get; }
/// <summary>Exact captured plate state for plate-scoped requests; null for detached part lists.</summary>
internal PlateCuttingState PlateState { get; }
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
internal CuttingParameters OwnedParameters { get; }
internal bool Regeneration { get; }
internal bool PreservePartOrder { get; }
internal int MaxEntries { get; }
internal Action<int> ExpansionObserver { get; }
public IReadOnlyList<FixedProgramPlacement> Placements { get; }
public Vector StartPoint { get; }
public int ExpansionBudget { get; }
internal CuttingPlanStatus? Failure { get; }
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
}
public sealed class FixedProgramPlacement
{
internal FixedProgramPlacement(Part sourcePart, int sourceOrdinal, Vector location,
double rotation, bool leadInsLocked, OwnedExecution execution, Program program = null,
PreparedContours prepared = null, LeadMaterialSnapshot material = null,
IReadOnlyList<ContourChoice> choices = null)
{
SourcePart = sourcePart;
SourceOrdinal = sourceOrdinal;
Location = location;
Rotation = rotation;
LeadInsLocked = leadInsLocked;
Execution = execution;
this.program = program;
Prepared = prepared;
Material = material;
ContourChoices = Array.AsReadOnly((choices ?? []).ToArray());
}
private readonly Program program;
internal PreparedContours Prepared { get; }
internal LeadMaterialSnapshot Material { get; }
public IReadOnlyList<ContourChoice> ContourChoices { get; }
public bool IsRegenerated => ContourChoices.Count != 0;
/// <summary>Returns an independent deep copy; never an alias to captured/proposed code.</summary>
public Program CopyProgram() => program == null ? null : OwnedProgramCopy.Copy(program);
internal SelectedContourProgram SelectedProgram { get; private init; }
internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList<ContourChoice> choices,
System.Threading.CancellationToken token = default)
{
SelectedContourProgram selected = null;
try
{
// The expected program is constructed from owned choices/settings, NEVER
// from proposed or its cached execution. Counterfeit payloads remain subject
// to independent final checks, including callers of this internal test seam.
selected = Prepared?.CaptureSelectedProgram(choices, Location, token);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException or ArithmeticException)
{
// Invalid metadata is a refused proposal, not an exception from final replay.
}
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
{ SelectedProgram = selected, IsCutOff = IsCutOff };
}
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
public bool IsCutOff { get; internal init; }
public Part SourcePart { get; }
public int SourceOrdinal { get; }
public Vector Location { get; }
public double Rotation { get; }
public bool LeadInsLocked { get; }
public OwnedExecution Execution { get; }
}
public enum CuttingPlanStatus
{
Ready,
ConstraintConflict,
UnsupportedGeometry,
InvalidInput,
NoSolutionWithinBudget,
Cancelled
}
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
int? OtherSourceOrdinal, Part OtherSourcePart, PostVerificationKind? Kind, string Message);
/// <summary>
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
/// captured cutoff/containment prerequisites. Not physical safety or posting consent. Apply
/// installs it only for a plate-scoped request whose plate is unchanged. Failures contain no proposals.
/// </summary>
public sealed class CuttingPlanResult
{
internal CuttingPlanResult(CuttingPlanStatus status, IEnumerable<FixedProgramPlacement> order = null,
IEnumerable<CuttingPlanFinding> findings = null, int expansions = 0,
double rapidDistance = 0, bool independentlyReplayed = false)
{
Status = status;
ProposedOrder = Array.AsReadOnly((order ?? []).ToArray());
Findings = Array.AsReadOnly((findings ?? []).ToArray());
Expansions = expansions;
RapidDistance = rapidDistance;
IndependentlyReplayed = independentlyReplayed;
}
public CuttingPlanStatus Status { get; }
public IReadOnlyList<FixedProgramPlacement> ProposedOrder { get; }
public IReadOnlyList<CuttingPlanFinding> Findings { get; }
public int Expansions { get; }
public double RapidDistance { get; }
public bool IndependentlyReplayed { get; }
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
internal CuttingPlanSnapshot Snapshot { get; set; }
}
@@ -0,0 +1,385 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
public static class CuttingPlanService
{
/// <summary>
/// Read stable caller-owned sources once into privately owned programs and geometry.
/// No private Plates, settings aliases, quantity updates or source subcall rebinding.
/// </summary>
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default)
{
var placements = new List<FixedProgramPlacement>();
Part source = null;
int? ordinal = null;
try
{
token.ThrowIfCancellationRequested();
PlateCuttingState plateState = null;
if (request?.Plate != null)
{
// Exact freshness record first, on the caller thread; the planned list must be it.
plateState = PlateCuttingState.Capture(request.Plate, token);
if (request.Parts == null || !request.Parts.SequenceEqual(plateState.Order, ReferenceEqualityComparer.Instance))
throw new ArgumentException("The plate's parts changed after the request was created.");
if (request.Parts.Count == 0 && request.ExpansionBudget > 0 && request.MaxEntries > 0)
return new([], request.StartPoint, request.ExpansionBudget, plateState: plateState);
}
if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0 || request.MaxEntries <= 0)
throw new ArgumentException("A nonempty source list and positive expansion budget are required.");
var eligible = new HashSet<Part>(ReferenceEqualityComparer.Instance);
if (request.EligibleParts != null)
{
if (request.ConfirmedParameters == null)
throw new ArgumentException("Eligibility requires confirmed cutting parameters.");
foreach (var part in request.EligibleParts)
if (part == null || !request.Parts.Any(p => ReferenceEquals(p, part)) || !eligible.Add(part))
throw new ArgumentException("Foreign or duplicate eligible placement.");
}
// Cutoff definitions by drawing reference, as automatic sequencing matches them.
var definitions = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
foreach (var cutOff in request.Plate?.CutOffs ?? [])
if (cutOff?.Drawing != null)
definitions[cutOff.Drawing] = cutOff;
var nodes = new List<DependencyNode>();
// Reuse the reader's coordinate validation without publishing its native kernel.
var identities = new HashSet<Part>(ReferenceEqualityComparer.Instance);
for (var index = 0; index < request.Parts.Count; index++)
{
token.ThrowIfCancellationRequested();
source = request.Parts[index];
ordinal = index;
if (source?.BaseDrawing == null || source.Program == null || !double.IsFinite(source.Rotation)
|| !identities.Add(source))
throw new ArgumentException("Missing/duplicate source placement or invalid pose.");
// Validate both original graphs before Clone or any virtual transform can
// erase unsupported runtime semantics, including fixed/ineligible targets.
OwnedProgramCopy.Validate(source.BaseDrawing.Program, token);
OwnedProgramCopy.Validate(source.Program, token);
if (source.BaseDrawing.IsCutOff)
{
// A cutoff is a fixed open cut: never material, never regenerated.
if (request.Plate == null)
throw new NotSupportedException("Cutoff dependencies require a plate-scoped request.");
if (eligible.Contains(source))
throw new ArgumentException("A cutoff cannot be eligible for regeneration.");
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
if (!cut.HasCuttingContour)
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition)
? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
source.BoundingBox, null, null));
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
cut, OwnedProgramCopy.Copy(source.Program, token))
{ IsCutOff = true });
continue;
}
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
if (!clean.HasCuttingContour)
throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice.");
var execution = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
if (!execution.HasCuttingContour)
throw new ArgumentException("Placed program has no nonzero cutting contour motions.");
var ownedProgram = OwnedProgramCopy.Copy(source.Program, token);
PreparedContours prepared = null;
LeadMaterialSnapshot material = null;
// Geometry-only transform: original graphs were strictly validated and
// clone expansion bounded above. Legacy Rotate visits per parent, so
// retain Clone's per-parent sharing here instead of restoring diamonds.
// Exact placed/proposed payloads still use lossless OwnedProgramCopy.
var ownedClean = (Program)source.BaseDrawing.Program.Clone();
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
// Material extent only: remote rapids and scribe marks must not widen it, or a
// genuine insert could fail the containment prefilter.
var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
var location = source.Location;
if (request.ConfirmedParameters != null)
{
material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
if (!material.IsComplete)
throw new NotSupportedException(material.Reason);
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
}
var captured = material;
// Fixed-route material is captured only for a pair whose bounds admit containment.
nodes.Add(new(false, null, source.BoundingBox, cleanBounds,
() => captured ?? LeadMaterialSnapshot.Capture(ownedClean, location, token)));
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
execution, ownedProgram, prepared, material));
}
// The start can be invalid even when the first rapid has no cutting geometry.
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
source = null;
ordinal = null;
var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token);
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
expansionObserver: request.ExpansionObserver, plateState: plateState,
ownedParameters: request.ConfirmedParameters == null ? null
: OwnedCuttingParameters.Copy(request.ConfirmedParameters),
dependencies: dependencies);
}
catch (OperationCanceledException)
{
return Failure(CuttingPlanStatus.Cancelled, "Capture cancelled.");
}
catch (CuttingDependencyException exception)
{
var other = exception.Other is { } index ? request.Parts[index] : null;
return new(placements, request.StartPoint, request.ExpansionBudget, exception.Status,
[new(exception.Ordinal, request.Parts[exception.Ordinal], exception.Other, other, null, exception.Message)]);
}
catch (NotSupportedException exception)
{
return Failure(CuttingPlanStatus.UnsupportedGeometry, exception.Message);
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or ArithmeticException)
{
return Failure(CuttingPlanStatus.InvalidInput, exception.Message);
}
CuttingPlanSnapshot Failure(CuttingPlanStatus status, string message) =>
new(placements, request?.StartPoint ?? Vector.Zero, request?.ExpansionBudget ?? 0, status,
[new(ordinal, source, null, null, null, message)]);
}
public static CuttingPlanResult Plan(CuttingPlanRequest request, CancellationToken token = default) =>
Plan(Capture(request, token), token);
/// <summary>Worker-only planning uses owned values; live identities are never dereferenced.</summary>
public static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token = default)
{
var result = PlanCaptured(snapshot, token);
result.Snapshot = snapshot;
return result;
}
/// <summary>
/// Installs exactly the replayed proposals of Ready plate-scoped results, all or nothing.
/// Each plate must still match the state captured with its request; otherwise Stale and
/// nothing changes. Run on the thread that owns the plates. Never replans.
/// </summary>
public static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token = default) =>
Apply(results, token, null);
// beforeInstall is the commit's install-boundary test seam.
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
Action<Plate, Part> beforeInstall)
{
var plans = new List<PlateCuttingPlan>();
foreach (var result in results ?? [])
{
var snapshot = result?.Snapshot;
if (result?.Status != CuttingPlanStatus.Ready || !result.IndependentlyReplayed
|| snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
return new(CuttingCommitStatus.InvalidInput,
"Only Ready, independently replayed plate-scoped proposals can be applied.");
var programs = new List<PlannedPartProgram>();
foreach (var proposal in result.ProposedOrder)
{
if (!proposal.IsRegenerated)
continue;
if (snapshot.OwnedParameters == null)
return new(CuttingCommitStatus.InvalidInput, "A regenerated proposal has no captured settings.",
snapshot.PlateState.Plate);
// Fresh owned copies: a result can be applied at most once per captured state,
// and nothing installed aliases the proposal or another part's settings.
programs.Add(new(proposal.SourcePart, proposal.CopyProgram(),
OwnedCuttingParameters.Copy(snapshot.OwnedParameters)));
}
plans.Add(new(snapshot.PlateState, result.ProposedOrder.Select(p => p.SourcePart), programs));
}
return CuttingPlanCommit.Apply(plans, token, beforeInstall);
}
private static CuttingPlanResult PlanCaptured(CuttingPlanSnapshot snapshot, CancellationToken token)
{
if (token.IsCancellationRequested)
return new(CuttingPlanStatus.Cancelled);
if (snapshot == null)
return new(CuttingPlanStatus.InvalidInput);
if (snapshot.Failure is { } failure)
return new(failure, findings: snapshot.Findings);
if (snapshot.Placements.Count == 0)
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
if (snapshot.PreservePartOrder
&& snapshot.Dependencies.FirstViolation(Enumerable.Range(0, snapshot.Placements.Count).ToArray()) is { } manual)
return new(CuttingPlanStatus.ConstraintConflict,
findings: [DependencyFinding(snapshot, manual, "The preserved part order")]);
try
{
var fixedFindings = new List<CuttingPlanFinding>();
foreach (var placement in snapshot.Placements)
{
token.ThrowIfCancellationRequested();
if (placement.Prepared != null)
continue; // An eligible old crossing is precisely what regeneration may repair.
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
var findings = new ReleasedContourState().Check(placement.Execution, null,
placement.SourceOrdinal + 1, placement.IsCutOff, token);
fixedFindings.AddRange(Map(snapshot, findings));
}
if (fixedFindings.Count != 0)
return new(fixedFindings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: fixedFindings);
if (snapshot.Regeneration)
{
var joint = JointCuttingPlanSearch.Run(snapshot, token);
if (joint.Status != CuttingPlanStatus.Ready)
return new(joint.Status, findings: joint.Findings, expansions: joint.Expansions);
return ReplayPrograms(snapshot, joint.Order, joint.Expansions, token);
}
var search = FixedProgramSearch.Run(snapshot, token);
if (search.Status != CuttingPlanStatus.Ready)
return new(search.Status, findings: Map(snapshot, search.Findings), expansions: search.Expansions);
return Replay(snapshot, search.Order, search.Expansions, token);
}
catch (OperationCanceledException)
{
return new(CuttingPlanStatus.Cancelled);
}
}
// Fresh checker and full sequence replay: no cached branch state is accepted as evidence.
internal static CuttingPlanResult Replay(CuttingPlanSnapshot snapshot, IReadOnlyList<int> order,
int expansions, CancellationToken token)
{
if (order.Count != snapshot.Placements.Count || order.Distinct().Count() != order.Count
|| order.Any(index => index < 0 || index >= snapshot.Placements.Count))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
// Dependencies are rechecked from the captured graph, not trusted from the search.
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState();
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List<PostVerificationFinding>();
foreach (var index in order)
{
token.ThrowIfCancellationRequested();
var placement = snapshot.Placements[index];
findings.AddRange(checker.Check(placement.Execution, position, placement.SourceOrdinal + 1,
placement.IsCutOff, token));
distance += placement.Execution.RapidDistanceFrom(position);
position = placement.Execution.DeparturePoint;
}
token.ThrowIfCancellationRequested();
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: Map(snapshot, findings), expansions: expansions);
return new(CuttingPlanStatus.Ready, order.Select(index => snapshot.Placements[index]),
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
}
// Re-read EXACT selected programs with a fresh checker and native lead validation.
// No emission/regeneration or cached branch verdict is used here.
internal static CuttingPlanResult ReplayPrograms(CuttingPlanSnapshot snapshot,
IReadOnlyList<FixedProgramPlacement> order, int expansions, CancellationToken token)
{
if (order == null || order.Count != snapshot.Placements.Count
|| order.Any(p => p == null || p.SourceOrdinal < 0 || p.SourceOrdinal >= snapshot.Placements.Count)
|| order.Select(p => p.SourceOrdinal).Distinct().Count() != order.Count)
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState();
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List<CuttingPlanFinding>();
var materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
foreach (var proposal in order)
{
token.ThrowIfCancellationRequested();
var source = snapshot.Placements[proposal.SourceOrdinal];
if (!ReferenceEquals(source.SourcePart, proposal.SourcePart)
|| !SameBits(source.Location.X, proposal.Location.X) || !SameBits(source.Location.Y, proposal.Location.Y)
|| !SameBits(source.Rotation, proposal.Rotation) || source.LeadInsLocked != proposal.LeadInsLocked
|| !ReferenceEquals(source.Prepared, proposal.Prepared) || !ReferenceEquals(source.Material, proposal.Material)
|| source.IsCutOff != proposal.IsCutOff
|| snapshot.PreservePartOrder && proposal.SourceOrdinal != order.TakeWhile(p => !ReferenceEquals(p, proposal)).Count())
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
if (source.Prepared == null)
{
if (!ReferenceEquals(source, proposal))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
}
else if (proposal.SelectedProgram == null
|| !proposal.SelectedProgram.Matches(source.Prepared, proposal.ContourChoices))
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
OwnedExecution execution;
bool complete;
try
{
execution = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, position, token);
// A cutoff is an open cut with no material; it is retained exactly (checked above).
complete = source.IsCutOff
|| ContourProgramVerifier.Verify(execution, source.Material, proposal.SelectedProgram, token);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return new(CuttingPlanStatus.UnsupportedGeometry,
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
}
findings.AddRange(JointCuttingPlanSearch.Map(snapshot,
checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, token)));
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
: LeadPathValidator.Check(execution, source.Material, materials, token);
if (!lead.IsComplete || !lead.IsClear)
findings.Add(JointCuttingPlanSearch.Finding(source,
lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
if (!complete && findings.Count == 0)
return new(CuttingPlanStatus.InvalidInput,
findings: [JointCuttingPlanSearch.Finding(source, null,
"Selected program does not preserve complete directed contours, certified tabs or selected entry/style geometry.")],
expansions: expansions);
distance += execution.RapidDistanceFrom(position);
position = execution.DeparturePoint;
}
token.ThrowIfCancellationRequested();
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: findings, expansions: expansions);
return new(CuttingPlanStatus.Ready, order, expansions: expansions,
rapidDistance: distance, independentlyReplayed: true);
}
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
(int Part, int Prerequisite) violation, string subject)
{
var part = snapshot.Placements[violation.Part];
var prerequisite = snapshot.Placements[violation.Prerequisite];
var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
}
private static bool SameBits(double source, double proposed) =>
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
private static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot,
IEnumerable<PostVerificationFinding> findings) => findings.Select(finding =>
{
var source = finding.PartNumber is { } part ? snapshot.Placements[part - 1] : null;
var other = finding.OtherPartNumber is { } otherPart ? snapshot.Placements[otherPart - 1] : null;
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
other?.SourceOrdinal, other?.SourcePart, finding.Kind, finding.Message);
});
}
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>Bounded forward depth-first search, nearest feasible branch then source ordinal.</summary>
internal static class FixedProgramSearch
{
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<int> Order,
IReadOnlyList<PostVerificationFinding> Findings, int Expansions);
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
var stack = new Stack<Frame>();
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
var rejected = new HashSet<PostVerificationFinding>();
var expansions = 0;
while (stack.Count != 0)
{
token.ThrowIfCancellationRequested();
var frame = stack.Peek();
if (frame.Order.Length == snapshot.Placements.Count)
return new(CuttingPlanStatus.Ready, frame.Order, [], expansions);
if (frame.Next == frame.Candidates.Length)
{
stack.Pop();
continue;
}
if (expansions == snapshot.ExpansionBudget)
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.ToArray(), expansions);
var candidate = frame.Candidates[frame.Next++];
expansions++;
var placement = snapshot.Placements[candidate];
var checker = frame.Checker.Copy();
var findings = checker.Check(placement.Execution, frame.Position, placement.SourceOrdinal + 1,
placement.IsCutOff, token);
if (findings.Count != 0)
{
rejected.UnionWith(findings);
continue;
}
stack.Push(Create([.. frame.Order, candidate], placement.Execution.DeparturePoint, checker));
}
return new(CuttingPlanStatus.ConstraintConflict, [], rejected.ToArray(), expansions);
Frame Create(int[] order, Vector position, ReleasedContourState checker) => new(order, position, checker,
Enumerable.Range(0, snapshot.Placements.Count).Where(index => !order.Contains(index)
&& snapshot.Dependencies.IsReady(index, order)
&& (!snapshot.PreservePartOrder || index == order.Length))
.OrderBy(index => snapshot.Placements[index].Execution.RapidDistanceFrom(position))
.ThenBy(index => snapshot.Placements[index].SourceOrdinal).ToArray());
}
private sealed class Frame(int[] order, Vector position, ReleasedContourState checker, int[] candidates)
{
internal int[] Order { get; } = order;
internal Vector Position { get; } = position;
internal ReleasedContourState Checker { get; } = checker;
internal int[] Candidates { get; } = candidates;
internal int Next { get; set; }
}
}
@@ -0,0 +1,362 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Plans whole parts and their emitted contour prefixes along a part order. A preserved order is
/// followed with full backtracking. Otherwise the order comes from <see cref="CuttingPartOrder"/>;
/// when a part on it cannot be reached without crossing parts already cut, the search learns
/// "cut this part before those", keeps the parts cut before them and re-plans the rest. Once
/// nothing new can be learned, the remaining budget goes to a full search over every ready part.
/// </summary>
internal static class JointCuttingPlanSearch
{
/// <summary>
/// Expansions per entry and contour that a reordering attempt may spend without getting further
/// along its order before it gives up and learns from the part that blocked it.
/// </summary>
internal const int StallExpansionsPerEntry = 8;
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions);
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
var walk = new Walk(snapshot, token);
var count = snapshot.Placements.Count;
try
{
if (snapshot.PreservePartOrder)
{
var kept = walk.Follow(Enumerable.Range(0, count).ToArray(), null, null);
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
}
var centres = snapshot.Placements.Select(Centre).ToArray();
var prerequisites = Enumerable.Range(0, count)
.Select(i => new HashSet<int>(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres,
snapshot.StartPoint, prerequisites, token);
Node resume = null;
while (true)
{
var attempt = walk.Follow(sequence, stall, resume);
if (attempt.Order != null)
return walk.Ready(attempt.Order);
if (!Learn(attempt, prerequisites))
{
// "Cut before" rules are a heuristic: a part blocked straight after another can
// still be reachable via a third. Search every order with what budget remains.
var full = walk.Follow(null, null, null);
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
}
// Back up to just before the earliest part the blocked approach crossed, keep the
// parts cut before it, and re-plan the rest from where the tool is at that point.
var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part));
resume = attempt.BoundaryAt(back);
sequence = [.. sequence.Take(back), .. CuttingPartOrder.Plan(sequence[back..], centres,
resume.Position, prerequisites, token)];
}
}
catch (BudgetExceededException)
{
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions);
}
catch (OperationCanceledException)
{
return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions);
}
}
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
// would contradict an order already required. False when nothing new was learned.
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
{
if (attempt.Blocked is not int blocked)
return false;
var learned = false;
foreach (var crossed in attempt.Crossed.Order())
{
if (crossed == blocked || prerequisites[crossed].Contains(blocked) || Precedes(crossed, blocked, prerequisites))
continue;
prerequisites[crossed].Add(blocked);
learned = true;
}
return learned;
}
// True when 'first' must already come before 'second' through the prerequisite chain.
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
{
var seen = new HashSet<int>();
var pending = new Stack<int>(prerequisites[second]);
while (pending.Count != 0)
{
var part = pending.Pop();
if (part == first)
return true;
if (seen.Add(part))
foreach (var prerequisite in prerequisites[part])
pending.Push(prerequisite);
}
return false;
}
// The centre of the part's placed cut material, for ordering only.
private static Vector Centre(FixedProgramPlacement placement)
{
var cuts = placement.Execution.Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList();
return cuts.Count == 0 ? placement.Execution.DeparturePoint : cuts.GetBoundingBox().Center;
}
internal static CuttingPlanFinding Finding(FixedProgramPlacement source, PostVerificationKind? kind, string message) =>
new(source.SourceOrdinal, source.SourcePart, null, null, kind, message);
internal static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot, IEnumerable<PostVerificationFinding> findings) =>
findings.Select(f =>
{
var source = f.PartNumber is { } p ? snapshot.Placements[p - 1] : null;
var other = f.OtherPartNumber is { } o ? snapshot.Placements[o - 1] : null;
return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
other?.SourceOrdinal, other?.SourcePart, f.Kind, f.Message);
});
/// <summary>
/// The forward DFS over the next part on a given order and its emitted contour prefixes.
/// Attempts share one expansion budget and one list of rejected findings.
/// </summary>
private sealed class Walk(CuttingPlanSnapshot snapshot, CancellationToken token)
{
private readonly List<CuttingPlanFinding> rejected = [];
private readonly LeadMaterialSnapshot[] materials =
snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
internal int Expansions { get; private set; }
internal IReadOnlyList<CuttingPlanFinding> Rejected => rejected.Distinct().ToArray();
internal Outcome Ready(IReadOnlyList<FixedProgramPlacement> order) =>
new(CuttingPlanStatus.Ready, order, [], Expansions);
// Entries are capped; exhaustion is not a proof over all possible entries.
internal Outcome Exhausted()
{
var status = snapshot.Placements.Any(p => p.Prepared != null)
? CuttingPlanStatus.NoSolutionWithinBudget
: rejected.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict;
return new(status, [], Rejected, Expansions);
}
/// <summary>
/// Follows <paramref name="sequence"/> from <paramref name="resume"/> (or the start point);
/// a null sequence tries every dependency-ready part, nearest first.
/// With a stall limit the attempt ends once that many expansions pass without getting
/// further along the order; it never backtracks behind its starting node.
/// </summary>
internal Attempt Follow(int[] sequence, int? stall, Node resume)
{
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null);
var attempt = new Attempt(sequence);
var progressExpansions = Expansions;
var stack = new Stack<Frame>();
stack.Push(new(root));
while (stack.Count != 0)
{
token.ThrowIfCancellationRequested();
var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
{
attempt.Order = node.Order;
return attempt;
}
if (attempt.Advance(node))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
frame.Children ??= Expand(node, sequence, attempt).OrderBy(c => c.Distance)
.ThenBy(c => c.Ordinal).ThenBy(c => c.Contour).ThenBy(c => c.Entry).ToArray();
if (frame.Next == frame.Children.Length)
{
stack.Pop();
continue;
}
stack.Push(new(frame.Children[frame.Next++].Node));
}
return attempt;
}
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt)
{
IEnumerable<FixedProgramPlacement> sources;
if (node.Active is { } active)
sources = [active.Source];
else
{
var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
sources = sequence == null
? snapshot.Placements.Where(p => !finished.Contains(p.SourceOrdinal)
&& snapshot.Dependencies.IsReady(p.SourceOrdinal, finished))
: snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished)
? [snapshot.Placements[sequence[node.Order.Length]]] : [];
}
foreach (var source in sources)
{
token.ThrowIfCancellationRequested();
if (source.Prepared == null)
{
CountExpansion(source);
var checker = node.Checker.Copy();
if (Check(source, source.Execution, node.Position, checker, attempt))
yield return new(new([.. node.Order, source], source.Execution.DeparturePoint, checker, null, node),
source.Execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, -1);
continue;
}
var prepared = source.Prepared;
var choices = node.Active?.Choices ?? [];
var arrival = node.Active?.Arrival ?? node.Position;
var before = node.Active?.Before ?? node.Checker;
var boundary = node.Active?.Boundary ?? node;
var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
foreach (var contour in contours)
{
token.ThrowIfCancellationRequested();
var entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token);
for (var entry = 0; entry < entries.Count; entry++)
{
CountExpansion(source); // Before emission/native queries, including rejected candidates.
var prefix = choices.Append(entries[entry]).ToArray();
Program program;
OwnedExecution execution;
try
{
program = prefix.Length == prepared.Count ? prepared.Emit(prefix) : prepared.EmitPrefix(prefix);
execution = ExecutionMotionReader.Read(program, source.Location, arrival, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
{
rejected.Add(Finding(source, PostVerificationKind.Incomplete,
$"Contour {contour}, entry {entry} emission refused: {ex.Message}"));
continue; // Retain emitter semantics, never repair/shorten invalid lead styles.
}
// Prefix includes all earlier cuts and scribes. Replay from BEFORE the whole part.
var checker = before.Copy();
if (!Check(source, execution, arrival, checker, attempt)) continue;
var distance = execution.RapidDistanceFrom(arrival);
var next = prefix.Length == prepared.Count
? new Node([.. node.Order, source.Propose(program, execution, prefix, token)],
execution.DeparturePoint, checker, null, boundary)
: new Node(node.Order, execution.DeparturePoint, checker,
new(source, prefix, arrival, before, distance, boundary), null);
yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry);
}
}
}
}
private void CountExpansion(FixedProgramPlacement source)
{
token.ThrowIfCancellationRequested();
if (Expansions == snapshot.ExpansionBudget)
{
rejected.Add(Finding(source, null, $"Expansion budget {snapshot.ExpansionBudget} reached before the next candidate."));
throw new BudgetExceededException();
}
Expansions++;
snapshot.ExpansionObserver?.Invoke(Expansions);
token.ThrowIfCancellationRequested();
}
private bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival,
ReleasedContourState checker, Attempt attempt)
{
var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, source.IsCutOff, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
// A fixed cutoff has no material or leads to certify; its rapids are still checked.
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
: LeadPathValidator.Check(execution, source.Material, materials, token);
if (!lead.IsComplete || !lead.IsClear)
rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
return findings.Count == 0 && lead.IsComplete && lead.IsClear;
}
}
/// <summary>One pass along an order: how far it got, which part stopped it and what that part crossed.</summary>
private sealed class Attempt(int[] sequence)
{
private long progress = -1;
internal IReadOnlyList<FixedProgramPlacement> Order { get; set; }
/// <summary>The deepest whole-part boundary reached; its Previous chain leads back to the root.</summary>
private Node Deepest { get; set; }
/// <summary>The ordinal of the part the attempt could not get past, or null.</summary>
internal int? Blocked => sequence == null || Deepest == null || Deepest.Order.Length >= sequence.Length ? null
: sequence[Deepest.Order.Length];
/// <summary>Parts whose completed contours the blocked part's motions crossed (it can be among them).</summary>
internal HashSet<int> Crossed { get; } = [];
// Records a node that gets further along the order than any before. True when it does.
internal bool Advance(Node node)
{
var depth = (long)node.Order.Length * (int.MaxValue + 1L) + (node.Active?.Choices.Length ?? 0);
if (depth <= progress)
return false;
progress = depth;
if (node.Active == null)
{
Deepest = node;
Crossed.Clear();
}
return true;
}
internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings)
{
if (Blocked != ordinal)
return;
foreach (var finding in findings)
if (finding.Kind == PostVerificationKind.RapidCrossing && finding.OtherPartNumber is int other)
Crossed.Add(other - 1);
}
/// <summary>The part boundary at <paramref name="depth"/> on the way to the deepest one.</summary>
internal Node BoundaryAt(int depth)
{
var node = Deepest;
while (node.Order.Length > depth)
node = node.Previous;
return node;
}
}
private sealed class BudgetExceededException : Exception;
private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival,
ReleasedContourState Before, double Distance, Node Boundary);
/// <summary>A search state; Previous links a whole-part boundary to the boundary before it.</summary>
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
ActivePart Active, Node Previous);
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry);
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
internal Edge[] Children { get; set; }
internal int Next { get; set; }
}
}
+26 -5
View File
@@ -53,6 +53,13 @@ namespace OpenNest.Engine.Fill
/// </summary>
public string Label { get; set; }
/// <summary>
/// When positive, grid fills stop adding perpendicular rows once they hold at least
/// this many parts; a first row that already holds them is returned on its own. The
/// first row is always complete. Zero fills the whole work area.
/// </summary>
public int MaxParts { get; init; }
private static Vector MakeOffset(NestDirection direction, double distance)
{
return direction == NestDirection.Horizontal
@@ -195,7 +202,12 @@ namespace OpenNest.Engine.Fill
/// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area.
/// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, OffsetPerimeterCache cache)
private List<Part> TilePattern(
Pattern basePattern,
NestDirection direction,
OffsetPerimeterCache cache,
int maxParts = 0
)
{
var copyDistance = FindPatternCopyDistance(basePattern, direction, cache);
@@ -211,7 +223,10 @@ namespace OpenNest.Engine.Fill
var count = 1;
while (true)
// maxParts counts the base pattern too: stop once the tiling holds that many.
bool Full() => maxParts > 0 && basePattern.Parts.Count + result.Count >= maxParts;
while (!Full())
{
var nextPos = start + copyDistance * count;
@@ -230,7 +245,7 @@ namespace OpenNest.Engine.Fill
// next copy that didn't fit as a whole. This handles cases where
// e.g. a 2-part pair only partially fits — one part may still be
// within the work area even though the full pattern exceeds it.
if (basePattern.Parts.Count > 1)
if (basePattern.Parts.Count > 1 && !Full())
{
var offset = MakeOffset(direction, copyDistance * count);
@@ -383,7 +398,7 @@ namespace OpenNest.Engine.Fill
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
{
row.AddRange(TilePattern(pattern, perpAxis, cache));
row.AddRange(TilePattern(pattern, perpAxis, cache, MaxParts));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
{
@@ -395,13 +410,19 @@ namespace OpenNest.Engine.Fill
return row;
}
// A capped fill whose first row already holds enough parts needs no more rows.
// Step 2's check could only switch perpendicular tiling to the bbox fallback; it
// never repairs the row itself, so returning the row loses no protection.
if (MaxParts > 0 && row.Count >= MaxParts)
return row;
// Step 2: Build row pattern and tile along perpendicular axis
var rowPattern = new Pattern();
rowPattern.Parts.AddRange(row);
rowPattern.UpdateBounds();
var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache));
gridResult.AddRange(TilePattern(rowPattern, perpAxis, cache, MaxParts));
// Only the unchanged row is covered by Step 1's clean verdict: skip Step 2
// only when the perpendicular tiling appended zero parts, so gridResult
+33 -11
View File
@@ -68,14 +68,16 @@ public class StripeFiller
sheetSpan,
spacing,
axis,
_context.Token
_context.Token,
_context.MaxQuantity
);
var shrinkResult = ConvergeStripeAngleShrink(
pairParts,
sheetSpan,
spacing,
axis,
_context.Token
_context.Token,
_context.MaxQuantity
);
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
@@ -127,7 +129,11 @@ public class StripeFiller
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
return null;
var stripeEngine = new FillLinear(stripeBox, spacing) { Label = "Stripe" };
var stripeEngine = new FillLinear(stripeBox, spacing)
{
Label = "Stripe",
MaxParts = _context.MaxQuantity,
};
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
if (stripeParts == null || stripeParts.Count == 0)
@@ -144,7 +150,11 @@ public class StripeFiller
stripePattern.Parts.AddRange(stripeParts);
stripePattern.UpdateBounds();
var gridEngine = new FillLinear(workArea, spacing) { Label = "Stripe-Grid" };
var gridEngine = new FillLinear(workArea, spacing)
{
Label = "Stripe-Grid",
MaxParts = _context.MaxQuantity,
};
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
if (gridParts == null || gridParts.Count == 0)
@@ -179,6 +189,10 @@ public class StripeFiller
var allParts = new List<Part>(gridParts);
// A capped fill that already holds the quantity needs no remnant parts.
if (_context.MaxQuantity > 0 && gridParts.Count >= _context.MaxQuantity)
return allParts;
var remnantParts = FillRemnant(gridParts, primaryAxis);
if (remnantParts != null)
{
@@ -351,18 +365,20 @@ public class StripeFiller
/// <summary>
/// Iteratively finds the rotation angle where N copies of the pattern
/// span the given dimension with minimal waste by expanding pair width.
/// Returns (angle, waste, pairCount).
/// Returns (angle, waste, pairCount). A positive <paramref name="maxParts"/> caps
/// the estimate rows like <see cref="FillLinear.MaxParts"/>.
/// </summary>
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
List<Part> patternParts,
double sheetSpan,
double spacing,
NestDirection axis,
CancellationToken token = default
CancellationToken token = default,
int maxParts = 0
)
{
var startAngle = OrientShortSideAlong(patternParts, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
}
/// <summary>
@@ -374,7 +390,8 @@ public class StripeFiller
double sheetSpan,
double spacing,
NestDirection axis,
CancellationToken token = default
CancellationToken token = default,
int maxParts = 0
)
{
var baseAngle = OrientShortSideAlong(patternParts, axis);
@@ -392,7 +409,7 @@ public class StripeFiller
return (0, double.MaxValue, 0);
var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token, maxParts);
}
private static (double Angle, double Waste, int Count) ConvergeFromAngle(
@@ -401,7 +418,8 @@ public class StripeFiller
double sheetSpan,
double spacing,
NestDirection axis,
CancellationToken token
CancellationToken token,
int maxParts
)
{
var bestWaste = double.MaxValue;
@@ -428,7 +446,11 @@ public class StripeFiller
axis == NestDirection.Horizontal
? new Box(0, 0, sheetSpan, perpDim)
: new Box(0, 0, perpDim, sheetSpan);
var engine = new FillLinear(stripeBox, spacing) { Label = "Stripe-EstimateRow" };
var engine = new FillLinear(stripeBox, spacing)
{
Label = "Stripe-EstimateRow",
MaxParts = maxParts,
};
var filled = engine.Fill(rotated, axis);
var n = filled?.Count ?? 0;
@@ -0,0 +1,227 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>
/// Fills one closed cutout of a frame part with copies of one insert part. A Fill lattice is
/// built over the cutout's bounds plus one part step on every side and shifted across a grid of
/// offsets of up to half a step each way. At each offset the copies whose spacing-grown outline
/// lies inside the cutout (a point test against the insert's inner-fit region of the cutout)
/// are counted; the offset keeping the most copies wins. Every kept pose is then certified with
/// <see cref="NestLayoutCheck.Clears"/> against the frame and the other copies.
/// </summary>
/// <remarks>
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order.
/// </remarks>
internal static class CutoutLatticeFill
{
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
internal const int DefaultShiftSteps = 8;
private const double FlattenTolerance = 0.001;
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
private const double Margin = FlattenTolerance + 0.001;
private const int Precision = NestTolerances.ClipperPrecision;
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
/// or none when no copy fits.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, CancellationToken token = default) =>
Fill(frame, cutoutIndex, insert, maxQuantity, spacing, DefaultShiftSteps, token);
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(insert);
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
return Array.Empty<NestJobPlacement>();
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
if (frameGeometry == null || insertGeometry == null)
return Array.Empty<NestJobPlacement>();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed())
return Array.Empty<NestJobPlacement>();
// Inscribed: the flattened cutout never extends past the real one.
var hole = Positive(ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, FlattenTolerance, circumscribe: false),
new Vector()));
if (hole.Count < 3)
return Array.Empty<NestJobPlacement>();
var bounds = cutout.BoundingBox;
var insertBounds = insertGeometry.Bounds;
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
var lattice = Lattice(insert, bounds, step, spacing, token);
if (lattice.Count == 0)
return Array.Empty<NestJobPlacement>();
var regions = new Dictionary<double, PathsD>();
foreach (var rotation in lattice.Select(p => p.Rotation).Distinct())
regions[rotation] = InnerFit(insertGeometry, rotation, hole, spacing);
var best = (Count: 0, I: 0, J: 0);
for (var i = -shiftSteps; i <= shiftSteps; i++)
for (var j = -shiftSteps; j <= shiftSteps; j++)
{
token.ThrowIfCancellationRequested();
var (dx, dy) = Shift(step, shiftSteps, i, j);
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy));
if (Better(count, i, j, best))
best = (count, i, j);
}
if (best.Count == 0)
return Array.Empty<NestJobPlacement>();
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy))
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
System.Math.Round(p.Y + sy, 8), p.Rotation))
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
.ToList();
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
}
/// <summary>Default Fill over the cutout's bounds grown by one step on every side, in frame
/// coordinates. Fill places whole parts only, so the margin keeps every offset covered.</summary>
private static List<NestJobPlacement> Lattice(NestJobPart insert, Box bounds, double step, double spacing,
CancellationToken token)
{
var drawing = DrawingJobMapper.CreateDrawing(insert);
try
{
var parts = PrivatePlateFill.Run(drawing, insert.Rotation, spacing,
bounds.Length + 2 * step, bounds.Width + 2 * step, 0, token);
token.ThrowIfCancellationRequested();
var left = bounds.Left - step;
var bottom = bounds.Bottom - step;
return parts.Select(p => new NestJobPlacement(insert.Id, 0, p.Location.X + left, p.Location.Y + bottom,
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.Where(p => double.IsFinite(p.X) && double.IsFinite(p.Y) && insert.Rotation.Allows(p.Rotation))
.ToList();
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return new List<NestJobPlacement>();
}
finally
{
BestFitCache.Invalidate(drawing);
}
}
/// <summary>
/// Reference points t at which the insert, rotated and grown by the spacing, lies inside the
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
/// never meets the cutout boundary (t outside boundary + reflected outline).
/// </summary>
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
{
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
foreach (var entity in entities)
entity.Rotate(rotation);
var perimeter = new ShapeProfile(entities).Perimeter;
var outline = Positive(ClipperBridge.ToPath(ClipperBridge.Flatten(perimeter, FlattenTolerance, circumscribe: true),
new Vector()));
var grown = Clipper.InflatePaths(new PathsD { outline }, spacing + Margin, JoinType.Round, EndType.Polygon,
2.0, Precision, FlattenTolerance)
.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).FirstOrDefault();
if (grown == null || grown.Count < 3)
return new PathsD();
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
var forbidden = Clipper.Union(Minkowski.Sum(reflected, hole, true, Precision),
new PathsD { Clipper.TranslatePath(reflected, hole[0].x, hole[0].y) }, FillRule.NonZero, Precision);
var anchor = grown[0];
return Clipper.Difference(new PathsD { Clipper.TranslatePath(hole, -anchor.x, -anchor.y) }, forbidden,
FillRule.NonZero, Precision);
}
/// <summary>Drops copies that fail the production clearance check against the frame; any
/// failing pair of copies rejects the whole fill, since the lattice itself is then unsound.</summary>
private static IReadOnlyList<NestJobPlacement> Certify(string frameId, JobPartGeometry frame,
JobPartGeometry insertGeometry, List<NestJobPlacement> poses, double spacing, CancellationToken token)
{
var framePose = new NestJobPlacement(frameId, 0, 0, 0, 0);
var clear = new List<NestJobPlacement>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
if (NestLayoutCheck.Clears(frame, framePose, insertGeometry, pose, spacing))
clear.Add(pose);
}
// Rotation is about the reference point, so every copy's material lies within this
// distance of its pose whatever its rotation.
var b = insertGeometry.Bounds;
var reach = new[] { (b.Left, b.Bottom), (b.Right, b.Bottom), (b.Right, b.Top), (b.Left, b.Top) }
.Max(c => System.Math.Sqrt(c.Item1 * c.Item1 + c.Item2 * c.Item2));
var apart = 2 * reach + spacing;
for (var i = 0; i < clear.Count; i++)
for (var j = i + 1; j < clear.Count; j++)
{
token.ThrowIfCancellationRequested();
if (System.Math.Abs(clear[i].X - clear[j].X) > apart || System.Math.Abs(clear[i].Y - clear[j].Y) > apart)
continue;
if (!NestLayoutCheck.Clears(insertGeometry, clear[i], insertGeometry, clear[j], spacing))
return Array.Empty<NestJobPlacement>();
}
return clear;
}
private static (double Dx, double Dy) Shift(double step, int steps, int i, int j) =>
steps == 0 ? (0, 0) : (step * i / (2.0 * steps), step * j / (2.0 * steps));
/// <summary>More copies win; ties keep the smaller offset, then the lower i, then j.</summary>
private static bool Better(int count, int i, int j, (int Count, int I, int J) best)
{
if (count != best.Count)
return count > best.Count;
var distance = System.Math.Abs(i) + System.Math.Abs(j);
var bestDistance = System.Math.Abs(best.I) + System.Math.Abs(best.J);
if (distance != bestDistance)
return distance < bestDistance;
return i != best.I ? i < best.I : j < best.J;
}
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
private static bool Inside(PathsD region, double x, double y)
{
var point = new PointD(x, y);
var inside = false;
foreach (var path in region)
{
var result = Clipper.PointInPolygon(point, path, Precision);
if (result == PointInPolygonResult.IsOn)
return false;
if (result == PointInPolygonResult.IsInside)
inside = !inside;
}
return inside;
}
private static PathD Positive(PathD path)
{
if (path.Count >= 3 && !Clipper.IsPositive(path))
path.Reverse();
return path;
}
}
@@ -28,6 +28,13 @@ internal class DefaultPlateFiller : PlateFillerBase
set => angleBuilder.ForceFullSweep = value;
}
/// <summary>
/// When true, a positive item quantity lets supporting strategies stop adding rows once a
/// candidate holds the quantity. Other strategies still fill the work area; the winner is
/// trimmed to the quantity. Off by default, so ordinary fills are unchanged.
/// </summary>
internal bool StopAtQuantity { get; init; }
public override List<double> BuildAngles(
NestItem item,
ClassificationResult classification,
@@ -338,7 +345,7 @@ internal class DefaultPlateFiller : PlateFillerBase
Token = token,
Progress = progress,
Policy = BuildPolicy(),
MaxQuantity = item.Quantity,
MaxQuantity = StopAtQuantity ? System.Math.Max(0, item.Quantity) : 0,
};
RunPipeline(context);
@@ -0,0 +1,45 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Placement;
/// <summary>
/// Runs the Default Fill for one private drawing on a private plate, for callers that turn the
/// result into job poses. The caller owns the drawing and must call
/// <see cref="BestFitCache.Invalidate"/> for it when finished.
/// </summary>
internal static class PrivatePlateFill
{
/// <param name="length">X extent of the plate and work area.</param>
/// <param name="width">Y extent of the plate and work area.</param>
/// <param name="quantity">Copies wanted; zero fills the whole area.</param>
internal static List<Part> Run(Drawing drawing, RotationPolicy rotation, double spacing,
double length, double width, int quantity, CancellationToken token)
{
var plate = new Plate(new Size(width, length)) { PartSpacing = spacing };
// The drawing is private: stabilize this cache entry before Fill's candidate pruning.
var fits = BestFitCache.GetOrCompute(drawing, plate.Size.Length, plate.Size.Width, spacing);
var sorted = fits.OrderBy(f => f.RotatedArea).ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation).ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray();
fits.Clear();
fits.AddRange(sorted);
// A positive quantity lets supporting strategies avoid surplus rows. Zero still
// fills the whole area (the cutout lattice caller requires an uncapped lattice).
var filler = new DefaultPlateFiller(plate) { StopAtQuantity = true };
return filler.Fill(new NestItem
{
Drawing = drawing,
Quantity = quantity,
RotationStart = rotation.Start,
RotationEnd = rotation.End,
StepAngle = DrawingJobMapper.LegacyStep(rotation),
}, new Box(0, 0, length, width), null!, token);
}
}
@@ -0,0 +1,142 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>Per-solve, per-spacing Fill proposals. Only the members occupy material.</summary>
internal sealed class BlockCatalog : IDisposable
{
private readonly double spacing;
private readonly Dictionary<int, Drawing> drawings = new();
private readonly Dictionary<int, List<Orientation>> orientations = new();
private readonly Dictionary<int, int> attempts = new();
internal int PreparationCount => attempts.Values.Sum();
private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList<Placed>> cache = new();
public BlockCatalog(double spacing, IReadOnlyList<PartType> types,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs)
{
this.spacing = spacing;
foreach (var type in types)
{
var poses = type.Orientations.ToList();
if (pairs.TryGetValue(type.Index, out var found))
poses.AddRange(found.SelectMany(p => new[] { p.A, p.B }));
orientations[type.Index] = poses.Distinct().ToList();
}
}
internal static IReadOnlyList<Box> Rectangles(Box work, IReadOnlyList<Placed> placed, double spacing)
{
var free = new PathsD { new PathD
{
new(work.Left, work.Bottom), new(work.Right, work.Bottom),
new(work.Right, work.Top), new(work.Left, work.Top),
} };
foreach (var part in placed)
{
// Physical occupied material, not a reference-point region for any moving part.
// The catalog currently prepares solid outlines; future profile preparation owns holes.
var blocked = Clipper.InflatePaths(new PathsD { part.Orientation.Outline },
spacing + part.Orientation.Tolerance + 0.001, JoinType.Miter, EndType.Polygon,
2, NoFitCache.Precision);
free = Clipper.Difference(free, Clipper.TranslatePaths(blocked, part.X, part.Y),
FillRule.NonZero, NoFitCache.Precision);
}
return MaximalRectangles.InRegion(free).OrderByDescending(b => b.Area())
.ThenBy(b => b.Left).ThenBy(b => b.Bottom).ThenBy(b => b.Length).Take(2).ToArray();
}
public IReadOnlyList<Placed> Get(PartType type, int quantity, Box rectangle, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (quantity <= 2 || type.Orientations.Count == 0 || rectangle.Length <= 0 || rectangle.Width <= 0)
return Array.Empty<Placed>();
var key = (type.Index, quantity, rectangle.Length, rectangle.Width);
if (cache.TryGetValue(key, out var cached))
return cached;
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8)
return Array.Empty<Placed>();
attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1;
var result = Build(type, quantity, rectangle, token);
cache[key] = result;
return result;
}
private IReadOnlyList<Placed> Build(PartType type, int quantity, Box rectangle, CancellationToken token)
{
if (!drawings.TryGetValue(type.Index, out var drawing))
drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part);
try
{
var members = PrivatePlateFill.Run(drawing, type.Part.Rotation, spacing,
rectangle.Length, rectangle.Width, quantity, token);
token.ThrowIfCancellationRequested();
return Resolve(type, members.Take(quantity).ToArray(), orientations[type.Index], spacing, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Array.Empty<Placed>();
}
}
public void Dispose()
{
foreach (var drawing in drawings.Values)
BestFitCache.Invalidate(drawing);
drawings.Clear();
}
internal static IReadOnlyList<Placed> Resolve(PartType type, IReadOnlyList<Part> members,
List<Orientation> orientations, double spacing, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (members.Count <= 2)
return Array.Empty<Placed>();
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
return Array.Empty<Placed>();
// Canonical rebinding is already performed by FillItem. Quantization removes sub-grid
// arithmetic differences from equivalent Fill proposals; certify the resulting poses.
var poses = members.Select(p => new NestJobPlacement(type.Part.Id, 0,
System.Math.Round(p.Location.X, 8), System.Math.Round(p.Location.Y, 8),
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.OrderBy(p => p.X).ThenBy(p => p.Y).ThenBy(p => p.Rotation).ToArray();
if (poses.Any(p => !double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Rotation)
|| !type.Part.Rotation.Allows(p.Rotation)))
return Array.Empty<Placed>();
for (var i = 0; i < poses.Length; i++)
for (var j = i + 1; j < poses.Length; j++)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing))
return Array.Empty<Placed>();
}
var result = new List<Placed>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
var orientation = orientations.FirstOrDefault(o => o.Rotation == pose.Rotation);
if (orientation == null)
{
orientation = PartCatalog.CreateOrientation(type, orientations.Max(o => o.Index) + 1, pose.Rotation);
if (orientation == null)
return Array.Empty<Placed>();
orientations.Add(orientation);
}
result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y));
}
return result;
}
}
@@ -55,17 +55,30 @@ internal sealed class FrontierPacker
private readonly IReadOnlyList<PartType> types;
private readonly NoFitCache nfps;
private readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs;
private readonly NestPlateStock stock;
private readonly PackAxis axis;
private readonly double beta;
private readonly Box work;
private readonly WorkCounter counter;
private readonly BlockCatalog? blocks;
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
public FrontierPacker(
IReadOnlyList<PartType> types,
NoFitCache nfps,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs,
NestPlateStock stock,
PackAxis axis,
double beta,
WorkCounter counter,
BlockCatalog? blocks = null
)
{
this.blocks = blocks;
this.counter = counter;
this.types = types;
this.nfps = nfps;
this.pairs = pairs;
this.stock = stock;
this.axis = axis;
this.beta = beta;
@@ -76,39 +89,102 @@ internal sealed class FrontierPacker
{
var left = remaining.ToArray();
var states = new List<Region>();
var byOrientation = new Dictionary<Orientation, Region>(ReferenceEqualityComparer.Instance);
bool Track(Orientation o, bool single)
{
if (byOrientation.ContainsKey(o))
return true;
if (!stock.Fits(o.Width, o.Height))
return false;
var region = new Region(o, work, single);
states.Add(region);
byOrientation[o] = region;
return true;
}
var offered = new List<PairState>();
foreach (var type in types)
{
if (left[type.Index] <= 0)
continue;
foreach (var o in type.Orientations)
if (stock.Fits(o.Width, o.Height))
states.Add(new Region(o, work));
Track(o, single: true);
if (left[type.Index] < 2 || !pairs.TryGetValue(type.Index, out var typePairs))
continue;
foreach (var pair in typePairs)
{
// Members missing from the catalog get regions too, but only for pair placement.
if (stock.Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
offered.Add(new PairState(pair, byOrientation[pair.A], byOrientation[pair.B]));
}
}
var placed = new List<Placed>();
var blockStates = new List<BlockState>();
var preparations = 0;
void PrepareBlocks()
{
if (blocks == null || preparations++ >= 2)
return;
var rectangles = BlockCatalog.Rectangles(work, placed, stock.PartSpacing);
foreach (var type in types.Where(t => left[t.Index] > 2)
.OrderByDescending(t => t.Area * left[t.Index]).ThenBy(t => t.Index).Take(4))
foreach (var rectangle in rectangles)
{
var members = blocks.Get(type, left[type.Index], rectangle, token);
if (members.Count <= 2)
continue;
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
if (!stock.Fits(width, height))
continue;
foreach (var member in members)
{
if (byOrientation.ContainsKey(member.Orientation))
continue;
if (!Track(member.Orientation, single: false))
break;
var region = byOrientation[member.Orientation];
foreach (var existing in placed)
region.Subtract(nfps.Get(existing.Orientation, member.Orientation), existing.X, existing.Y);
}
if (members.All(p => byOrientation.ContainsKey(p.Orientation)))
blockStates.Add(new BlockState(members, members.Select(p => byOrientation[p.Orientation]).ToArray()));
}
}
PrepareBlocks();
var partArea = 0.0;
var front = axis == PackAxis.X ? work.Left : work.Bottom;
while (states.Count > 0)
{
token.ThrowIfCancellationRequested();
var choice = Choose(states, front);
var choice = Choose(states, offered, blockStates, front);
if (choice == null)
break;
var (region, point) = choice.Value;
var part = new Placed(region.Orientation, point.x, point.y);
var typeIndex = choice[0].Orientation.TypeIndex;
foreach (var part in choice)
{
placed.Add(part);
var typeIndex = region.Orientation.TypeIndex;
partArea += types[typeIndex].Area;
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
}
if (--left[typeIndex] == 0)
left[typeIndex] -= choice.Count;
blockStates.RemoveAll(b => b.Members.Count > left[b.Members[0].Orientation.TypeIndex]);
if (left[typeIndex] == 0)
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
if (left[typeIndex] < 2)
{
offered.RemoveAll(p => p.Pose.TypeIndex == typeIndex);
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex && !s.Single);
}
// Each surviving region loses the positions the new part now blocks. Regions are
// Each surviving region loses the positions the new parts now block. Regions are
// independent, so they update in parallel without affecting determinism.
var snapshot = states.ToArray();
foreach (var part in choice)
{
counter.Add(snapshot.Length);
Parallel.For(
0,
@@ -116,34 +192,42 @@ internal sealed class FrontierPacker
new ParallelOptions { CancellationToken = token },
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
);
}
states.RemoveAll(s => s.IsEmpty);
offered.RemoveAll(p => p.A.IsEmpty || p.B.IsEmpty);
if (offered.Count == 0 && blocks == null)
states.RemoveAll(s => !s.Single);
blockStates.RemoveAll(b => b.Regions.Any(r => r.IsEmpty));
PrepareBlocks();
}
return new SheetFill(stock, placed, partArea);
}
private (Region, PointD)? Choose(List<Region> states, double front)
/// <summary>
/// The next placement: one part, or both members of a pair. Singles and pairs compete
/// under the same rule; a pair counts as one piece of twice the part area, and on a tie
/// the single (considered first) is kept.
/// </summary>
private IReadOnlyList<Placed>? Choose(List<Region> states, List<PairState> offered, List<BlockState> blocks, double front)
{
Region? bestRegion = null;
var bestPoint = default(PointD);
IReadOnlyList<Placed>? bestParts = null;
var bestFills = false;
var bestValue = double.PositiveInfinity;
var bestSide = double.PositiveInfinity;
var bestLead = double.PositiveInfinity;
var bestPriority = int.MaxValue;
foreach (var region in states)
void Consider(Func<IReadOnlyList<Placed>> build, int typeIndex, double area, double advance, double side, double lead)
{
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
continue;
var area = types[region.Orientation.TypeIndex].Area;
var priority = types[region.Orientation.TypeIndex].Part.Priority;
if (priority > bestPriority) continue;
var priority = types[typeIndex].Part.Priority;
if (priority > bestPriority)
return;
var fills = advance <= Tie;
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
var better = bestRegion == null
var better = bestParts == null
|| priority < bestPriority
|| (fills && !bestFills)
|| (
@@ -157,17 +241,83 @@ internal sealed class FrontierPacker
)
);
if (!better)
continue;
bestRegion = region;
return;
bestParts = build();
bestPriority = priority;
bestPoint = point;
bestFills = fills;
bestValue = value;
bestSide = side;
bestLead = lead;
}
return bestRegion == null ? null : (bestRegion, bestPoint);
foreach (var region in states)
{
if (!region.Single)
continue;
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
continue;
var o = region.Orientation;
Consider(() => new[] { new Placed(o, point.x, point.y) }, o.TypeIndex, types[o.TypeIndex].Area, advance, side, lead);
}
foreach (var state in offered)
{
var pair = state.Pose;
if (!state.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
Consider(
() => new[] { new Placed(pair.A, point.x, point.y), new Placed(pair.B, point.x + pair.Dx, point.y + pair.Dy) },
pair.TypeIndex,
2 * types[pair.TypeIndex].Area,
advance,
side,
lead
);
}
foreach (var block in blocks)
{
if (!block.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
var typeIndex = block.Members[0].Orientation.TypeIndex;
Consider(() => block.Members.Select(p => new Placed(p.Orientation, p.X + point.x, p.Y + point.y)).ToArray(),
typeIndex, block.Members.Count * types[typeIndex].Area, advance, side, lead);
}
return bestParts;
}
private sealed class BlockState(IReadOnlyList<Placed> members, Region[] regions)
{
public IReadOnlyList<Placed> Members { get; } = members;
public Region[] Regions { get; } = regions;
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
out PointD point, out double advance, out double side, out double lead)
{
var free = Clipper.TranslatePaths(Regions[0].Free, -Members[0].X, -Members[0].Y);
var minX = double.NegativeInfinity;
var minY = double.NegativeInfinity;
var maxX = double.PositiveInfinity;
var maxY = double.PositiveInfinity;
for (var i = 0; i < Members.Count; i++)
{
var member = Members[i];
var region = Regions[i];
minX = System.Math.Max(minX, region.MinX - member.X);
minY = System.Math.Max(minY, region.MinY - member.Y);
maxX = System.Math.Min(maxX, region.MaxX - member.X);
maxY = System.Math.Min(maxY, region.MaxY - member.Y);
if (i > 0)
{
counter.Add(1);
free = Clipper.Intersect(free, Clipper.TranslatePaths(region.Free, -member.X, -member.Y),
FillRule.NonZero, NoFitCache.Precision);
}
}
return BestVertex(free, minX, minY, System.Math.Max(minX, maxX), System.Math.Max(minY, maxY),
(Members.Min(p => p.Left), Members.Min(p => p.Bottom), Members.Max(p => p.Right), Members.Max(p => p.Top)),
axis, front, out point, out advance, out side, out lead);
}
}
/// <summary>Legal reference points for one orientation on this sheet.</summary>
@@ -177,9 +327,10 @@ internal sealed class FrontierPacker
private PathsD free;
private RectD bounds;
public Region(Orientation orientation, Box work)
public Region(Orientation orientation, Box work, bool single)
{
Orientation = orientation;
Single = single;
minX = work.Left - orientation.MinX;
maxX = work.Right - orientation.MaxX;
minY = work.Bottom - orientation.MinY;
@@ -203,6 +354,10 @@ internal sealed class FrontierPacker
}
public Orientation Orientation { get; }
/// <summary>False for a pair-only orientation: it is never placed on its own.</summary>
public bool Single { get; }
public bool IsEmpty => free.Count == 0;
public void Subtract(Nfp nfp, double dx, double dy)
@@ -219,18 +374,54 @@ internal sealed class FrontierPacker
// Drop numerical dust; a sliver thinner than the precision grid is no real room.
free.RemoveAll(p => p.Count < 3);
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
Version++;
}
/// <summary>Changes whenever the free region does.</summary>
public int Version { get; private set; }
public PathsD Free => free;
public RectD Bounds => bounds;
public double MinX => minX;
public double MinY => minY;
public double MaxX => maxX;
public double MaxY => maxY;
/// <summary>
/// Best vertex of the free region: least front advance, then lowest cross-axis position,
/// then lowest leading edge. Vertices suffice because every score is linear in position.
/// </summary>
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
{
var o = Orientation;
return BestVertex(free, minX, minY, maxX, maxY, (o.MinX, o.MinY, o.MaxX, o.MaxY),
axis, front, out point, out advance, out side, out lead);
}
}
/// <summary>
/// Scores the vertices of <paramref name="free"/>, each clamped to the inner-fit box
/// [minX, maxX] x [minY, maxY], for a piece occupying <paramref name="box"/> around its
/// reference point.
/// </summary>
private static bool BestVertex(
PathsD free,
double minX,
double minY,
double maxX,
double maxY,
(double MinX, double MinY, double MaxX, double MaxY) box,
PackAxis axis,
double front,
out PointD point,
out double advance,
out double side,
out double lead
)
{
point = default;
advance = side = lead = double.PositiveInfinity;
var found = false;
var o = Orientation;
foreach (var path in free)
foreach (var raw in path)
{
@@ -239,15 +430,15 @@ internal sealed class FrontierPacker
double reach, across, start;
if (axis == PackAxis.X)
{
reach = x + o.MaxX;
across = y + o.MinY;
start = x + o.MinX;
reach = x + box.MaxX;
across = y + box.MinY;
start = x + box.MinX;
}
else
{
reach = y + o.MaxY;
across = x + o.MinX;
start = y + o.MinY;
reach = y + box.MaxY;
across = x + box.MinX;
start = y + box.MinY;
}
var adv = System.Math.Max(0, reach - front);
var better = !found
@@ -263,5 +454,52 @@ internal sealed class FrontierPacker
}
return found;
}
/// <summary>
/// Legal reference points for a pair: member A's free region intersected with member B's
/// moved back by the pair offset. Recomputed only after either member's region changes.
/// </summary>
private sealed class PairState(PairPose pose, Region a, Region b)
{
private int versionA = -1, versionB = -1;
private PathsD free = new();
public PairPose Pose { get; } = pose;
public Region A { get; } = a;
public Region B { get; } = b;
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
out PointD point, out double advance, out double side, out double lead)
{
if (versionA != A.Version || versionB != B.Version)
{
versionA = A.Version;
versionB = B.Version;
counter.Add(1);
free = Intersect();
}
// Clamp into both members' inner-fit boxes; they overlap whenever the pair fits.
var minX = System.Math.Max(A.MinX, B.MinX - Pose.Dx);
var maxX = System.Math.Max(minX, System.Math.Min(A.MaxX, B.MaxX - Pose.Dx));
var minY = System.Math.Max(A.MinY, B.MinY - Pose.Dy);
var maxY = System.Math.Max(minY, System.Math.Min(A.MaxY, B.MaxY - Pose.Dy));
return BestVertex(free, minX, minY, maxX, maxY, (Pose.MinX, Pose.MinY, Pose.MaxX, Pose.MaxY),
axis, front, out point, out advance, out side, out lead);
}
private PathsD Intersect()
{
if (A.IsEmpty || B.IsEmpty)
return new PathsD();
var a = A.Bounds;
var b = B.Bounds;
if (b.right - Pose.Dx < a.left || b.left - Pose.Dx > a.right
|| b.bottom - Pose.Dy < a.top || b.top - Pose.Dy > a.bottom)
return new PathsD();
var shifted = Clipper.TranslatePaths(B.Free, -Pose.Dx, -Pose.Dy);
var result = Clipper.Intersect(A.Free, shifted, FillRule.NonZero, NoFitCache.Precision);
result.RemoveAll(p => p.Count < 3);
return result;
}
}
}
@@ -1,7 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System;
using System.Threading;
using OpenNest.Engine.Jobs;
@@ -48,9 +48,10 @@ public sealed class IrregularNestingEngine : INestingEngine
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var types = PartCatalog.Build(job);
var solver = new Solver(job, types, progress, token);
using var solver = new Solver(job, types, progress, token);
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
// Pair-only orientations may fit stock even when the sampled single poses do not.
// Demand that neither a single nor a pair can fit is reported unplaced.
var demand = new int[types.Count];
foreach (var type in types)
{
@@ -58,7 +59,8 @@ public sealed class IrregularNestingEngine : INestingEngine
stock.Quantity != 0
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
);
demand[type.Index] = placeable ? type.Part.Quantity : 0;
demand[type.Index] = placeable || solver.PairFits(type)
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
}
Plan? best = null;
@@ -85,10 +87,26 @@ public sealed class IrregularNestingEngine : INestingEngine
IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress,
CancellationToken token
)
) : IDisposable
{
public void Dispose()
{
foreach (var catalog in blocks.Values)
catalog.Dispose();
}
private const int MaxTail = 3;
private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private BlockCatalog BlocksFor(NestPlateStock stock)
{
var spacing = System.Math.Max(0, stock.PartSpacing);
if (!blocks.TryGetValue(spacing, out var found))
blocks[spacing] = found = new BlockCatalog(spacing, types, PairsFor(stock));
return found;
}
public WorkCounter Work { get; } = new();
@@ -157,6 +175,30 @@ public sealed class IrregularNestingEngine : INestingEngine
return cache;
}
public bool PairFits(PartType type)
{
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
return false;
return job.Plates.Any(stock => stock.Quantity != 0
&& PairsFor(stock).TryGetValue(type.Index, out var candidates)
&& candidates.Any(pair => stock.Fits(pair.Width, pair.Height)));
}
/// <summary>Best-fit pairs for this stock's spacing, built once per solve.</summary>
private IReadOnlyDictionary<int, IReadOnlyList<PairPose>> PairsFor(NestPlateStock stock)
{
var clearance = System.Math.Max(0, stock.PartSpacing);
if (!pairs.TryGetValue(clearance, out var found))
{
// The best-fit plate filter only needs the largest sheet at this spacing;
// each packer still checks the pair against its own work area.
var sheets = job.Plates.Where(p => System.Math.Max(0, p.PartSpacing) == clearance).ToList();
pairs[clearance] = found = PairCatalog.Build(types, clearance,
sheets.Max(p => p.Size.Length), sheets.Max(p => p.Size.Width), token);
}
return found;
}
/// <summary>
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
@@ -199,7 +241,7 @@ public sealed class IrregularNestingEngine : INestingEngine
if (stock.Quantity is int available && used[stock.Id] >= available)
continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0)
trials.Add((fill, NetArea(job.Options, fill)));
@@ -0,0 +1,207 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// Two copies of one part type placed together: member A at the pair's reference point and
/// member B at <see cref="Dx"/>, <see cref="Dy"/> from it.
/// </summary>
internal sealed class PairPose
{
public required Orientation A { get; init; }
public required Orientation B { get; init; }
public required double Dx { get; init; }
public required double Dy { get; init; }
public int TypeIndex => A.TypeIndex;
/// <summary>Box of both members around the pair's reference point (catalog-padded).</summary>
public double MinX => System.Math.Min(A.MinX, Dx + B.MinX);
public double MinY => System.Math.Min(A.MinY, Dy + B.MinY);
public double MaxX => System.Math.Max(A.MaxX, Dx + B.MaxX);
public double MaxY => System.Math.Max(A.MaxY, Dy + B.MaxY);
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
}
/// <summary>
/// Best-fit pairs offered to the packer for part types with two or more copies.
///
/// A pair is placed through the existing free regions: its reference point must be legal for
/// member A and, shifted by the pair offset, for member B (region A intersected with region B
/// moved back by the offset). No new no-fit polygons are built. The members' clearance from
/// each other comes from the best-fit search and is re-checked here once per pair with the
/// layout check at the job spacing; a pair that fails is never offered.
/// </summary>
internal static class PairCatalog
{
/// <summary>Best-fit results tried per type, in rank order.</summary>
private const int CandidatesPerType = 12;
/// <summary>Pairs kept per type; each one costs a region intersection per placement.</summary>
private const int PairsPerType = 2;
private const double AngleTolerance = 1e-6;
/// <summary>
/// Pairs for every type with quantity two or more, for one part spacing. A member rotation
/// missing from the catalog gets a pair-only orientation (indexed after the catalog's), so
/// both members always own cached footprints; such orientations are never offered as singles.
/// </summary>
/// <param name="sheetLength">Largest sheet X extent offered at this spacing.</param>
/// <param name="sheetWidth">Largest sheet Y extent offered at this spacing.</param>
public static IReadOnlyDictionary<int, IReadOnlyList<PairPose>> Build(
IReadOnlyList<PartType> types,
double spacing,
double sheetLength,
double sheetWidth,
CancellationToken token
)
{
var result = new Dictionary<int, IReadOnlyList<PairPose>>();
foreach (var type in types)
{
token.ThrowIfCancellationRequested();
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
continue;
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
continue;
var pairs = BuildForType(type, geometry, spacing, sheetLength, sheetWidth, token);
if (pairs.Count > 0)
result[type.Index] = pairs;
}
return result;
}
private static List<PairPose> BuildForType(
PartType type,
JobPartGeometry geometry,
double spacing,
double sheetLength,
double sheetWidth,
CancellationToken token
)
{
var drawing = new Drawing(type.Part.Id, DrawingJobMapper.ToProgram(type.Part.Geometry));
List<BestFitResult> fits;
try
{
fits = BestFitCache.GetOrCompute(drawing, sheetLength, sheetWidth, spacing);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return new List<PairPose>();
}
finally
{
// The drawing exists only for this call; release its cache entry now.
BestFitCache.Invalidate(drawing);
}
// Best-fit evaluates in parallel, so equal-area results arrive in any order. Sort on
// the candidate itself as well, so the same job always picks the same pairs.
var ranked = fits
.Where(f => f.Keep)
.OrderBy(f => f.RotatedArea)
.ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation)
.ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y)
.ThenBy(f => f.OptimalRotation)
.Take(CandidatesPerType);
var pairs = new List<PairPose>();
var extra = new List<Orientation>();
foreach (var fit in ranked)
{
token.ThrowIfCancellationRequested();
List<Part> members;
try
{
members = fit.BuildSourceParts(drawing);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
continue;
}
if (members.Count != 2)
continue;
// The whole pair may also turn a quarter: offer it along each sheet axis.
foreach (var turn in new[] { 0.0, System.Math.PI / 2 })
{
var pair = Resolve(type, extra, geometry, members, turn, spacing);
if (pair != null && !pairs.Any(p => SamePair(p, pair)))
pairs.Add(pair);
if (pairs.Count >= PairsPerType)
return pairs;
}
}
return pairs;
}
internal static PairPose? Resolve(
PartType type,
List<Orientation> extra,
JobPartGeometry geometry,
List<Part> members,
double turn,
double spacing
)
{
// Turning a part about the origin turns its program and its location together,
// so the members keep their relative placement.
var poses = members
.Select(member =>
{
var location = member.Location.Rotate(turn);
return (Rotation: Angle.NormalizeRad(member.Rotation + turn), location.X, location.Y);
})
.ToArray();
if (!type.Part.Rotation.Allows(poses[0].Rotation) || !type.Part.Rotation.Allows(poses[1].Rotation))
return null;
var a = new NestJobPlacement(type.Part.Id, 0, poses[0].X, poses[0].Y, poses[0].Rotation);
var b = new NestJobPlacement(type.Part.Id, 1, poses[1].X, poses[1].Y, poses[1].Rotation);
if (!NestLayoutCheck.Clears(geometry, a, geometry, b, spacing))
return null;
var oa = Find(type, extra, poses[0].Rotation);
var ob = Find(type, extra, poses[1].Rotation);
if (oa == null || ob == null)
return null;
return new PairPose { A = oa, B = ob, Dx = poses[1].X - poses[0].X, Dy = poses[1].Y - poses[0].Y };
}
private static Orientation? Find(PartType type, List<Orientation> extra, double rotation)
{
foreach (var orientation in type.Orientations.Concat(extra))
if (SameAngle(orientation.Rotation, rotation))
return orientation;
var added = PartCatalog.CreateOrientation(type, type.Orientations.Count + extra.Count, rotation);
if (added != null)
extra.Add(added);
return added;
}
private static bool SamePair(PairPose first, PairPose second) =>
ReferenceEquals(first.A, second.A)
&& ReferenceEquals(first.B, second.B)
&& System.Math.Abs(first.Dx - second.Dx) < 1e-6
&& System.Math.Abs(first.Dy - second.Dy) < 1e-6;
private static bool SameAngle(double first, double second)
{
var difference = Angle.NormalizeRad(first - second);
return difference < AngleTolerance || Angle.TwoPI - difference < AngleTolerance;
}
}
@@ -25,7 +25,11 @@ internal sealed class Orientation
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
public required double Tolerance { get; init; }
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary>
/// <summary>
/// Outline bounds grown by the outline's actual flattening error, so they contain the true
/// perimeter: by <see cref="Tolerance"/> when arcs were flattened, not at all for line-only
/// outlines, whose vertices are exact. Footprints and NFPs still use <see cref="Tolerance"/>.
/// </summary>
public required double MinX { get; init; }
public required double MinY { get; init; }
public required double MaxX { get; init; }
@@ -41,6 +45,9 @@ internal sealed class PartType
public required NestJobPart Part { get; init; }
public required double Area { get; init; }
public required IReadOnlyList<Orientation> Orientations { get; init; }
/// <summary>Analytic perimeter the orientations were flattened from; null when unreadable.</summary>
public Shape? Perimeter { get; init; }
}
/// <summary>
@@ -93,15 +100,36 @@ internal static class PartCatalog
var outline = Polygonize(perimeter, angle, tolerance);
if (outline.Count < 3)
continue;
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance, perimeter));
}
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
types.Add(new PartType
{
Index = index,
Part = part,
Area = area,
Orientations = orientations,
Perimeter = perimeter,
});
}
return types;
}
/// <summary>
/// An extra pose of <paramref name="type"/> at <paramref name="angle"/>, flattened like the
/// catalog's own. <paramref name="index"/> must be unique within the type, because NFP
/// caches key on it.
/// </summary>
internal static Orientation? CreateOrientation(PartType type, int index, double angle)
{
if (type.Perimeter == null || type.Orientations.Count == 0)
return null;
var tolerance = type.Orientations[0].Tolerance;
var outline = Polygonize(type.Perimeter, angle, tolerance);
return outline.Count < 3 ? null : MakeOrientation(type.Index, index, angle, outline, tolerance, type.Perimeter);
}
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
JobPartGeometry.TryRead(geometry)?.Perimeter;
@@ -139,9 +167,21 @@ internal static class PartCatalog
return path;
}
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance)
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline,
double tolerance, Shape perimeter)
{
var bounds = Clipper.GetBounds(outline);
// Curves retain flattening-error padding. For lines, use analytic endpoint bounds:
// polygon cleanup can discard short-edge chains and shrink the material's true extent.
var padding = tolerance;
if (perimeter.Entities.All(e => e.Type == EntityType.Line))
{
var nominal = (Shape)perimeter.Clone();
nominal.Rotate(angle);
var box = nominal.BoundingBox;
bounds = new RectD(box.Left, box.Bottom, box.Right, box.Top);
padding = 0;
}
return new Orientation
{
TypeIndex = typeIndex,
@@ -149,10 +189,10 @@ internal static class PartCatalog
Rotation = angle,
Outline = outline,
Tolerance = tolerance,
MinX = bounds.left - tolerance,
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
MaxX = bounds.right + tolerance,
MaxY = bounds.bottom + tolerance,
MinX = bounds.left - padding,
MinY = bounds.top - padding, // Clipper RectD: top is the minimum Y.
MaxX = bounds.right + padding,
MaxY = bounds.bottom + padding,
};
}
+3 -24
View File
@@ -1,8 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Engine.CuttingPlanning;
namespace OpenNest.Engine.Sequencing
{
@@ -60,7 +59,8 @@ namespace OpenNest.Engine.Sequencing
// An orphaned cutoff still must not follow potentially
// crossed parts when its nominal span cannot be recovered.
if (!definitions.TryGetValue(cut.Part.BaseDrawing, out var definition)
|| CrossesBounds(definition, part.BoundingBox, bounds))
|| CuttingDependencyGraph.CutOffCrosses(definition.Axis, definition.Position,
definition.StartLimit, definition.EndLimit, part.BoundingBox, bounds))
{
result.Add(cut.Part);
emitted[cut.Index] = true;
@@ -77,26 +77,5 @@ namespace OpenNest.Engine.Sequencing
return result;
}
private static bool CrossesBounds(CutOff cutOff, Box part, Box plate)
{
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var position = vertical ? cutOff.Position.X : cutOff.Position.Y;
var acrossMin = vertical ? part.Left : part.Bottom;
var acrossMax = vertical ? part.Right : part.Top;
var alongMin = vertical ? part.Bottom : part.Left;
var alongMax = vertical ? part.Top : part.Right;
var start = cutOff.StartLimit ?? (vertical ? plate.Bottom : plate.Left);
var end = cutOff.EndLimit ?? (vertical ? plate.Top : plate.Right);
// Use the nominal line, not its trimmed cutting segments (which
// deliberately skip the parts). Bounds conservatively include edge
// contacts and concave recesses; limits prevent unrelated dependencies
// beyond the cutoff's span. Negative coordinates need no special case.
return !(position < acrossMin - Tolerance.Epsilon
|| position > acrossMax + Tolerance.Epsilon
|| System.Math.Max(start, end) < alongMin - Tolerance.Epsilon
|| System.Math.Min(start, end) > alongMax + Tolerance.Epsilon);
}
}
}
@@ -17,6 +17,10 @@ namespace OpenNest.Engine.Strategies
public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
/// <summary>
/// When positive, strategies may stop placing parts once a candidate holds this
/// many (see <see cref="FillLinear.MaxParts"/>). Zero fills the whole work area.
/// </summary>
public int MaxQuantity { get; init; }
public PartType PartType { get; set; }
+13 -8
View File
@@ -1,5 +1,4 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
using OpenNest.Engine.Fill;
@@ -40,24 +39,27 @@ namespace OpenNest.Engine.Strategies
IFillComparer comparer = null
)
{
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
// Slots in angle order, V before H within an angle: ties keep the earliest slot
// however the workers finish.
var results = new (List<Part> Parts, FillScore Score)[angles.Count * 2];
Parallel.ForEach(
angles,
angle =>
Parallel.For(
0,
angles.Count,
i =>
{
var pattern = BuildRotatedPattern(groupParts, angle);
var pattern = BuildRotatedPattern(groupParts, angles[i]);
if (pattern.Parts.Count == 0)
return;
var h = engine.Fill(pattern, NestDirection.Horizontal);
if (h != null && h.Count > 0)
results.Add((h, comparer == null ? FillScore.Compute(h, workArea) : default));
results[2 * i + 1] = (h, comparer == null ? FillScore.Compute(h, workArea) : default);
var v = engine.Fill(pattern, NestDirection.Vertical);
if (v != null && v.Count > 0)
results.Add((v, comparer == null ? FillScore.Compute(v, workArea) : default));
results[2 * i] = (v, comparer == null ? FillScore.Compute(v, workArea) : default);
}
);
@@ -66,6 +68,9 @@ namespace OpenNest.Engine.Strategies
foreach (var res in results)
{
if (res.Parts == null)
continue;
if (comparer != null)
{
if (best == null || comparer.IsBetter(res.Parts, best, workArea))
+4 -29
View File
@@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace OpenNest.IO.Bom
@@ -31,23 +29,8 @@ namespace OpenNest.IO.Bom
{
var result = new BomAnalysis();
// Build a case-insensitive lookup of DXF files in the folder (if it exists)
var folderExists = Directory.Exists(dxfFolder);
var dxfFiles = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (folderExists)
{
foreach (var file in Directory.GetFiles(dxfFolder, "*.dxf"))
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles[nameWithoutExt] = file;
}
foreach (var file in Directory.GetFiles(dxfFolder, "*.dwg"))
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles.TryAdd(nameWithoutExt, file);
}
}
// Case-insensitive lookup of the drawing files in the folder (if it exists)
var drawings = DrawingFileIndex.Load(dxfFolder);
// Partition items into: skipped, unmatched, or matched (grouped)
var matched = new List<MatchedPart>();
@@ -60,20 +43,12 @@ namespace OpenNest.IO.Bom
continue;
}
var lookupName = item.FileName;
if (
lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (!folderExists)
if (!drawings.FolderExists)
{
// No folder to search — group items without a DXF path
matched.Add(new MatchedPart { Item = item, DxfPath = null });
}
else if (dxfFiles.TryGetValue(lookupName, out var dxfPath))
else if (drawings.Find(item.FileName) is { } dxfPath)
{
matched.Add(new MatchedPart { Item = item, DxfPath = dxfPath });
}
+61
View File
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.IO.Bom;
/// <summary>
/// The rows of one material and thickness; each group becomes one nest.
/// </summary>
public sealed class BomImportGroup
{
public BomImportGroup(string material, double thickness, IReadOnlyList<BomPartRow> parts)
{
Material = material;
Thickness = thickness;
Parts = parts;
}
/// <summary>The material as the group's first row spells it.</summary>
public string Material { get; }
public double Thickness { get; }
public IReadOnlyList<BomPartRow> Parts { get; }
/// <summary>Sum of the rows' quantities.</summary>
public int TotalQty => Parts.Sum(p => p.Qty ?? 0);
public string Key => BomImportGroups.Key(Material, Thickness);
}
/// <summary>
/// Groups importable rows by material and thickness. The Groups tab and
/// Create Nests both use this, so they always agree.
/// </summary>
public static class BomImportGroups
{
/// <summary>
/// Returns the groups of <see cref="BomRowStatus.Ready"/> rows. Material
/// is compared case-insensitively. Groups are ordered by material, then
/// thickness.
/// </summary>
public static List<BomImportGroup> Build(IEnumerable<BomPartRow> rows)
{
return rows.Where(p => p.Status == BomRowStatus.Ready)
.GroupBy(p => new
{
Material = p.Material.ToUpperInvariant(),
Thickness = p.Thickness.Value,
})
.OrderBy(g => g.First().Material)
.ThenBy(g => g.Key.Thickness)
.Select(g => new BomImportGroup(g.First().Material, g.Key.Thickness, g.ToList()))
.ToList();
}
/// <summary>
/// The key that identifies a group's plate settings across regrouping.
/// </summary>
public static string Key(string material, double thickness) =>
$"{material?.ToUpperInvariant()}|{thickness}";
}
+44
View File
@@ -0,0 +1,44 @@
using System.Collections.Generic;
namespace OpenNest.IO.Bom;
/// <summary>
/// Builds the import dialog's part rows from the BOM items and the drawing
/// folder.
/// </summary>
public static class BomImportRows
{
/// <summary>
/// Returns one row per BOM item, in BOM order. Every row with a file
/// name gets the drawing it matches in <paramref name="dxfFolder"/>,
/// whether or not its material and thickness are filled in. A blank
/// BOM quantity becomes 1, marked as assumed.
/// </summary>
public static List<BomPartRow> Build(List<BomItem> items, string dxfFolder)
{
var index = DrawingFileIndex.Load(dxfFolder);
var rows = new List<BomPartRow>();
foreach (var item in items)
{
rows.Add(
new BomPartRow
{
ItemNum = item.ItemNum,
FileName = item.FileName,
BomQty = item.Qty,
Qty = item.Qty ?? 1,
QtyAssumed = item.Qty == null,
Description = item.Description,
Material = item.Material,
Thickness = item.Thickness,
DxfPath = string.IsNullOrWhiteSpace(item.FileName)
? null
: index.Find(item.FileName),
}
);
}
return rows;
}
}
+39
View File
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.IO.Bom;
/// <summary>The import dialog's one-line summary of the part rows.</summary>
public static class BomImportSummary
{
/// <summary>
/// For example "12 ready, 2 need a thickness, 1 no drawing found, 3 had
/// no BOM quantity (1 used)".
/// </summary>
public static string Describe(IEnumerable<BomPartRow> rows)
{
var list = rows.ToList();
var counts = list.GroupBy(r => r.Status).ToDictionary(g => g.Key, g => g.Count());
int Count(BomRowStatus status) => counts.TryGetValue(status, out var n) ? n : 0;
var parts = new List<string> { $"{Count(BomRowStatus.Ready)} ready" };
Add(parts, Count(BomRowStatus.NeedsMaterial), "needs a material", "need a material");
Add(parts, Count(BomRowStatus.NeedsThickness), "needs a thickness", "need a thickness");
Add(parts, Count(BomRowStatus.NeedsQuantity), "needs a quantity", "need a quantity");
Add(parts, Count(BomRowStatus.NoDrawing), "no drawing found", "no drawing found");
Add(parts, Count(BomRowStatus.NoFileName), "no file name", "no file name");
Add(
parts,
list.Count(r => r.IsEditable && r.QtyAssumed),
"had no BOM quantity (1 used)",
"had no BOM quantity (1 used)"
);
return string.Join(", ", parts);
}
private static void Add(List<string> parts, int count, string one, string many)
{
if (count > 0)
parts.Add($"{count} {(count == 1 ? one : many)}");
}
}
+119
View File
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.IO.Bom;
/// <summary>Plate size and spacing for one group's nest.</summary>
public sealed class BomGroupPlateSettings
{
public double PlateWidth { get; set; }
public double PlateLength { get; set; }
public double PartSpacing { get; set; }
public double EdgeLeft { get; set; }
public double EdgeBottom { get; set; }
public double EdgeRight { get; set; }
public double EdgeTop { get; set; }
}
/// <summary>
/// The nest built for one group, or none when no drawing imported, plus a
/// message for every drawing that could not be imported.
/// </summary>
public sealed class BomNestBuildResult
{
public BomNestBuildResult(Nest nest, IReadOnlyList<string> errors)
{
Nest = nest;
Errors = errors;
}
/// <summary>The new nest, or null when none of the group's drawings imported.</summary>
public Nest Nest { get; }
public IReadOnlyList<string> Errors { get; }
}
/// <summary>Builds the nest for one BOM import group.</summary>
public static class BomNestBuilder
{
/// <summary>
/// Creates a nest named "<paramref name="jobName"/> - thickness material".
/// <paramref name="applySavedDefaults"/> runs first (units, quadrant,
/// plate), then the group's plate size and spacing, material and
/// thickness are set. Each drawing file is imported once, needing the
/// total quantity of the rows that name it (a blank quantity counts as
/// 1). The nest gets one plate when at least one drawing imported.
/// </summary>
public static BomNestBuildResult Build(
BomImportGroup group,
BomGroupPlateSettings plate,
string jobName,
Action<Nest> applySavedDefaults
)
{
ArgumentNullException.ThrowIfNull(group);
ArgumentNullException.ThrowIfNull(plate);
var material = group.Material;
var thickness = group.Thickness;
var errors = new List<string>();
var nest = new Nest($"{jobName} - {thickness:0.###} {material}");
nest.DateCreated = DateTime.Now;
nest.DateLastModified = DateTime.Now;
// Saved defaults first (units, quadrant, plate), as New does;
// then the group's own plate size and spacing.
applySavedDefaults?.Invoke(nest);
nest.PlateDefaults.Size = new Size(plate.PlateWidth, plate.PlateLength);
nest.Thickness = thickness;
nest.Material = new Material(material);
nest.PlateDefaults.PartSpacing = plate.PartSpacing;
nest.PlateDefaults.EdgeSpacing = new Spacing(
plate.EdgeLeft,
plate.EdgeBottom,
plate.EdgeRight,
plate.EdgeTop
);
// Rows naming the same drawing file become one drawing that needs
// their total: the nest's drawing set is keyed by drawing name.
foreach (var rows in group.Parts.GroupBy(p => p.DxfPath, StringComparer.OrdinalIgnoreCase))
{
var part = rows.First();
if (!File.Exists(part.DxfPath))
{
errors.Add($"{part.FileName}: DXF file not found");
continue;
}
try
{
var drawing = CadImporter.ImportDrawing(
part.DxfPath,
new CadImportOptions { Quantity = rows.Sum(p => p.Qty ?? 1) }
);
drawing.Material = new Material(material);
nest.Drawings.Add(drawing);
}
catch (Exception ex)
{
errors.Add($"{part.FileName}: {ex.Message}");
}
}
if (nest.Drawings.Count == 0)
return new BomNestBuildResult(null, errors);
nest.CreatePlate();
return new BomNestBuildResult(nest, errors);
}
}
+150
View File
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace OpenNest.IO.Bom;
/// <summary>Whether a BOM row can be imported, and if not, why.</summary>
public enum BomRowStatus
{
/// <summary>Drawing found and every value set: the row is imported.</summary>
Ready,
/// <summary>Drawing found but the material is blank.</summary>
NeedsMaterial,
/// <summary>Drawing found but the thickness is blank, zero or negative.</summary>
NeedsThickness,
/// <summary>Drawing found but the quantity is below 1.</summary>
NeedsQuantity,
/// <summary>No drawing file matches the row's file name.</summary>
NoDrawing,
/// <summary>The BOM row has no file name.</summary>
NoFileName,
}
/// <summary>
/// One BOM line as the import dialog shows and edits it: the values read
/// from the BOM, the drawing file it resolved to and whether it can be
/// imported. Changing Material, Thickness or Qty raises
/// <see cref="PropertyChanged"/> for that value and for the status.
/// </summary>
public class BomPartRow : INotifyPropertyChanged
{
private string material;
private double? thickness;
private int? qty;
public event PropertyChangedEventHandler PropertyChanged;
public int? ItemNum { get; set; }
public string FileName { get; set; }
/// <summary>The quantity the BOM gave, unchanged by edits; null when blank.</summary>
public int? BomQty { get; set; }
/// <summary>
/// The quantity to import. Set it only to a value accepted by
/// <see cref="BomQuantity.TryParse"/>, or use <see cref="TrySetQuantity"/>.
/// Setting it clears <see cref="QtyAssumed"/>.
/// </summary>
public int? Qty
{
get => qty;
set
{
QtyAssumed = false;
Set(ref qty, value);
}
}
/// <summary>True while Qty is the 1 used for a blank BOM quantity.</summary>
public bool QtyAssumed { get; set; }
public string Description { get; set; }
public string Material
{
get => material;
set => Set(ref material, value);
}
public double? Thickness
{
get => thickness;
set => Set(ref thickness, value);
}
/// <summary>The matched drawing file, or null when none was found.</summary>
public string DxfPath { get; set; }
public BomRowStatus Status
{
get
{
if (string.IsNullOrWhiteSpace(FileName))
return BomRowStatus.NoFileName;
if (string.IsNullOrWhiteSpace(DxfPath))
return BomRowStatus.NoDrawing;
if (string.IsNullOrWhiteSpace(Material))
return BomRowStatus.NeedsMaterial;
if (Thickness is not double t || !double.IsFinite(t) || t <= 0)
return BomRowStatus.NeedsThickness;
if (Qty is not int q || q < 1)
return BomRowStatus.NeedsQuantity;
return BomRowStatus.Ready;
}
}
public string StatusText => Describe(Status);
/// <summary>
/// True when the row has a drawing, so the operator can complete its
/// values; rows without one can never be imported.
/// </summary>
public bool IsEditable => Status is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing);
/// <summary>
/// Sets Qty from typed text when <see cref="BomQuantity.TryParse"/>
/// accepts it; otherwise leaves the row unchanged and returns false.
/// </summary>
public bool TrySetQuantity(string text)
{
if (!BomQuantity.TryParse(text, out var value))
return false;
Qty = value;
return true;
}
public static string Describe(BomRowStatus status) =>
status switch
{
BomRowStatus.Ready => "Ready",
BomRowStatus.NeedsMaterial => "Needs material",
BomRowStatus.NeedsThickness => "Needs thickness",
BomRowStatus.NeedsQuantity => "Needs quantity",
BomRowStatus.NoDrawing => "No drawing found",
BomRowStatus.NoFileName => "No file name",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
private void Set<T>(ref T field, T value, [CallerMemberName] string name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
return;
field = value;
OnPropertyChanged(name);
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(StatusText));
}
private void OnPropertyChanged(string name) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
+29
View File
@@ -0,0 +1,29 @@
using System.Globalization;
namespace OpenNest.IO.Bom;
/// <summary>Reads a part quantity the operator typed in the import dialog.</summary>
public static class BomQuantity
{
/// <summary>
/// Accepts a whole number of at least 1, with optional surrounding
/// spaces. Rejects blanks, signs, decimals, exponents, group
/// separators and values above <see cref="int.MaxValue"/>.
/// </summary>
public static bool TryParse(string text, out int quantity)
{
if (
int.TryParse(
text,
NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite,
CultureInfo.CurrentCulture,
out quantity
)
&& quantity >= 1
)
return true;
quantity = 0;
return false;
}
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace OpenNest.IO.Bom;
/// <summary>
/// The .dxf and .dwg files in one folder, looked up by the name a BOM uses.
/// A .dxf wins over a .dwg with the same name.
/// </summary>
internal sealed class DrawingFileIndex
{
private readonly Dictionary<string, string> files = new(StringComparer.OrdinalIgnoreCase);
private DrawingFileIndex(string folder)
{
FolderExists = Directory.Exists(folder);
if (!FolderExists)
return;
foreach (var file in Directory.GetFiles(folder, "*.dxf"))
files[Path.GetFileNameWithoutExtension(file)] = file;
foreach (var file in Directory.GetFiles(folder, "*.dwg"))
files.TryAdd(Path.GetFileNameWithoutExtension(file), file);
}
public bool FolderExists { get; }
public static DrawingFileIndex Load(string folder) => new(folder);
/// <summary>
/// The drawing file for a BOM file name, ignoring case and a .dxf or
/// .dwg extension; null when there is none.
/// </summary>
public string Find(string bomFileName) =>
files.TryGetValue(LookupName(bomFileName), out var path) ? path : null;
/// <summary>The BOM file name without a .dxf or .dwg extension.</summary>
public static string LookupName(string fileName)
{
fileName ??= "";
if (
fileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
return Path.GetFileNameWithoutExtension(fileName);
return fileName;
}
}
+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 .
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# Release builds pass --build-arg VERSION=X.Y.Z and SOURCE_REVISION=<full commit SHA>;
# the defaults mark an unversioned development build. Base images may be pinned by digest.
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:8.0
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:8.0
FROM ${SDK_IMAGE} AS build
WORKDIR /src
COPY OpenNest.Server/OpenNest.Server.csproj OpenNest.Server/
COPY OpenNest.Data/OpenNest.Data.csproj OpenNest.Data/
COPY OpenNest.Core/OpenNest.Core.csproj OpenNest.Core/
RUN dotnet restore OpenNest.Server/OpenNest.Server.csproj
COPY OpenNest.Server/ OpenNest.Server/
COPY OpenNest.Data/ OpenNest.Data/
RUN dotnet publish OpenNest.Server/OpenNest.Server.csproj -c Release -o /app --no-restore
COPY OpenNest.Core/ OpenNest.Core/
ARG VERSION=0.0.0-dev
ARG SOURCE_REVISION=unknown
RUN dotnet publish OpenNest.Server/OpenNest.Server.csproj -c Release -o /app --no-restore \
-p:Version="$VERSION" -p:SourceRevisionId="$SOURCE_REVISION"
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
FROM ${RUNTIME_IMAGE} AS runtime
# curl is installed only for the HEALTHCHECK below.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Runtime files stay root-owned and read-only to the app user.
COPY --from=build /app .
# SQLite database + .nest blobs live here; mount a volume at this path.
VOLUME /app/data
# SQLite database + .nest blobs live here; mount a volume at this path. The directory
# belongs to the base image's non-root app user (APP_UID 1654), so a fresh named volume
# inherits that ownership. Existing root-owned data needs a one-time scoped chown.
RUN mkdir -p /app/data && chown "$APP_UID:$APP_UID" /app/data && chmod 0750 /app/data
ENV OPENNEST_DB=/app/data/nests.db
# Listen on the explicit 8090 URL; clear the base image's 8080 port default.
ENV ASPNETCORE_URLS=http://+:8090
ENV ASPNETCORE_HTTP_PORTS=
EXPOSE 8090
USER $APP_UID
VOLUME /app/data
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --start-interval=2s --retries=3 \
CMD ["curl", "--fail", "--silent", "--show-error", "http://127.0.0.1:8090/healthz"]
ARG VERSION=0.0.0-dev
ARG SOURCE_REVISION=unknown
ARG RUNTIME_IMAGE
LABEL org.opencontainers.image.title="OpenNest.Server" \
org.opencontainers.image.description="OpenNest nest-storage HTTP service (SQLite)" \
org.opencontainers.image.source="https://github.com/ajisaacs/OpenNest" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.version="$VERSION" \
org.opencontainers.image.revision="$SOURCE_REVISION" \
org.opencontainers.image.base.name="$RUNTIME_IMAGE"
ENTRYPOINT ["dotnet", "OpenNest.Server.dll"]
+124 -1
View File
@@ -11,6 +11,9 @@ namespace OpenNest.Server;
/// </summary>
public sealed class NestDatabase : IDisposable
{
// One reentrant monitor owns each complete connection operation, including
// readers, nested Get readbacks, health checks, and disposal.
private readonly object _sync = new();
private readonly SqliteConnection _connection;
public NestDatabase(string databasePath)
@@ -45,6 +48,8 @@ public sealed class NestDatabase : IDisposable
}
public IReadOnlyList<NestRecord> List()
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText =
@@ -55,8 +60,11 @@ public sealed class NestDatabase : IDisposable
records.Add(ReadRecord(reader));
return records;
}
}
public NestRecord? Get(Guid id)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = $"SELECT {RecordColumns} FROM nests WHERE id = $id";
@@ -64,8 +72,50 @@ public sealed class NestDatabase : IDisposable
using var reader = command.ExecuteReader();
return reader.Read() ? ReadRecord(reader) : null;
}
}
/// <summary>
/// One bounded page of matching metadata plus the total match count. The count and
/// the page are read in the same monitor hold, so they describe one state.
/// </summary>
public NestPage Query(NestQuery query)
{
ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError();
if (error is not null)
throw new ArgumentException(error, nameof(query));
var search = query.NormalizedSearch;
var where = search.Length == 0 ? "" : SearchPredicate;
lock (_sync)
{
using var count = _connection.CreateCommand();
count.CommandText = $"SELECT COUNT(*) FROM nests {where}";
AddSearchParameter(count, search);
var total = checked((int)(long)count.ExecuteScalar()!);
using var command = _connection.CreateCommand();
var direction = query.Descending ? "DESC" : "ASC";
command.CommandText = $"""
SELECT {RecordColumns} FROM nests {where}
ORDER BY {SortColumn(query.Sort)} {direction}, id {direction}
LIMIT $limit OFFSET $offset
""";
AddSearchParameter(command, search);
command.Parameters.AddWithValue("$limit", query.Limit);
command.Parameters.AddWithValue("$offset", query.Offset);
var records = new List<NestRecord>();
using var reader = command.ExecuteReader();
while (reader.Read())
records.Add(ReadRecord(reader));
return new NestPage { Items = records, Total = total, Offset = query.Offset, Limit = query.Limit };
}
}
public byte[]? GetFile(Guid id)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = "SELECT file FROM nests WHERE id = $id";
@@ -73,8 +123,11 @@ public sealed class NestDatabase : IDisposable
var value = command.ExecuteScalar();
return value is byte[] bytes ? bytes : null;
}
}
public NestRecord Insert(Guid id, NestRecord record, byte[] file)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = """
@@ -89,8 +142,11 @@ public sealed class NestDatabase : IDisposable
command.ExecuteNonQuery();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
}
}
public NestRecord? Update(Guid id, NestRecord record, byte[]? file)
{
lock (_sync)
{
if (Get(id) is null)
return null;
@@ -110,16 +166,42 @@ public sealed class NestDatabase : IDisposable
command.ExecuteNonQuery();
return Get(id);
}
}
public bool Delete(Guid id)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = "DELETE FROM nests WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString());
return command.ExecuteNonQuery() > 0;
}
}
public void Dispose() => _connection.Dispose();
/// <summary>Checks the live connection without exposing storage error details.</summary>
public bool IsHealthy()
{
lock (_sync)
{
try
{
using var command = _connection.CreateCommand();
command.CommandText = "SELECT 1";
return command.ExecuteScalar() is long value && value == 1;
}
catch (Exception ex) when (ex is SqliteException or InvalidOperationException)
{
return false;
}
}
}
public void Dispose()
{
lock (_sync)
_connection.Dispose();
}
private void Execute(string sql)
{
@@ -133,6 +215,47 @@ public sealed class NestDatabase : IDisposable
status, plateCount, partCount, comments, madeBy, fileSize, savedAt
""";
// Case-insensitive (ASCII) substring over the text columns and the status's stored
// and display names. The search text is always a bound, escaped LIKE parameter.
private const string SearchPredicate = """
WHERE name LIKE $pattern ESCAPE '\'
OR customer LIKE $pattern ESCAPE '\'
OR material LIKE $pattern ESCAPE '\'
OR madeBy LIKE $pattern ESCAPE '\'
OR comments LIKE $pattern ESCAPE '\'
OR status LIKE $pattern ESCAPE '\'
OR (CASE status WHEN 'ToBeCut' THEN 'To Be Cut' WHEN 'HasBeenCut' THEN 'Has Been Cut'
ELSE status END) LIKE $pattern ESCAPE '\'
""";
// Fixed allowlist: request text never reaches the ORDER BY clause.
private static string SortColumn(NestSortField field) => field switch
{
NestSortField.SavedAt => "savedAt",
NestSortField.Name => "name COLLATE NOCASE",
NestSortField.Customer => "customer COLLATE NOCASE",
NestSortField.Status => "status COLLATE NOCASE",
NestSortField.Material => "material COLLATE NOCASE",
NestSortField.DateCreated => "dateCreated",
NestSortField.DateModified => "dateModified",
NestSortField.Thickness => "thickness",
NestSortField.PlateCount => "plateCount",
NestSortField.PartCount => "partCount",
NestSortField.MadeBy => "madeBy COLLATE NOCASE",
NestSortField.Comments => "comments COLLATE NOCASE",
NestSortField.FileSize => "fileSize",
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."),
};
private static void AddSearchParameter(SqliteCommand command, string search)
{
if (search.Length == 0)
return;
var escaped = search.Replace(@"\", @"\\").Replace("%", @"\%").Replace("_", @"\_");
command.Parameters.AddWithValue("$pattern", "%" + escaped + "%");
}
private static void AddRecordParameters(
SqliteCommand command, Guid id, NestRecord record, byte[]? file, bool updateFile)
{
+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>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.31" />
</ItemGroup>
<ItemGroup>
+29 -3
View File
@@ -25,15 +25,27 @@ var databasePath =
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
?? Environment.GetEnvironmentVariable("OPENNEST_DB")
?? Path.Combine("data", "nests.db");
builder.Services.AddSingleton(new NestDatabase(databasePath));
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
var app = builder.Build();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
// Open the database now so an unusable data path fails startup, not the first request.
app.Services.GetRequiredService<NestDatabase>();
app.MapGet("/healthz", (NestDatabase db) => db.IsHealthy()
? Results.Ok(new { status = "ok" })
: Results.Json(new { status = "unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable));
// Full enumeration (backup manifests, restore checks and the container smoke).
app.MapGet("/api/nests", (NestDatabase db) => Results.Ok(db.List()));
// Bounded, SQL-filtered page for browsing.
app.MapGet("/api/nests/query", (HttpRequest request, NestDatabase db) =>
NestQueryRequest.TryParse(request.Query, out var query, out var error)
? Results.Ok(db.Query(query))
: Results.BadRequest(error));
app.MapGet("/api/nests/{id:guid}", (Guid id, NestDatabase db) =>
db.Get(id) is { } record ? Results.Ok(record) : Results.NotFound());
@@ -91,7 +103,18 @@ static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultip
if (!http.Request.HasFormContentType)
return (null, null, Results.BadRequest("Expected multipart/form-data with metadata and file parts."));
var form = await http.Request.ReadFormAsync();
IFormCollection form;
try
{
form = await http.Request.ReadFormAsync();
}
catch (Exception ex) when (ex is InvalidDataException || (ex is IOException && ex is not BadHttpRequestException))
{
// A truncated or malformed multipart body is a client error. BadHttpRequestException
// (for example, an oversized body) keeps the status code Kestrel assigns it.
return (null, null, Results.BadRequest("Malformed multipart body."));
}
var metadataPart = form["metadata"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(metadataPart))
return (null, null, Results.BadRequest("Missing 'metadata' part."));
@@ -127,3 +150,6 @@ internal static class MultipartJson
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
}
// Entry-point type for in-memory integration tests (WebApplicationFactory<Program>).
public partial class Program { }
+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
+83
View File
@@ -0,0 +1,83 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
/// <summary>
/// BOM items to rows to an operator edit to groups to the created nest,
/// the same path the import dialog takes.
/// </summary>
public sealed class BomImportFlowTests : IDisposable
{
private readonly string folder = Path.Combine(
Path.GetTempPath(),
"bom-flow-" + Guid.NewGuid().ToString("N")
);
public BomImportFlowTests()
{
Directory.CreateDirectory(folder);
WriteSquare("PT01");
WriteSquare("PT02");
}
public void Dispose()
{
if (Directory.Exists(folder))
Directory.Delete(folder, recursive: true);
}
[Fact]
public void EditedQuantity_ReachesTheGroupTotalAndTheNestDrawing()
{
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", 2), Item("PT02", null) }, folder);
Assert.True(rows[0].TrySetQuantity("5"));
Assert.False(rows[1].TrySetQuantity("0"));
var group = Assert.Single(BomImportGroups.Build(rows));
Assert.Equal(6, group.TotalQty);
var nest = Assert.IsType<Nest>(BomNestBuilder.Build(group, Plate(), "Job", null).Nest);
Assert.Equal(
new[] { ("PT01", 5), ("PT02", 1) },
nest.Drawings.OrderBy(d => d.Name).Select(d => (d.Name, d.Quantity.Required))
);
}
[Fact]
public void BomQuantityBelowOne_KeepsThePartOutUntilTheOperatorFixesIt()
{
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", 0), Item("PT02", 3) }, folder);
Assert.Equal(new[] { "PT02" }, Assert.Single(BomImportGroups.Build(rows)).Parts.Select(p => p.FileName));
Assert.True(rows[0].TrySetQuantity("4"));
var group = Assert.Single(BomImportGroups.Build(rows));
Assert.Equal(7, group.TotalQty);
}
private static BomItem Item(string fileName, int? qty) =>
new()
{
FileName = fileName,
Qty = qty,
Material = "Stainless",
Thickness = 0.25,
};
private static BomGroupPlateSettings Plate() => new() { PlateWidth = 60, PlateLength = 120 };
private void WriteSquare(string name)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), Path.Combine(folder, name + ".dxf"));
}
}
@@ -0,0 +1,72 @@
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
public class BomImportGroupsTests
{
[Fact]
public void Build_GroupsByMaterialIgnoringCaseThenThickness()
{
var rows = new List<BomPartRow>
{
Row("PT01", "Stainless", 0.25, qty: 2),
Row("PT02", "Aluminum", 0.125, qty: 1),
Row("PT03", "STAINLESS", 0.25, qty: 3),
Row("PT04", "Stainless", 0.125, qty: 4),
Row("PT05", "Aluminum", 0.0625, qty: 3),
};
var groups = BomImportGroups.Build(rows);
Assert.Equal(
new[] { "Aluminum 0.0625", "Aluminum 0.125", "Stainless 0.125", "Stainless 0.25" },
groups.Select(g => $"{g.Material} {g.Thickness}")
);
var stainless = groups[3];
Assert.Equal(new[] { "PT01", "PT03" }, stainless.Parts.Select(p => p.FileName));
Assert.Equal(5, stainless.TotalQty);
Assert.Equal(BomImportGroups.Key("stainless", 0.25), stainless.Key);
}
[Fact]
public void Build_LeavesOutRowsThatCannotBeImported()
{
var noFileName = Row("PT02", "Stainless", 0.25);
noFileName.FileName = " ";
var noDrawing = Row("PT05", "Stainless", 0.25);
noDrawing.DxfPath = null;
var rows = new List<BomPartRow>
{
Row("PT01", "Stainless", 0.25),
noFileName,
Row("PT03", " ", 0.25),
Row("PT04", "Stainless", null),
noDrawing,
Row("PT06", "Stainless", 0),
Row("PT07", "Stainless", 0.25, qty: 0),
Row("PT08", "Stainless", 0.25, qty: null),
};
var groups = BomImportGroups.Build(rows);
var group = Assert.Single(groups);
Assert.Equal("PT01", Assert.Single(group.Parts).FileName);
}
[Fact]
public void Key_IgnoresMaterialCase()
{
Assert.Equal(BomImportGroups.Key("Stainless", 0.25), BomImportGroups.Key("STAINLESS", 0.25));
Assert.NotEqual(BomImportGroups.Key("Stainless", 0.25), BomImportGroups.Key("Stainless", 0.125));
}
private static BomPartRow Row(string fileName, string material, double? thickness, int? qty = 1) =>
new()
{
FileName = fileName,
Material = material,
Thickness = thickness,
Qty = qty,
DxfPath = $"/drawings/{fileName}.dxf",
};
}
+163
View File
@@ -0,0 +1,163 @@
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
public sealed class BomImportRowsTests : IDisposable
{
private readonly string folder = Path.Combine(
Path.GetTempPath(),
"bom-rows-" + Guid.NewGuid().ToString("N")
);
public BomImportRowsTests()
{
Directory.CreateDirectory(folder);
}
public void Dispose()
{
if (Directory.Exists(folder))
Directory.Delete(folder, recursive: true);
}
[Fact]
public void Build_MatchesRowsToDrawingFilesInBomOrder()
{
Touch("PT01.dxf");
Touch("PT03.dwg");
var items = new List<BomItem>
{
Item("PT01", qty: 2, itemNum: 1, description: "Gusset"),
Item("PT02", qty: 3, itemNum: 2),
Item(null, qty: 4, itemNum: 3),
Item("PT03", qty: null, itemNum: 4),
};
var rows = BomImportRows.Build(items, folder);
Assert.Equal(new[] { 1, 2, 3, 4 }, rows.Select(r => r.ItemNum ?? 0));
Assert.Equal(
new[]
{
BomRowStatus.Ready,
BomRowStatus.NoDrawing,
BomRowStatus.NoFileName,
BomRowStatus.Ready,
},
rows.Select(r => r.Status)
);
Assert.Equal(new[] { true, false, false, true }, rows.Select(r => r.IsEditable));
Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath);
Assert.Null(rows[1].DxfPath);
Assert.Null(rows[2].DxfPath);
Assert.Equal(Path.Combine(folder, "PT03.dwg"), rows[3].DxfPath);
Assert.Equal("Gusset", rows[0].Description);
Assert.Equal(2, rows[0].Qty);
Assert.Equal(2, rows[0].BomQty);
Assert.False(rows[0].QtyAssumed);
Assert.Equal(1, rows[3].Qty);
Assert.Null(rows[3].BomQty);
Assert.True(rows[3].QtyAssumed);
Assert.Equal("Stainless", rows[0].Material);
Assert.Equal(0.25, rows[0].Thickness);
}
[Fact]
public void Build_MatchesFileNamesIgnoringCase()
{
Touch("PT01.dxf");
var rows = BomImportRows.Build(new List<BomItem> { Item("pt01") }, folder);
Assert.Equal(BomRowStatus.Ready, rows[0].Status);
Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath);
}
[Fact]
public void Build_MatchesFileNamesThatIncludeTheExtension()
{
Touch("PT01.dxf");
Touch("PT02.dwg");
var rows = BomImportRows.Build(
new List<BomItem> { Item("PT01.dxf"), Item("PT02.DWG") },
folder
);
Assert.All(rows, r => Assert.True(r.IsEditable, r.FileName));
Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath);
Assert.Equal(Path.Combine(folder, "PT02.dwg"), rows[1].DxfPath);
}
[Fact]
public void Build_RowWithoutThickness_StillFindsItsDrawing()
{
Touch("PT01.dxf");
var item = Item("PT01");
item.Thickness = null;
var rows = BomImportRows.Build(new List<BomItem> { item }, folder);
Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath);
Assert.True(rows[0].IsEditable);
Assert.Equal(BomRowStatus.NeedsThickness, rows[0].Status);
}
[Fact]
public void Build_RowWithoutMaterial_NeedsAMaterial()
{
Touch("PT01.dxf");
var item = Item("PT01");
item.Material = " ";
var rows = BomImportRows.Build(new List<BomItem> { item }, folder);
Assert.True(rows[0].IsEditable);
Assert.Equal(BomRowStatus.NeedsMaterial, rows[0].Status);
}
[Theory]
[InlineData(0)]
[InlineData(-3)]
public void Build_RowWithBomQuantityBelowOne_NeedsAQuantity(int bomQty)
{
Touch("PT01.dxf");
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01", qty: bomQty) }, folder);
Assert.Equal(bomQty, rows[0].Qty);
Assert.False(rows[0].QtyAssumed);
Assert.Equal(BomRowStatus.NeedsQuantity, rows[0].Status);
}
[Fact]
public void Build_WithMissingFolder_FindsNoDrawings()
{
var missing = Path.Combine(folder, "missing");
var rows = BomImportRows.Build(new List<BomItem> { Item("PT01") }, missing);
Assert.Equal(BomRowStatus.NoDrawing, rows[0].Status);
Assert.False(rows[0].IsEditable);
Assert.Null(rows[0].DxfPath);
}
private void Touch(string name) => File.WriteAllText(Path.Combine(folder, name), "");
private static BomItem Item(
string? fileName,
int? qty = 1,
int? itemNum = null,
string? description = null
) =>
new()
{
ItemNum = itemNum,
FileName = fileName,
Qty = qty,
Description = description,
Material = "Stainless",
Thickness = 0.25,
};
}
@@ -0,0 +1,64 @@
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
public class BomImportSummaryTests
{
[Fact]
public void Describe_CountsReadyRowsAndEachProblem()
{
var rows = new List<BomPartRow>
{
Row("PT01", "Steel", 0.25),
Row("PT02", "Steel", 0.25),
Row("PT03", null, 0.25),
Row("PT04", "Steel", null),
Row("PT05", "Steel", null),
Row("PT06", "Steel", 0.25, dxfPath: null),
Row(null, "Steel", 0.25),
Row("PT08", "Steel", 0.25, qty: 0),
};
Assert.Equal(
"2 ready, 1 needs a material, 2 need a thickness, 1 needs a quantity, 1 no drawing found, 1 no file name",
BomImportSummary.Describe(rows)
);
}
[Fact]
public void Describe_CountsImportableRowsWhoseQuantityWasAssumed()
{
var assumed = Row("PT01", "Steel", 0.25);
assumed.QtyAssumed = true;
var noDrawing = Row("PT02", "Steel", 0.25, dxfPath: null);
noDrawing.QtyAssumed = true;
Assert.Equal(
"1 ready, 1 no drawing found, 1 had no BOM quantity (1 used)",
BomImportSummary.Describe(new[] { assumed, noDrawing })
);
}
[Fact]
public void Describe_WithEveryRowReady_ListsOnlyTheReadyCount()
{
Assert.Equal("1 ready", BomImportSummary.Describe(new[] { Row("PT01", "Steel", 0.25) }));
Assert.Equal("0 ready", BomImportSummary.Describe(Array.Empty<BomPartRow>()));
}
private static BomPartRow Row(
string? fileName,
string? material,
double? thickness,
string? dxfPath = "/d/part.dxf",
int qty = 1
) =>
new()
{
FileName = fileName,
Material = material,
Thickness = thickness,
DxfPath = dxfPath,
Qty = qty,
};
}
+144
View File
@@ -0,0 +1,144 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
public sealed class BomNestBuilderTests : IDisposable
{
private readonly string folder = Path.Combine(
Path.GetTempPath(),
"bom-nests-" + Guid.NewGuid().ToString("N")
);
public BomNestBuilderTests()
{
Directory.CreateDirectory(folder);
}
public void Dispose()
{
if (Directory.Exists(folder))
Directory.Delete(folder, recursive: true);
}
[Fact]
public void Build_AppliesSavedDefaultsThenGroupSettings()
{
var group = Group(Row("PT01", qty: 5), Row("PT02", qty: null));
var plate = Plate();
var result = BomNestBuilder.Build(
group,
plate,
"Job 42",
nest =>
{
nest.Units = Units.Millimeters;
nest.PlateDefaults.Quadrant = 3;
nest.PlateDefaults.Size = new Size(1, 1);
nest.PlateDefaults.PartSpacing = 9;
}
);
Assert.Empty(result.Errors);
var nest = Assert.IsType<Nest>(result.Nest);
Assert.Equal("Job 42 - 0.25 Stainless", nest.Name);
Assert.Equal(Units.Millimeters, nest.Units);
Assert.Equal(3, nest.PlateDefaults.Quadrant);
Assert.Equal(60, nest.PlateDefaults.Size.Width);
Assert.Equal(120, nest.PlateDefaults.Size.Length);
Assert.Equal(0.2, nest.PlateDefaults.PartSpacing);
Assert.Equal(new[] { 0.1, 0.2, 0.3, 0.4 }, Edges(nest.PlateDefaults.EdgeSpacing));
Assert.Equal(0.25, nest.Thickness);
Assert.Equal("Stainless", nest.Material.Name);
Assert.Single(nest.Plates);
var drawings = nest.Drawings.OrderBy(d => d.Name).ToList();
Assert.Equal(new[] { "PT01", "PT02" }, drawings.Select(d => d.Name));
Assert.Equal(new[] { 5, 1 }, drawings.Select(d => d.Quantity.Required));
Assert.All(drawings, d => Assert.Equal("Stainless", d.Material.Name));
}
[Fact]
public void Build_CombinesRowsThatUseTheSameDrawing()
{
var first = Row("PT01", qty: 2);
var second = Row("PT01", qty: 3);
second.FileName = "pt01.dxf";
var result = BomNestBuilder.Build(Group(first, second, Row("PT02", qty: 1)), Plate(), "Job", null);
Assert.Empty(result.Errors);
var nest = Assert.IsType<Nest>(result.Nest);
var drawings = nest.Drawings.OrderBy(d => d.Name).ToList();
Assert.Equal(new[] { "PT01", "PT02" }, drawings.Select(d => d.Name));
Assert.Equal(new[] { 5, 1 }, drawings.Select(d => d.Quantity.Required));
}
[Fact]
public void Build_ReportsMissingDrawingAndKeepsTheOthers()
{
var missing = Row("PT09");
File.Delete(missing.DxfPath);
var result = BomNestBuilder.Build(Group(Row("PT01"), missing), Plate(), "Job", null);
Assert.Equal("PT09: DXF file not found", Assert.Single(result.Errors));
var nest = Assert.IsType<Nest>(result.Nest);
Assert.Equal("PT01", Assert.Single(nest.Drawings).Name);
}
[Fact]
public void Build_WithNoImportedDrawing_ReturnsNoNest()
{
var unreadable = Row("PT01");
File.WriteAllText(unreadable.DxfPath, "not a drawing");
var result = BomNestBuilder.Build(Group(unreadable), Plate(), "Job", null);
Assert.Null(result.Nest);
Assert.StartsWith("PT01: ", Assert.Single(result.Errors));
}
private BomPartRow Row(string name, int? qty = 1) =>
new()
{
FileName = name,
Qty = qty,
Material = "Stainless",
Thickness = 0.25,
DxfPath = WriteSquare(name),
};
private static BomImportGroup Group(params BomPartRow[] rows) => new("Stainless", 0.25, rows);
private static BomGroupPlateSettings Plate() =>
new()
{
PlateWidth = 60,
PlateLength = 120,
PartSpacing = 0.2,
EdgeLeft = 0.1,
EdgeBottom = 0.2,
EdgeRight = 0.3,
EdgeTop = 0.4,
};
private static double[] Edges(Spacing spacing) =>
new[] { spacing.Left, spacing.Bottom, spacing.Right, spacing.Top };
private string WriteSquare(string name)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
var path = Path.Combine(folder, name + ".dxf");
Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), path);
return path;
}
}
+153
View File
@@ -0,0 +1,153 @@
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
public class BomPartRowTests
{
[Theory]
[InlineData(null, "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.NoFileName)]
[InlineData(" ", "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.NoFileName)]
[InlineData("PT01", null, "Steel", 0.25, BomRowStatus.NoDrawing)]
[InlineData("PT01", "/d/PT01.dxf", null, 0.25, BomRowStatus.NeedsMaterial)]
[InlineData("PT01", "/d/PT01.dxf", "\t", 0.25, BomRowStatus.NeedsMaterial)]
[InlineData("PT01", "/d/PT01.dxf", "Steel", null, BomRowStatus.NeedsThickness)]
[InlineData("PT01", "/d/PT01.dxf", "Steel", 0.0, BomRowStatus.NeedsThickness)]
[InlineData("PT01", "/d/PT01.dxf", "Steel", -0.25, BomRowStatus.NeedsThickness)]
[InlineData("PT01", "/d/PT01.dxf", "Steel", double.NaN, BomRowStatus.NeedsThickness)]
[InlineData("PT01", "/d/PT01.dxf", "Steel", double.PositiveInfinity, BomRowStatus.NeedsThickness)]
[InlineData("PT01", "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.Ready)]
public void Status_FollowsTheFirstMissingValue(
string? fileName,
string? dxfPath,
string? material,
double? thickness,
BomRowStatus expected
)
{
var row = new BomPartRow
{
FileName = fileName,
DxfPath = dxfPath,
Material = material,
Thickness = thickness,
Qty = 1,
};
Assert.Equal(expected, row.Status);
Assert.Equal(expected is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing), row.IsEditable);
}
[Theory]
[InlineData(null)]
[InlineData(0)]
[InlineData(-2)]
public void Status_NeedsAQuantityOfAtLeastOne(int? qty)
{
var row = ReadyRow();
row.Qty = qty;
Assert.Equal(BomRowStatus.NeedsQuantity, row.Status);
Assert.True(row.IsEditable);
}
[Fact]
public void Status_ChangesWhenTheOperatorFillsInAValue()
{
var row = new BomPartRow { FileName = "PT01", DxfPath = "/d/PT01.dxf" };
Assert.Equal(BomRowStatus.NeedsMaterial, row.Status);
row.Material = "Steel";
Assert.Equal(BomRowStatus.NeedsThickness, row.Status);
row.Thickness = 0.25;
Assert.Equal(BomRowStatus.NeedsQuantity, row.Status);
Assert.True(row.TrySetQuantity("3"));
Assert.Equal(BomRowStatus.Ready, row.Status);
Assert.Equal("Ready", row.StatusText);
}
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("2.5")]
[InlineData("")]
[InlineData("abc")]
public void TrySetQuantity_RefusesInvalidTextAndKeepsTheQuantity(string text)
{
var row = ReadyRow();
row.Qty = 4;
row.QtyAssumed = true;
var changes = Changes(row);
Assert.False(row.TrySetQuantity(text));
Assert.Equal(4, row.Qty);
Assert.True(row.QtyAssumed);
Assert.Empty(changes);
}
[Fact]
public void TrySetQuantity_SetsTheQuantityAndReportsTheChange()
{
var row = ReadyRow();
row.QtyAssumed = true;
var changes = Changes(row);
Assert.True(row.TrySetQuantity(" 12 "));
Assert.Equal(12, row.Qty);
Assert.False(row.QtyAssumed);
Assert.Equal(new[] { "Qty", "Status", "StatusText" }, changes);
}
[Fact]
public void EditingMaterialOrThickness_ReportsTheValueAndTheStatus()
{
var row = ReadyRow();
var changes = Changes(row);
row.Material = "Aluminum";
row.Thickness = 0.125;
row.Thickness = 0.125;
Assert.Equal(
new[] { "Material", "Status", "StatusText", "Thickness", "Status", "StatusText" },
changes
);
}
[Fact]
public void Describe_NamesEveryStatus()
{
Assert.Equal(
new[]
{
"Ready",
"Needs material",
"Needs thickness",
"Needs quantity",
"No drawing found",
"No file name",
},
Enum.GetValues<BomRowStatus>().Select(BomPartRow.Describe)
);
}
private static BomPartRow ReadyRow() =>
new()
{
FileName = "PT01",
DxfPath = "/d/PT01.dxf",
Material = "Steel",
Thickness = 0.25,
Qty = 1,
};
private static List<string?> Changes(BomPartRow row)
{
var names = new List<string?>();
row.PropertyChanged += (_, e) => names.Add(e.PropertyName);
return names;
}
}
+35
View File
@@ -0,0 +1,35 @@
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
public class BomQuantityTests
{
[Theory]
[InlineData("1", 1)]
[InlineData("5", 5)]
[InlineData(" 7 ", 7)]
[InlineData("2147483647", int.MaxValue)]
public void TryParse_AcceptsWholeNumbersOfAtLeastOne(string text, int expected)
{
Assert.True(BomQuantity.TryParse(text, out var quantity));
Assert.Equal(expected, quantity);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("0")]
[InlineData("-1")]
[InlineData("+3")]
[InlineData("2.5")]
[InlineData("1e3")]
[InlineData("1,000")]
[InlineData("abc")]
[InlineData("2147483648")]
public void TryParse_RejectsEverythingElse(string? text)
{
Assert.False(BomQuantity.TryParse(text!, out var quantity));
Assert.Equal(0, quantity);
}
}
@@ -0,0 +1,107 @@
using System.Reflection;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingCaptureFidelityTests
{
public static IEnumerable<object[]> UnsupportedOriginals()
{
foreach (var kind in new[] { "linear", "program", "mode" })
foreach (var nested in new[] { false, true })
foreach (var target in new[] { "eligible", "locked", "ineligible", "placed", "compatibility" })
yield return new object[] { kind, nested, target };
}
[Theory]
[MemberData(nameof(UnsupportedOriginals))]
public void Service_RefusesOriginalUnsupportedSemanticsBeforeClone(string kind, bool nested, string target)
{
var original = Rectangle();
if (kind == "linear")
original.Codes[1] = new CustomLinear { EndPoint = ((Motion)original.Codes[1]).EndPoint };
if (kind == "program")
{
var custom = new CustomProgram();
custom.Codes.AddRange(original.Codes);
original = custom;
}
var invalid = original;
if (nested)
{
var root = new Program();
root.Codes.Add(new SubProgramCall(original, 0) { Id = -3 });
original = root;
}
// Part construction uses legacy Clone, so original subclass information is lost.
var part = new Part(new Drawing("original", original));
if (kind == "linear")
{
var placed = nested ? ((SubProgramCall)part.Program.Codes[0]).Program : part.Program;
Assert.IsType<LinearMove>(placed.Codes[1]);
}
if (target == "placed")
{
part = new Part(new Drawing("supported", Rectangle()));
Assert.True(part.RestoreLeadInProgram(original, false));
}
if (kind == "mode")
typeof(Program).GetField("mode", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(invalid, (Mode)100);
part.LeadInsLocked = target == "locked";
var clean = part.BaseDrawing.Program;
var placedProgram = part.Program;
var codes = original.Codes.ToArray();
var invalidCodes = invalid.Codes.ToArray();
var bounds = part.BoundingBox;
var quantity = part.BaseDrawing.Quantity.Nested;
var cloneCalls = invalid.Codes.OfType<CustomLinear>().Sum(c => c.CloneCalls);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part],
confirmedParameters: target == "compatibility" ? null : ExplicitContourTests.Parameters(),
eligibleParts: target == "ineligible" ? [] : null));
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
Assert.Contains(result.Findings, f => ReferenceEquals(f.SourcePart, part) && f.SourceOrdinal == 0);
Assert.Same(clean, part.BaseDrawing.Program);
Assert.Same(placedProgram, part.Program);
Assert.Equal(codes, original.Codes);
Assert.Equal(invalidCodes, invalid.Codes);
Assert.Same(bounds, part.BoundingBox);
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
Assert.Equal(cloneCalls, invalid.Codes.OfType<CustomLinear>().Sum(c => c.CloneCalls));
}
[Theory]
[InlineData("linear")]
[InlineData("program")]
[InlineData("mode")]
public void Service_RefusesUnsupportedInactiveRegisteredGraphBeforeClone(string kind)
{
var part = new Part(new Drawing("registered", Rectangle()));
Program inactive = kind == "program" ? new CustomProgram() : new Program();
if (kind == "linear") inactive.Codes.Add(new CustomLinear());
if (kind == "mode")
typeof(Program).GetField("mode", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(inactive, (Mode)100);
part.Program.SubPrograms[-27] = inactive;
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.Same(inactive, part.Program.SubPrograms[-27]);
Assert.All(inactive.Codes.OfType<CustomLinear>(), c => Assert.Equal(0, c.CloneCalls));
}
private static Program Rectangle() => LeadPathValidationTests.Rectangle(0, 0, 10, 10);
private sealed class CustomProgram : Program { }
private sealed class CustomLinear : LinearMove
{
public int CloneCalls { get; private set; }
public override ICode Clone()
{
CloneCalls++;
return base.Clone();
}
}
}
@@ -0,0 +1,94 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingCaptureRotationTests
{
[Theory]
[InlineData(0.3, false)]
[InlineData(-0.3, false)]
[InlineData(0, false)]
[InlineData(0.3, true)]
public void SharedDiamond_CaptureAndReadyPreserveOnceRotatedCleanGeometry(double angle, bool incrementalRoot)
{
var leaf = Rectangle(1, 2);
leaf.Mode = Mode.Incremental;
var left = new Program();
left.Codes.Add(new SubProgramCall(leaf, 0));
left.Mode = Mode.Incremental;
var right = new Program();
right.Codes.Add(new SubProgramCall(leaf, 0));
right.Mode = Mode.Incremental;
var clean = Rectangle(10, 10);
clean.Codes.Add(new SubProgramCall(left, 0) { Offset = new Vector(3, 3) });
clean.Codes.Add(new SubProgramCall(right, 0) { Offset = new Vector(7, 3) });
if (incrementalRoot) clean.Mode = Mode.Incremental;
var part = new Part(new Drawing("shared diamond", clean));
part.Rotate(angle);
part.Location = new Vector(11, 13);
var before = ExplicitContourTests.Fingerprint(clean);
var beforeLeaf = ExplicitContourTests.Fingerprint(leaf);
var beforePlaced = ExplicitContourTests.Fingerprint(part.Program);
var bounds = part.BoundingBox;
var quantity = part.BaseDrawing.Quantity.Nested;
var settings = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.1 },
InternalLeadIn = new LineLeadIn { Length = 0.1 },
ExternalLeadOut = new NoLeadOut(),
InternalLeadOut = new NoLeadOut(),
PierceClearance = 0.01
};
var original = ExecutionMotionReader.ReadSupported(part.Program, part.Location, null);
var expectedCuts = original.Motions.Where(IsCut).ToArray();
var once = new Vector(3, 5).Rotate(angle) + part.Location;
Assert.Contains(expectedCuts, m => m.End.DistanceTo(once) < 1e-8);
// Retain the review's independent per-parent clone/once-rotation control.
var legacy = (Program)clean.Clone();
legacy.Rotate(angle);
var legacyCuts = ExecutionMotionReader.ReadSupported(legacy, part.Location, null).Motions.Where(IsCut).ToArray();
Assert.Equal(expectedCuts.Length, legacyCuts.Length);
Assert.All(Enumerable.Range(0, expectedCuts.Length), i =>
Assert.True(expectedCuts[i].End.DistanceTo(legacyCuts[i].End) < 1e-8));
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], new Vector(9, 13), confirmedParameters: settings));
var captured = Assert.Single(snapshot.Placements);
var nominal = captured.Material.Rings.SelectMany(r => r).ToArray();
Assert.Equal(expectedCuts.Length, nominal.Length);
Assert.All(Enumerable.Range(0, nominal.Length), i =>
Assert.True(nominal[i].End.DistanceTo(expectedCuts[i].End) < 1e-8,
$"Nominal curve {i} moved from {expectedCuts[i].End} to {nominal[i].End}."));
var ready = CuttingPlanService.Plan(snapshot);
Assert.True(ready.Status == CuttingPlanStatus.Ready, string.Join("; ", ready.Findings.Select(f => f.Message)));
Assert.True(ready.IndependentlyReplayed);
var emitted = ExecutionMotionReader.ReadSupported(Assert.Single(ready.ProposedOrder).CopyProgram(), part.Location, null)
.Motions.Where(IsCut).ToArray();
Assert.Equal(expectedCuts.Sum(m => m.Length), emitted.Sum(m => m.Length), 8);
Assert.All(emitted, move => Assert.Contains(expectedCuts, expected => expected.Curve.SameSupport(move.Curve)
&& expected.Curve.Contains(move.Curve.Start) && expected.Curve.Contains(move.End)));
Assert.Equal(before, ExplicitContourTests.Fingerprint(clean));
Assert.Equal(beforeLeaf, ExplicitContourTests.Fingerprint(leaf));
Assert.Equal(beforePlaced, ExplicitContourTests.Fingerprint(part.Program));
Assert.Same(leaf, ((SubProgramCall)left.Codes[0]).Program);
Assert.Same(leaf, ((SubProgramCall)right.Codes[0]).Program);
Assert.Same(bounds, part.BoundingBox);
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
}
private static bool IsCut(ExecutionMotion motion) => !motion.Rapid && motion.Layer is LayerType.Cut or LayerType.Display;
private static Program Rectangle(double width, double height)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, 0);
program.LineTo(0, 0);
return program;
}
}
@@ -0,0 +1,318 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingDependencyTests
{
// Started just above P, nearest-first cuts P before the far cutoff start. P leaves upward
// and the cutoff's trimmed cut stops short of P, so P, cutoff, Q crosses nothing: only the
// nominal-span dependency puts the cutoff first.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Plan_CutOffWhoseNominalSpanCrossesAPart_IsCutFirstAndAppliedThatWay(bool regenerate)
{
var parameters = ExplicitContourTests.Parameters();
var (_, plate, p, q, cut) = CutOffPlate(parameters, orphan: false);
var request = CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5),
confirmedParameters: regenerate ? parameters : null);
var snapshot = CuttingPlanService.Capture(request);
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(0));
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(2));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
Assert.True(order.IndexOf(cut) < order.IndexOf(p), string.Join(",", result.ProposedOrder.Select(o => o.SourceOrdinal)));
Assert.Equal(regenerate, result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, p)).IsRegenerated);
Assert.False(result.ProposedOrder.Single(o => ReferenceEquals(o.SourcePart, cut)).IsRegenerated);
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
Assert.Equal(order, plate.Parts);
Assert.Contains(q, plate.Parts);
}
[Fact]
public void Apply_CutOffReclassifiedAfterPlanning_IsStale()
{
var (_, plate, p, q, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5)));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
cut.BaseDrawing.IsCutOff = false;
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Equal(new[] { p, q, cut }, plate.Parts);
}
[Fact]
public void Plan_OrphanedCutOff_PrecedesEveryPart()
{
var (_, plate, p, q, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: true);
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5)));
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(0));
Assert.Equal(new[] { 2 }, snapshot.Dependencies.PrerequisitesOf(1));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.Same(cut, result.ProposedOrder[0].SourcePart);
}
[Fact]
public void Plan_PreservedOrderCuttingAPartBeforeItsCutOff_IsAConstraintConflict()
{
var (_, plate, p, _, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5), preservePartOrder: true));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
Assert.Empty(result.ProposedOrder);
var finding = Assert.Single(result.Findings);
Assert.Same(p, finding.SourcePart);
Assert.Same(cut, finding.OtherSourcePart);
Assert.Contains("cutoff", finding.Message);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Replay_RechecksDependenciesInsteadOfTrustingTheSearch(bool regenerate)
{
var parameters = ExplicitContourTests.Parameters();
var (_, plate, _, _, _) = CutOffPlate(parameters, orphan: false);
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, new Vector(11, 12.5),
confirmedParameters: regenerate ? parameters : null));
var ready = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, ready.Status);
// Same verified placements, counterfeit order: crossed part before its cutoff.
var counterfeit = ready.ProposedOrder.OrderBy(o => o.SourceOrdinal).ToArray();
var replayed = regenerate
? CuttingPlanService.ReplayPrograms(snapshot, counterfeit, 0, default)
: CuttingPlanService.Replay(snapshot, counterfeit.Select(o => o.SourceOrdinal).ToArray(), 0, default);
Assert.Equal(CuttingPlanStatus.InvalidInput, replayed.Status);
Assert.Empty(replayed.ProposedOrder);
Assert.Contains(replayed.Findings, f => f.SourceOrdinal == 0 && f.OtherSourceOrdinal == 2);
}
[Fact]
public void Capture_DetachedListWithACutOff_IsUnsupported()
{
var (_, plate, _, _, _) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate.Parts.ToArray()));
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
Assert.Contains("plate-scoped", Assert.Single(result.Findings).Message);
}
[Fact]
public void Plan_PartInsideAHostCutout_IsCutBeforeTheHost()
{
var (plate, host, inner) = NestedPlate(Rectangle(2.7, 2.7, 0.6, 0.6));
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(new[] { 1 }, snapshot.Dependencies.PrerequisitesOf(0));
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.Equal(new[] { inner, host }, result.ProposedOrder.Select(o => o.SourcePart));
var preserved = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, preservePartOrder: true));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, preserved.Status);
var finding = Assert.Single(preserved.Findings);
Assert.Same(host, finding.SourcePart);
Assert.Same(inner, finding.OtherSourcePart);
Assert.Contains("nested in its cutout", finding.Message);
}
[Fact]
public void Dependencies_NonmaterialMotionsCannotHideANestedPart()
{
// Same insert as above plus a remote rapid and scribe mark: material is unchanged, so
// the inner-before-host prerequisite must not depend on whole-program bounds.
var (plate, host, inner) = NestedPlate(Marked(Rectangle(2.7, 2.7, 0.6, 0.6)));
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
Assert.Null(snapshot.Findings.FirstOrDefault()?.Message);
Assert.True(inner.BoundingBox.Left < host.BoundingBox.Left);
Assert.Equal(new[] { 1 }, snapshot.Dependencies.PrerequisitesOf(0));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.Equal(new[] { inner, host }, result.ProposedOrder.Select(o => o.SourcePart));
}
[Fact]
public void Capture_PartStraddlingAHostCutoutEdge_IsAmbiguousContainment()
{
var (plate, host, inner) = NestedPlate(Rectangle(3.6, 2.8, 0.8, 0.4));
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, result.Status);
var finding = Assert.Single(result.Findings);
Assert.Same(inner, finding.SourcePart);
Assert.Same(host, finding.OtherSourcePart);
Assert.Contains("Containment", finding.Message);
}
[Fact]
public void Capture_PartInAConcavePocketOutsideTheHost_HasNoDependency()
{
var nest = new Nest();
var plate = nest.CreatePlate();
var host = Pocketed();
var inner = Rectangle(4.5, 5, 1, 1);
plate.Parts.Add(host);
plate.Parts.Add(inner);
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate));
Assert.Null(snapshot.Findings.FirstOrDefault()?.Message);
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(0));
Assert.Empty(snapshot.Dependencies.PrerequisitesOf(1));
Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.Plan(snapshot).Status);
}
[Fact]
public void DependencyGraph_ReportsCyclesAndFirstViolation()
{
Assert.NotNull(CuttingDependencyGraph.FromPrerequisites([[1], [0], []]).FindCycle());
var chain = CuttingDependencyGraph.FromPrerequisites([[], [0], [1]]);
Assert.Null(chain.FindCycle());
Assert.Null(chain.FirstViolation([0, 1, 2]));
Assert.Equal((2, 1), chain.FirstViolation([0, 2, 1]));
Assert.True(chain.IsReady(1, [0]));
Assert.False(chain.IsReady(2, [0]));
}
private static (Nest, Plate, Part P, Part Q, Part Cut) CutOffPlate(CuttingParameters parameters, bool orphan)
{
var nest = new Nest();
var plate = nest.CreatePlate();
var p = Emitted(parameters, new Vector(10, 10));
var q = Emitted(parameters, new Vector(40, 20));
var cutOff = new CutOff(new Vector(11, 0), CutOffAxis.Vertical) { StartLimit = 0, EndLimit = 30 };
var program = new Program();
program.Codes.Add(new RapidMove(11, 30));
program.Codes.Add(new LinearMove(11, 12.5) { Layer = LayerType.Cut });
cutOff.Drawing.Program = program;
if (!orphan)
plate.CutOffs.Add(cutOff);
var cut = new Part(cutOff.Drawing);
plate.Parts.Add(p);
plate.Parts.Add(q);
plate.Parts.Add(cut);
return (nest, plate, p, q, cut);
}
// A regenerable 2x2 square with its emitted lead at the top-edge midpoint.
private static Part Emitted(CuttingParameters parameters, Vector location)
{
var clean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
var prepared = PreparedContours.Capture(clean, parameters);
var part = new Part(new Drawing("same", clean), location);
Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(1, 3))]), false));
part.CuttingParameters = parameters;
return part;
}
// Host: 10x10 square with circular cutouts of radius 1 at (3,3) and (7,3), cut hole, hole,
// perimeter from the right so its own program has no completed-contour crossing.
private static (Plate, Part Host, Part Inner) NestedPlate(Part inner)
{
var clean = PreparedContourTests.Holes();
var parameters = ExplicitContourTests.Parameters();
var prepared = PreparedContours.Capture(clean, parameters);
var program = prepared.Emit(
[
prepared.Entry(0, 0, new Vector(4, 3)),
prepared.Entry(1, 0, new Vector(8, 3)),
prepared.ClosestEntry(2, new Vector(12, 3))
]);
var host = new Part(new Drawing("same", clean));
Assert.True(host.RestoreLeadInProgram(program, false));
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Parts.Add(host);
plate.Parts.Add(inner);
return (plate, host, inner);
}
// Prefixes a remote rapid and scribe mark to both the clean and placed programs.
private static Part Marked(Part part)
{
static Program Prefix(Program source)
{
var program = new Program();
program.Codes.Add(new RapidMove(-20, -20));
program.Codes.Add(new LinearMove(-19, -20) { Layer = LayerType.Scribe });
program.Codes.AddRange(source.Codes);
return program;
}
var marked = new Part(new Drawing("same", Prefix(part.BaseDrawing.Program)), part.Location);
Assert.True(marked.RestoreLeadInProgram(Prefix(part.Program), false));
return marked;
}
// A U-shaped part open at the top between x 3 and 7, lead on its right edge.
private static Part Pocketed()
{
var outline = new[] { (10.0, 0.0), (0.0, 0.0), (0.0, 10.0), (3.0, 10.0), (3.0, 3.0), (7.0, 3.0),
(7.0, 10.0), (10.0, 10.0), (10.0, 5.0) };
var clean = new Program();
clean.MoveTo(10, 5);
foreach (var (x, y) in outline)
clean.LineTo(x, y);
var placed = new Program();
placed.MoveTo(10.25, 5);
placed.Codes.Add(new LinearMove(10, 5) { Layer = LayerType.Leadin });
foreach (var (x, y) in outline)
placed.LineTo(x, y);
placed.Codes.Add(new LinearMove(10.25, 5) { Layer = LayerType.Leadout });
var part = new Part(new Drawing("same", clean));
Assert.True(part.RestoreLeadInProgram(placed, false));
return part;
}
// Fixed-program rectangle at (x, y), contour starting at its right-edge midpoint with a
// 0.25 straight lead-in and lead-out to the right.
private static Part Rectangle(double x, double y, double width, double height)
{
var clean = new Program();
clean.MoveTo(width, height / 2);
Contour(clean, width, height);
var part = new Part(new Drawing("same", clean), new Vector(x, y));
var placed = new Program();
placed.MoveTo(width + 0.25, height / 2);
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
Contour(placed, width, height);
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
Assert.True(part.RestoreLeadInProgram(placed, false));
return part;
}
private static void Contour(Program program, double width, double height)
{
program.LineTo(width, 0);
program.LineTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, height / 2);
}
private static string Describe(CuttingPlanResult r) =>
$"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message));
}
@@ -0,0 +1,94 @@
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPartOrderTests
{
private static readonly Vector[] Line = [new(1, 0), new(-2, 0), new(3, 0)];
[Fact]
public void Plan_ShortensTheNearestNeighbourTour()
{
// Nearest first gives 0, 2, 1 (1 + 2 + 5 = 8); going left first is 2 + 3 + 2 = 7.
var order = Plan(Line, NoPrerequisites(3));
Assert.Equal(new[] { 1, 0, 2 }, order);
}
[Fact]
public void Plan_NeverPlacesAPartBeforeItsPrerequisite()
{
// The shortest tour starts with part 1, but part 2 must come before it.
var prerequisites = NoPrerequisites(3);
prerequisites[1] = [2];
var order = Plan(Line, prerequisites);
Assert.Equal(new[] { 0, 2, 1 }, order);
}
[Fact]
public void Plan_WaitsForAPrerequisiteBeforeTheNearestPart()
{
// Part 0 is nearest and the shorter tour, but part 1 must be cut first.
var prerequisites = NoPrerequisites(2);
prerequisites[0] = [1];
Assert.Equal(new[] { 1, 0 }, Plan([new(1, 0), new(5, 0)], prerequisites));
}
[Fact]
public void Plan_OnASubset_TreatsPrerequisitesOutsideItAsDone()
{
var prerequisites = NoPrerequisites(3);
prerequisites[1] = [0];
var order = CuttingPartOrder.Plan([1, 2], Line, new Vector(4, 0), prerequisites, CancellationToken.None);
Assert.Equal(new[] { 2, 1 }, order);
}
[Fact]
public void Plan_ReversalsShortenTheTour()
{
// Nearest neighbour gives 4, 1, 0, 2, 3. With reversals the tour ends at 4, 3, 1, 0, 2
// (14.14); moving short runs alone stops at 2, 0, 4, 1, 3 (15.30).
Vector[] centres = [new(-2, -1), new(0, -2), new(-4, 3), new(2, -4), new(0, -1)];
Assert.Equal(new[] { 4, 3, 1, 0, 2 }, Plan(centres, NoPrerequisites(5)));
}
[Fact]
public void Plan_MovesARunOfPartsWhereReversalsCannotHelp()
{
// Nearest neighbour and 2-opt stop at 1, 3, 2, 0 (13.78); moving part 0 to the front
// gives 0, 1, 3, 2 (12.16).
Vector[] centres = [new(-3, 0), new(1, 0), new(4, -3), new(3, 0)];
Assert.Equal(new[] { 0, 1, 3, 2 }, Plan(centres, NoPrerequisites(4)));
}
[Fact]
public void Plan_EqualTours_PreferTheLowerOrdinalFirst()
{
Assert.Equal(new[] { 0, 1 }, Plan([new(0, 5), new(0, -5)], NoPrerequisites(2)));
Assert.Equal(new[] { 0, 1 }, Plan([new(0, -5), new(0, 5)], NoPrerequisites(2)));
}
[Fact]
public void Plan_PrerequisiteCycle_Throws()
{
var prerequisites = NoPrerequisites(2);
prerequisites[0] = [1];
prerequisites[1] = [0];
Assert.Throws<InvalidOperationException>(() => Plan([new(0, 0), new(1, 0)], prerequisites));
}
private static int[] Plan(Vector[] centres, IReadOnlyCollection<int>[] prerequisites) =>
CuttingPartOrder.Plan(centres, Vector.Zero, prerequisites, CancellationToken.None);
private static IReadOnlyCollection<int>[] NoPrerequisites(int count) =>
Enumerable.Range(0, count).Select(_ => (IReadOnlyCollection<int>)Array.Empty<int>()).ToArray();
}
@@ -0,0 +1,335 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanBatchTests
{
[Fact]
public void Apply_EveryPlateReady_InstallsAllPlatesAndRecordsOwnedSettingsOnEach()
{
var nest = new Nest();
var first = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1));
var second = Plate(nest, Clean("c", 1, 1));
var parameters = ExplicitContourTests.Parameters();
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
var proposal = CuttingPlanBatch.Capture([first, second], parameters, preservePartOrder: false).Plan();
Assert.True(proposal.CanApply);
Assert.All(proposal.Plates, p => Assert.True(p.IsReady));
Assert.Equal(3, proposal.Plates.Sum(p => p.RegeneratedCount));
Assert.All(first.Parts.Concat(second.Parts), part => Assert.False(part.HasManualLeadIns)); // Planning never mutates.
((LineLeadIn)parameters.ExternalLeadIn).Length = length * 3; // Confirmed settings were copied at capture.
var commit = proposal.Apply();
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
foreach (var planned in proposal.Plates)
foreach (var placement in planned.Result.ProposedOrder)
Assert.True(ProgramContent.Equal(placement.CopyProgram(), placement.SourcePart.Program));
Assert.All(first.Parts.Concat(second.Parts), part => Assert.True(part.HasManualLeadIns));
Assert.NotNull(first.CuttingParameters);
Assert.NotSame(parameters, first.CuttingParameters);
Assert.NotSame(first.CuttingParameters, second.CuttingParameters);
Assert.Equal(length, ((LineLeadIn)first.CuttingParameters.ExternalLeadIn).Length);
Assert.Equal(length, ((LineLeadIn)second.CuttingParameters.ExternalLeadIn).Length);
}
[Fact]
public void Apply_OneBlockedPlate_RefusesTheWholeBatchAndNamesThePlateAndPart()
{
var nest = new Nest();
var ready = Plate(nest, Clean("open", 1, 1));
var locked = Clean("locked", 1, 1);
locked.LeadInsLocked = true; // Locked programs never regenerate: no lead-in is a conflict.
var blocked = Plate(nest, locked);
var readyProgram = ready.Parts[0].Program;
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(),
preservePartOrder: false, plateNumbers: [3, 7]).Plan();
Assert.False(proposal.CanApply);
Assert.True(proposal.Plates[0].IsReady);
var refused = proposal.Plates[1];
Assert.Equal(CuttingPlanStatus.ConstraintConflict, refused.Result.Status);
Assert.False(refused.KeptCurrentOrder); // Only a budget-exhausted search is retried.
var text = string.Join("\n", proposal.Describe("in"));
Assert.StartsWith("Apply is unavailable: 1 of 2 plates", text);
Assert.Contains("Plate 3: ready.", text);
Assert.Contains("Plate 7: blocked", text);
Assert.Contains("- Part 1 (locked):", text);
var commit = proposal.Apply();
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
Assert.Same(readyProgram, ready.Parts[0].Program);
Assert.False(ready.Parts[0].HasManualLeadIns);
Assert.Null(ready.CuttingParameters);
Assert.Null(blocked.CuttingParameters);
}
[Fact]
public void Plan_OverlappingParts_BlocksApplyAndNamesBothParts()
{
var nest = new Nest();
var plate = Plate(nest, Clean("first", 1, 1), Clean("second", 6, 6));
var programs = plate.Parts.Select(p => p.Program).ToArray();
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var planned = Assert.Single(proposal.Plates);
Assert.Equal(CuttingPlanStatus.Ready, planned.Result.Status); // The route alone is fine...
var pair = Assert.Single(planned.Overlap.Pairs);
Assert.Equal((0, 1), (pair.PartAId, pair.PartBId));
Assert.False(planned.IsReady); // ...but overlapping material blocks the plate.
Assert.False(proposal.CanApply);
var text = string.Join("\n", proposal.Describe("in"));
Assert.Contains("Plate 1: blocked: parts overlap or could not be checked for overlap.", text);
Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Plan_SubprogramHoles_AreNeverAFalseOverlap(Mode mode)
{
var hole = new Program();
hole.MoveTo(1, 0);
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
hole.Mode = mode;
var clean = ExplicitContourTests.Square(false);
clean.SubPrograms[-1] = hole;
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(3, 3), Id = -1 });
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(7, 3), Id = -1 });
clean.Mode = mode;
var nest = new Nest();
var plate = Plate(nest, new Part(new Drawing("holes", clean), new Vector(1, 1)));
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var planned = Assert.Single(proposal.Plates);
Assert.True(planned.IsRouteReady);
Assert.Empty(planned.Overlap.Pairs);
if (mode == Mode.Incremental)
Assert.True(planned.IsReady, string.Join("; ", planned.Overlap.Issues.Select(i => i.Message)));
else
{
// The overlap check cannot read absolute subprograms yet: blocked as unchecked, not as overlapping.
Assert.False(planned.IsReady);
Assert.Contains("Overlap check incomplete for part 1: Absolute-mode subprograms",
string.Join("\n", proposal.Describe("in")));
}
}
[Fact]
public void Capture_RefusedGraphs_BlockThePlateInsteadOfThrowing()
{
var nest = new Nest();
var missing = Clean("missing", 1, 1);
missing.BaseDrawing.Program.Codes = null;
var plate = Plate(nest, missing);
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var planned = Assert.Single(proposal.Plates);
Assert.False(planned.IsReady);
Assert.False(proposal.CanApply);
Assert.Null(proposal.BuildPreview(0));
}
[Fact]
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
{
var nest = new Nest();
var plate = Plate(nest, Grid(4));
var order = plate.Parts.ToArray();
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false, null,
reorderBudget: 50, CancellationToken.None).Plan();
var planned = Assert.Single(proposal.Plates);
Assert.True(planned.KeptCurrentOrder);
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, planned.ReorderAttempt!.Status);
Assert.True(planned.IsReady);
Assert.False(planned.OrderChanged);
Assert.Equal(order, planned.Result.ProposedOrder.Select(p => p.SourcePart));
Assert.Contains("the current order is kept", string.Join("\n", proposal.Describe("in")));
Assert.True(proposal.CanApply);
}
[Fact]
public void Plan_PreservedOrder_DoesNotSearchForANewOrder()
{
var nest = new Nest();
var plate = Plate(nest, Grid(4));
var phases = new List<CuttingPlanPhase>();
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: true)
.Plan(new Recorder(phases));
var planned = Assert.Single(proposal.Plates);
Assert.True(planned.IsReady);
Assert.False(planned.KeptCurrentOrder);
Assert.Equal(new[] { CuttingPlanPhase.CheckingOverlap, CuttingPlanPhase.KeepingOrder }, phases);
}
[Fact]
public void Apply_AfterALiveEdit_IsStaleAndChangesNothing()
{
var nest = new Nest();
var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1));
var programs = plate.Parts.Select(p => p.Program).ToArray();
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
Assert.True(proposal.CanApply);
plate.Parts[1].Offset(0, 1);
var commit = proposal.Apply();
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
Assert.Null(plate.CuttingParameters);
}
[Fact]
public void Plan_Cancelled_OffersNothingToApply()
{
var nest = new Nest();
var plate = Plate(nest, Clean("a", 1, 1));
var batch = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var proposal = batch.Plan(token: cancellation.Token);
Assert.True(proposal.IsCancelled);
Assert.False(proposal.CanApply);
Assert.Equal("Planning was cancelled. Nothing has changed.", proposal.Describe("in")[0]);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.False(plate.Parts[0].HasManualLeadIns);
Assert.Null(plate.CuttingParameters);
}
[Fact]
public void BuildPreview_ReadyPlate_IsDetachedAndShowsTheProposal()
{
var nest = new Nest();
var plate = Plate(nest, Grid(4));
plate.Quantity = 3;
var drawing = plate.Parts[0].BaseDrawing;
var nested = drawing.Quantity.Nested;
var liveParts = plate.Parts.ToArray();
var livePrograms = liveParts.Select(p => p.Program).ToArray();
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var preview = proposal.BuildPreview(0);
Assert.Equal(0, preview.Quantity);
Assert.Equal(plate.Size, preview.Size);
Assert.Equal(nested, drawing.Quantity.Nested);
var proposed = proposal.Plates[0].Result.ProposedOrder;
Assert.Equal(proposed.Count, preview.Parts.Count);
for (var i = 0; i < proposed.Count; i++)
{
Assert.DoesNotContain(preview.Parts[i], liveParts);
Assert.Same(proposed[i].SourcePart.BaseDrawing, preview.Parts[i].BaseDrawing);
Assert.Equal(proposed[i].Location, preview.Parts[i].Location);
Assert.True(ProgramContent.Equal(proposed[i].CopyProgram(), preview.Parts[i].Program));
}
Assert.Equal(liveParts, plate.Parts);
Assert.Equal(livePrograms, plate.Parts.Select(p => p.Program));
}
[Fact]
public void BuildPreview_NotReadyOrChangedPlate_ShowsNothing()
{
var nest = new Nest();
var ready = Plate(nest, Clean("a", 1, 1));
var locked = Clean("locked", 1, 1);
locked.LeadInsLocked = true;
var blocked = Plate(nest, locked);
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan();
Assert.True(proposal.Plates[0].IsReady);
Assert.False(proposal.Plates[1].IsReady);
// A refused plate may hold graphs that are unsafe to copy, so it is never previewed.
Assert.Null(proposal.BuildPreview(1));
Assert.NotNull(proposal.BuildPreview(0));
// A moved part would draw the replayed program somewhere it was never checked.
ready.Parts[0].Offset(25, 0);
Assert.Null(proposal.BuildPreview(0));
}
[Theory]
[InlineData(0, 20000)]
[InlineData(10, 20000)]
[InlineData(100, 40000)]
[InlineData(6_000_000, int.MaxValue)]
public void PlateBudget_ScalesWithPartCountFromTheDefault(int parts, int expected) =>
Assert.Equal(expected, CuttingPlanBatch.PlateBudget(parts));
[Fact]
public void Plan_LargeGrid_FindsANewOrderWithinThePlateBudget()
{
// Row-major 10 x 10 needs more than the 20000 default to reorder (about 26000).
var nest = new Nest();
var plate = Plate(nest, Grid(100));
plate.Size = new Size(120, 120);
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var planned = Assert.Single(proposal.Plates);
Assert.True(planned.IsReady, string.Join("\n", proposal.Describe("in")));
Assert.False(planned.KeptCurrentOrder);
Assert.Equal(100, planned.PartCount);
}
[Fact]
public void Capture_RejectsMissingDuplicateOrMisnumberedPlates()
{
var nest = new Nest();
var plate = Plate(nest, Clean("a", 1, 1));
var parameters = ExplicitContourTests.Parameters();
Assert.Throws<ArgumentNullException>(() => CuttingPlanBatch.Capture(null, parameters, false));
Assert.Throws<ArgumentNullException>(() => CuttingPlanBatch.Capture([plate], null, false));
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([], parameters, false));
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate, plate], parameters, false));
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate, null], parameters, false));
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate], parameters, false, [1, 2]));
}
private static Plate Plate(Nest nest, params Part[] parts)
{
var plate = nest.CreatePlate();
plate.Size = new Size(100, 100);
foreach (var part in parts)
plate.Parts.Add(part);
return plate;
}
private static Part Clean(string name, double x, double y) =>
new(new Drawing(name, ExplicitContourTests.Square(false)), new Vector(x, y));
private static Part[] Grid(int count)
{
var drawing = new Drawing("grid", PreparedContourTests.Holes());
var side = (int)System.Math.Ceiling(System.Math.Sqrt(count));
return Enumerable.Range(0, count)
.Select(i => new Part(drawing, new Vector(1 + i % side * 11, 1 + i / side * 11)))
.ToArray();
}
private sealed class Recorder(List<CuttingPlanPhase> phases) : IProgress<CuttingPlanProgress>
{
public void Report(CuttingPlanProgress value) => phases.Add(value.Phase);
}
}
@@ -0,0 +1,575 @@
using System.Reflection;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanCommitTests
{
[Fact]
public void Apply_FixedProgramPlan_ReordersWithoutAccountingEventsAndPublishesOnce()
{
var (nest, plate, parts) = FixedPlate();
var programs = parts.Select(p => p.Program).ToArray();
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var events = Watch(plate);
Assert.Contains(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
f => f.Kind == PostVerificationKind.RapidCrossing);
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(parts, plate.Parts); // Planning never mutates.
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
Assert.Empty(commit.RefreshErrors);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, plate.Parts);
Assert.Equal((0, 0, 1), events());
Assert.Equal(quantities, parts.Select(p => p.BaseDrawing.Quantity.Nested));
Assert.Equal(programs, parts.Select(p => p.Program)); // Fixed programs are not replaced.
Assert.DoesNotContain(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
f => f.Kind == PostVerificationKind.RapidCrossing);
// The same proposal is bound to the captured state, which no longer exists.
var again = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, again.Status);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, plate.Parts);
Assert.Equal((0, 0, 1), events());
}
[Fact]
public void Apply_RegeneratedPlan_InstallsTheExactReplayedProgramWithOwnedSettings()
{
var (nest, plate, part, _) = RegeneratedPlate(Vector.Zero);
var location = part.Location;
var rotation = part.Rotation;
var quantity = part.BaseDrawing.Quantity.Nested;
// Caller-confirmed settings are planning input, not plate state: editing them after
// capture neither stales the plan nor leaks into what is installed.
var parameters = ExplicitContourTests.Parameters();
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
var proposal = Assert.Single(result.ProposedOrder);
Assert.True(proposal.IsRegenerated);
((LineLeadIn)parameters.ExternalLeadIn).Length = length * 3;
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
Assert.True(ProgramContent.Equal(proposal.CopyProgram(), part.Program));
Assert.NotSame(parameters, part.CuttingParameters);
Assert.Equal(length, ((LineLeadIn)part.CuttingParameters.ExternalLeadIn).Length);
Assert.True(part.HasManualLeadIns);
Assert.False(part.LeadInsLocked);
Assert.Equal(BitConverter.DoubleToInt64Bits(location.X), BitConverter.DoubleToInt64Bits(part.Location.X));
Assert.Equal(BitConverter.DoubleToInt64Bits(location.Y), BitConverter.DoubleToInt64Bits(part.Location.Y));
Assert.Equal(BitConverter.DoubleToInt64Bits(rotation), BitConverter.DoubleToInt64Bits(part.Rotation));
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
var bounds = part.Program.BoundingBox();
bounds.Offset(part.Location);
Assert.Equal((bounds.X, bounds.Y, bounds.Width, bounds.Length),
(part.BoundingBox.X, part.BoundingBox.Y, part.BoundingBox.Width, part.BoundingBox.Length));
// The installed program is owned: later proposal copies cannot alias it.
var detached = proposal.CopyProgram();
detached.Codes.Clear();
Assert.NotEmpty(part.Program.Codes);
Assert.Empty(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings);
}
[Theory]
[InlineData("order")]
[InlineData("pose")]
[InlineData("program-in-place")]
[InlineData("program-replaced")]
[InlineData("lock")]
[InlineData("quantity")]
[InlineData("cutoff")]
[InlineData("same-name-drawing")]
[InlineData("list-replaced")]
[InlineData("added")]
[InlineData("cutoff-classification")]
[InlineData("part-settings-in-place")]
[InlineData("part-settings-nested")]
[InlineData("plate-settings-in-place")]
[InlineData("plate-settings-replaced")]
public void Apply_AnyChangeAfterCapture_IsStaleAndChangesNothing(string change)
{
var (_, plate, parts) = FixedPlate();
var cutOff = new CutOff(new Vector(30, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutOff);
plate.CuttingParameters = new CuttingParameters();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
switch (change)
{
case "order": plate.Parts.Reorder([parts[2], parts[1], parts[0]]); break;
case "pose": parts[0].Offset(1e-12, 0); break;
case "program-in-place": ((Motion)parts[1].Program.Codes[1]).Feedrate = 7; break;
case "program-replaced":
Assert.True(parts[1].RestoreLeadInProgram((Program)parts[1].Program.Clone(), false)); break;
case "lock": parts[2].LeadInsLocked = true; break;
case "quantity": plate.Quantity = 2; break;
case "cutoff":
cutOff.Position = new Vector(BitConverter.Int64BitsToDouble(
BitConverter.DoubleToInt64Bits(30.0) + 1), 0); break;
// Every fixture drawing has the same name: identity must be by reference.
case "same-name-drawing": parts[2].BaseDrawing.Program.Codes.Add(new Comment("edited")); break;
case "list-replaced":
var list = new ObservableList<Part>();
foreach (var part in parts) list.Add(part);
plate.Parts = list; break;
case "added": plate.Parts.Add(Rectangle(20, 0, 2, 2)); break;
// Classification decides lead, material, obstacle and dependency treatment.
case "cutoff-classification": parts[0].BaseDrawing.IsCutOff = true; break;
// A regenerated part would otherwise overwrite an edit made after capture.
case "part-settings-in-place": parts[0].CuttingParameters.Kerf = 0.125; break;
case "part-settings-nested": parts[0].CuttingParameters.Assignment.Preference = "LIAT"; break;
case "plate-settings-in-place": plate.CuttingParameters.PierceClearance = 0.25; break;
case "plate-settings-replaced": plate.CuttingParameters = new CuttingParameters(); break;
}
var after = PlateCuttingState.Capture(plate);
var events = Watch(plate);
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Same(plate, commit.Plate);
Assert.False(string.IsNullOrEmpty(commit.Message));
Assert.True(after.IsCurrent(), after.Difference());
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Apply_MalformedLiveProgram_IsStaleInsteadOfThrowing()
{
var (_, plate, parts) = FixedPlate();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
parts[1].Program.Codes = null!;
var events = Watch(plate);
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Equal(parts, plate.Parts);
Assert.Null(parts[1].Program.Codes);
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void ProgramContent_ComparesAuthoredKeySpellingExactly()
{
Program With(string key)
{
var program = new Program();
program.Codes.Add(new LinearMove(1, 0)
{
VariableRefs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { [key] = "v" }
});
return program;
}
Assert.True(ProgramContent.Equal(With("X"), With("X")));
Assert.False(ProgramContent.Equal(With("X"), With("x")));
}
[Fact]
public void Apply_PartSharedByTwoPlates_IsRefusedWithoutChange()
{
var parameters = ExplicitContourTests.Parameters();
var clean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
var prepared = PreparedContours.Capture(clean, parameters);
var shared = new Part(new Drawing("same", clean), new Vector(5, 5));
Assert.True(shared.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(3, 1))]), false));
var nest = new Nest();
var regenerated = nest.CreatePlate();
var fixedPlate = nest.CreatePlate();
regenerated.Parts.Add(shared);
fixedPlate.Parts.Add(shared);
var results = new[]
{
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(regenerated, confirmedParameters: parameters)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(fixedPlate))
};
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
Assert.True(results[0].ProposedOrder[0].IsRegenerated);
var program = shared.Program;
var states = new[] { regenerated, fixedPlate }.Select(p => PlateCuttingState.Capture(p)).ToArray();
var commit = CuttingPlanService.Apply(results);
// Installing through one plate would silently replace the other plate's fixed program.
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
Assert.Same(program, shared.Program);
Assert.All(states, s => Assert.True(s.IsCurrent(), s.Difference()));
}
[Fact]
public void CommitInstaller_IsOnlyReachableThroughTheVerifiedService()
{
// The installer validates root references only; owned, verified payloads come from
// CuttingPlanService.Apply. Public access would accept nested aliases of live programs.
Assert.False(typeof(CuttingPlanCommit).IsPublic);
Assert.False(typeof(PlateCuttingPlan).IsPublic);
Assert.False(typeof(PlannedPartProgram).IsPublic);
Assert.True(typeof(CuttingPlanService).GetMethod(nameof(CuttingPlanService.Apply),
[typeof(IEnumerable<CuttingPlanResult>), typeof(CancellationToken)])!.IsPublic);
}
[Fact]
public void Apply_LaterPlateStale_AppliesNothingAnywhere()
{
var (_, first, firstParts) = FixedPlate();
var (_, second, secondParts) = FixedPlate();
var results = new[]
{
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second))
};
secondParts[0].LeadInsLocked = true;
var firstState = PlateCuttingState.Capture(first);
var events = Watch(first);
var commit = CuttingPlanService.Apply(results);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Same(second, commit.Plate);
Assert.Equal(firstParts, first.Parts);
Assert.True(firstState.IsCurrent(), firstState.Difference());
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Apply_InstallFailureOnLaterPlate_RestoresEveryPlateExactlyWithoutPublishing()
{
var (_, first, firstParts) = FixedPlate();
var (_, second, part, parameters) = RegeneratedPlate(Vector.Zero);
var (_, third, thirdPart, thirdParameters) = RegeneratedPlate(Vector.Zero);
var results = new[]
{
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second, confirmedParameters: parameters)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(third, confirmedParameters: thirdParameters))
};
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
var states = new[] { first, second, third }.Select(p => PlateCuttingState.Capture(p)).ToArray();
var program = part.Program;
var bounds = part.BoundingBox;
var events = new[] { Watch(first), Watch(second), Watch(third) };
var failure = new InvalidOperationException("injected");
var installs = 0;
var commit = CuttingPlanService.Apply(results, default, (plate, _) =>
{
installs++;
if (ReferenceEquals(plate, third)) throw failure;
});
Assert.Equal(CuttingCommitStatus.Failed, commit.Status);
Assert.Same(failure, commit.Error);
Assert.Equal(2, installs); // The second plate really was installed before the failure.
Assert.Equal(firstParts, first.Parts);
Assert.Same(program, part.Program);
Assert.Same(bounds, part.BoundingBox);
Assert.Same(parameters, part.CuttingParameters);
Assert.All(states, s => Assert.True(s.IsCurrent(), s.Difference()));
Assert.All(events, e => Assert.Equal((0, 0, 0), e()));
Assert.Same(thirdParameters, thirdPart.CuttingParameters);
}
[Fact]
public void Apply_CancelledBeforeCommit_ChangesNothing()
{
var (_, plate, parts) = FixedPlate();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate));
var state = PlateCuttingState.Capture(plate);
var events = Watch(plate);
using var cancel = new CancellationTokenSource();
cancel.Cancel();
var commit = CuttingPlanService.Apply([result], cancel.Token);
Assert.Equal(CuttingCommitStatus.Cancelled, commit.Status);
Assert.Equal(parts, plate.Parts);
Assert.True(state.IsCurrent(), state.Difference());
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Apply_ObserverFailureAfterPublication_IsAReportedRefreshFailureNotARollback()
{
var (_, first, firstParts) = FixedPlate();
var (_, second, secondParts) = FixedPlate();
var results = new[]
{
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(first)),
CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(second))
};
var failure = new InvalidOperationException("view refresh");
var seen = new List<string>();
first.PartsReordered += (_, _) => throw failure;
first.PartsReordered += (_, _) => seen.Add("first");
second.PartsReordered += (_, _) => seen.Add("second");
var commit = CuttingPlanService.Apply(results);
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
Assert.Same(failure, Assert.Single(commit.RefreshErrors));
Assert.Equal(new[] { "first", "second" }, seen);
Assert.Equal(new[] { firstParts[1], firstParts[0], firstParts[2] }, first.Parts);
Assert.Equal(new[] { secondParts[1], secondParts[0], secondParts[2] }, second.Parts);
}
[Theory]
[InlineData("detached")]
[InlineData("not-ready")]
[InlineData("duplicate")]
[InlineData("empty")]
public void Apply_RefusesResultsThatAreNotApplicablePlatePlans(string fault)
{
var (_, plate, parts) = FixedPlate();
var state = PlateCuttingState.Capture(plate);
var events = Watch(plate);
CuttingPlanResult[] results = fault switch
{
"detached" => [CuttingPlanService.Plan(new CuttingPlanRequest(parts))],
"not-ready" => [CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, preservePartOrder: true))],
"duplicate" => Enumerable.Repeat(CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate)), 2).ToArray(),
_ => []
};
if (fault == "detached")
Assert.Equal(CuttingPlanStatus.Ready, results[0].Status);
if (fault == "not-ready")
Assert.Equal(CuttingPlanStatus.ConstraintConflict, results[0].Status);
var commit = CuttingPlanService.Apply(results);
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
Assert.Equal(parts, plate.Parts);
Assert.True(state.IsCurrent(), state.Difference());
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Request_NullDetachedListStaysUnambiguousAndInvalid()
{
// A plate overload of the constructor made this existing call ambiguous (CS0121).
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest(null)).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(null)).Status);
}
[Fact]
public void Apply_AllPlatesWithEmptySentinel_KeepsSentinelAndPlateList()
{
var nest = new Nest();
using var manager = new PlateManager(nest);
var plate = nest.CreatePlate();
foreach (var part in Fixture())
plate.Parts.Add(part);
Assert.Equal(2, nest.Plates.Count);
var sentinel = nest.Plates[1];
Assert.Empty(sentinel.Parts);
var listChanges = 0;
manager.PlateListChanged += (_, _) => listChanges++;
var sentinelEvents = Watch(sentinel);
var results = nest.Plates.ToArray().Select(p => CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(p))).ToArray();
Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
Assert.Empty(results[1].ProposedOrder);
var commit = CuttingPlanService.Apply(results);
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
Assert.Equal(new[] { plate, sentinel }, nest.Plates);
Assert.Equal(0, listChanges);
Assert.Equal((0, 0, 0), sentinelEvents());
Assert.Equal(3, plate.Parts.Count);
}
[Theory]
[InlineData("missing")]
[InlineData("duplicate")]
[InlineData("foreign")]
[InlineData("count")]
[InlineData("null")]
public void Reorder_RejectsAnythingButTheSameReferencesWithoutChangeOrEvents(string fault)
{
var (_, plate, parts) = FixedPlate();
var events = Watch(plate);
Part[] order = fault switch
{
"missing" => [parts[0], parts[1], parts[1]],
"duplicate" => [parts[0], parts[1], parts[2], parts[2]],
"foreign" => [parts[0], parts[1], Rectangle(0, 0, 1, 1)],
"count" => [parts[0], parts[1]],
_ => [parts[0], parts[1], null!]
};
Assert.Throws<ArgumentException>(() => plate.Parts.Reorder(order));
Assert.Equal(parts, plate.Parts);
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void ProgramContent_DetectsEveryAuthoredFieldAndSharingShape()
{
// Guard: a new settable instruction field must be added to ProgramContent and here.
var expected = new Dictionary<Type, string[]>
{
[typeof(RapidMove)] = ["EndPoint", "Feedrate", "Suppressed", "UseExactStop", "VariableRefs"],
[typeof(LinearMove)] = ["EndPoint", "Feedrate", "Layer", "Suppressed", "UseExactStop", "VariableRefs"],
[typeof(ArcMove)] = ["CenterPoint", "EndPoint", "Feedrate", "Layer", "Rotation", "Suppressed",
"UseExactStop", "VariableRefs"],
[typeof(SubProgramCall)] = ["Id", "Offset", "Program", "Rotation"],
[typeof(Comment)] = ["Value"],
[typeof(Feedrate)] = ["Value", "VariableRef"],
[typeof(Kerf)] = ["Value"],
[typeof(Program)] = ["Mode"]
};
foreach (var (type, names) in expected)
Assert.Equal(names, type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.SetMethod?.IsPublic == true && p.GetIndexParameters().Length == 0)
.Select(p => p.Name).Order());
var mutations = new Action<Program>[]
{
p => ((Motion)p.Codes[0]).EndPoint = Nudge(((Motion)p.Codes[0]).EndPoint),
p => ((Motion)p.Codes[1]).UseExactStop = true,
p => ((Motion)p.Codes[1]).Feedrate = 3,
p => ((Motion)p.Codes[1]).Suppressed = true,
p => ((Motion)p.Codes[1]).VariableRefs!["x"] = "other",
p => ((Motion)p.Codes[1]).VariableRefs = null,
p => ((LinearMove)p.Codes[1]).Layer = LayerType.Display,
p => ((ArcMove)p.Codes[2]).Layer = LayerType.Display,
p => ((ArcMove)p.Codes[2]).Rotation = RotationType.CW,
p => ((ArcMove)p.Codes[2]).CenterPoint = Nudge(((ArcMove)p.Codes[2]).CenterPoint),
p => ((SubProgramCall)p.Codes[3]).Id = 9,
p => ((SubProgramCall)p.Codes[3]).Offset = Nudge(((SubProgramCall)p.Codes[3]).Offset),
p => ((SubProgramCall)p.Codes[3]).Rotation = 1,
p => ((SubProgramCall)p.Codes[3]).Program.Codes.Add(new Comment("hole")),
p => ((Comment)p.Codes[5]).Value = "other",
p => ((Feedrate)p.Codes[6]).Value = 2,
p => ((Feedrate)p.Codes[6]).VariableRef = "f",
p => ((Kerf)p.Codes[7]).Value = KerfType.Right,
p => p.Mode = Mode.Incremental,
p => p.Rotate(1e-9),
p => p.Variables["v"] = new VariableDefinition("v", "2", 2),
p => p.SubPrograms[5] = new Program(),
// Two calls sharing one hole versus two equal but distinct holes.
p => ((SubProgramCall)p.Codes[4]).BindProgram(OwnedProgramCopy.Copy(((SubProgramCall)p.Codes[4]).Program))
};
var original = Rich();
Assert.True(ProgramContent.Equal(original, OwnedProgramCopy.Copy(original)));
for (var i = 0; i < mutations.Length; i++)
{
var changed = OwnedProgramCopy.Copy(original);
mutations[i](changed);
Assert.False(ProgramContent.Equal(original, changed), $"Mutation {i} was not detected.");
}
}
private static Vector Nudge(Vector v) =>
new(BitConverter.Int64BitsToDouble(BitConverter.DoubleToInt64Bits(v.X) + 1), v.Y);
private static Program Rich()
{
var hole = new Program();
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Cut });
var program = new Program();
program.Variables["v"] = new VariableDefinition("v", "1", 1);
program.Codes.Add(new RapidMove(1, 1));
program.Codes.Add(new LinearMove(2, 1)
{
Layer = LayerType.Cut,
VariableRefs = new Dictionary<string, string> { ["x"] = "v" }
});
program.Codes.Add(new ArcMove(new Vector(3, 2), new Vector(2, 2), RotationType.CCW) { Layer = LayerType.Cut });
program.Codes.Add(new SubProgramCall(hole, 0) { Id = 1, Offset = new Vector(5, 5) });
program.Codes.Add(new SubProgramCall(hole, 0) { Id = 1, Offset = new Vector(7, 5) });
program.Codes.Add(new Comment("note"));
program.Codes.Add(new Feedrate(1));
program.Codes.Add(new Kerf(KerfType.Left));
program.SubPrograms[1] = hole;
return program;
}
private static Func<(int Added, int Removed, int Reordered)> Watch(Plate plate)
{
var added = 0;
var removed = 0;
var reordered = 0;
plate.PartAdded += (_, _) => added++;
plate.PartRemoved += (_, _) => removed++;
plate.PartsReordered += (_, _) => reordered++;
return () => (added, removed, reordered);
}
private static (Nest, Plate, Part[]) FixedPlate()
{
var nest = new Nest();
var plate = nest.CreatePlate();
var parts = Fixture();
foreach (var part in parts)
plate.Parts.Add(part);
return (nest, plate, parts);
}
private static (Nest, Plate, Part, CuttingParameters) RegeneratedPlate(Vector location)
{
var clean = PreparedContourTests.Holes();
var parameters = ExplicitContourTests.Parameters();
var prepared = PreparedContours.Capture(clean, parameters);
var original = prepared.Emit(
[
prepared.Entry(0, 0, new Vector(2, 3)),
prepared.Entry(1, 0, new Vector(8, 3)),
prepared.ClosestEntry(2, new Vector(-2, 3))
]);
var part = new Part(new Drawing("same", clean), location);
Assert.True(part.RestoreLeadInProgram(original, false));
part.CuttingParameters = parameters;
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Parts.Add(part);
return (nest, plate, part, parameters);
}
// Slice 1 fixture: A,B,C in this order crosses completed A; B,A,C does not.
private static Part[] Fixture() =>
[
Rectangle(4, 0, 4, 4),
Rectangle(0, 1, 2, 2),
Rectangle(10, 1, 2, 2)
];
private static Part Rectangle(double x, double y, double width, double height)
{
var clean = new Program();
clean.MoveTo(width, height / 2);
Contour(clean, width, height);
var part = new Part(new Drawing("same", clean), new Vector(x, y));
var placed = new Program();
placed.MoveTo(width + 0.25, height / 2);
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
Contour(placed, width, height);
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
Assert.True(part.RestoreLeadInProgram(placed, false));
part.CuttingParameters = new CuttingParameters();
return part;
}
private static void Contour(Program program, double width, double height)
{
program.LineTo(width, 0);
program.LineTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, height / 2);
}
}
@@ -0,0 +1,413 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanServiceTests
{
[Fact]
public void FixedPrograms_OriginalOrderCrossesA_ReorderedWholeProgramsDoNot()
{
var parts = Fixture();
var original = Analyze(parts);
Assert.Equal(new int?[] { 2, 3 }, original.Findings.Select(f => f.PartNumber));
Assert.All(original.Findings, crossing =>
{
Assert.Equal(PostVerificationKind.RapidCrossing, crossing.Kind);
Assert.Equal(1, crossing.OtherPartNumber);
});
var reordered = Analyze(parts[1], parts[0], parts[2]);
Assert.Empty(reordered.Findings);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Plan_ReordersWholeFixedPrograms_AndIndependentlyReplaysEveryPlacement(bool locked)
{
var parts = Fixture();
foreach (var part in parts)
part.LeadInsLocked = locked;
var unchanged = Unchanged(parts);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(parts.Length, result.ProposedOrder.Select(p => p.SourcePart).Distinct().Count());
Assert.Empty(result.Findings);
Assert.Empty(Analyze(result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
Assert.Equal(new Vector(12.25, 2), result.ProposedOrder[^1].Execution.DeparturePoint);
unchanged();
}
[Fact]
public void Plan_BacktracksRatherThanReturningUnsafeNearestOrder()
{
var parts = Fixture();
var start = new Vector(8.25, 2); // A is nearest but no continuation after A can reach B safely.
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, start));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(new[] { parts[1], parts[0], parts[2] }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.True(result.Expansions > parts.Length);
Assert.Empty(AnalyzeFrom(start, result.ProposedOrder.Select(p => p.SourcePart).ToArray()).Findings);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Plan_LockedFixedInternalCrossingIsConflictWithExactSourceIdentity(Mode mode)
{
var part = Rectangle("holes", 100, 50, 20, 20);
var hole = new Program();
hole.MoveTo(0.5, 0);
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
hole.Mode = mode;
var placed = new Program();
placed.SubPrograms[-7] = hole;
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(5, 5), Id = -7 });
placed.MoveTo(8, 5);
placed.MoveTo(2, 5);
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(15, 5), Id = -7 });
placed.Mode = mode;
Assert.True(part.RestoreLeadInProgram(placed, true));
var parts = new[] { Fixture()[0], part };
var unchanged = Unchanged(parts);
Assert.Contains(Analyze(part).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.NotEmpty(result.Findings);
Assert.All(result.Findings, finding =>
{
Assert.Equal(PostVerificationKind.RapidCrossing, finding.Kind);
Assert.Equal(1, finding.SourceOrdinal);
Assert.Equal(1, finding.OtherSourceOrdinal);
Assert.Same(part, finding.SourcePart);
Assert.Same(part, finding.OtherSourcePart);
});
unchanged();
}
[Theory]
[InlineData("recursive")]
[InlineData("missing-call")]
[InlineData("null-code")]
[InlineData("nonfinite")]
[InlineData("bad-arc")]
[InlineData("empty")]
[InlineData("motionless")]
[InlineData("null-codes")]
public void Plan_MalformedOrEmptyCuttingStateNeverReady(string fault)
{
var part = Fixture()[0];
var program = part.Program;
switch (fault)
{
case "recursive": program.Codes.Add(new SubProgramCall { Program = program }); break;
case "missing-call": program.Codes.Add(new SubProgramCall()); break;
case "null-code": program.Codes.Add(null!); break;
case "nonfinite": program.MoveTo(double.NaN, 0); break;
case "bad-arc": program.ArcTo(1, 1, 0, 0, RotationType.CW); break;
case "empty": program.Codes.Clear(); break;
case "motionless": program.Codes.Clear(); program.MoveTo(1, 1); break;
case "null-codes": program.Codes = null!; break;
}
var beforeCodes = program.Codes?.ToArray();
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(CuttingPlanStatus.InvalidInput, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.Same(program, part.Program);
Assert.Equal(beforeCodes, program.Codes?.ToArray());
}
[Theory]
[InlineData("suppressed")]
[InlineData("missing-lead")]
[InlineData("retention-unknown")]
[InlineData("cutoff")]
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
{
var part = Fixture()[0];
var expected = CuttingPlanStatus.UnsupportedGeometry;
switch (fault)
{
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
case "missing-lead":
part.Program.Codes.RemoveAt(1);
((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
expected = CuttingPlanStatus.ConstraintConflict; break;
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
}
var unchanged = Unchanged([part]);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
Assert.Equal(expected, result.Status);
Assert.Empty(result.ProposedOrder);
Assert.NotEmpty(result.Findings);
unchanged();
}
[Fact]
public void Plan_BudgetExhaustionHasNoFallbackOrMutation()
{
var parts = Fixture();
var unchanged = Unchanged(parts);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 1));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
Assert.Equal(1, result.Expansions);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
unchanged();
}
[Fact]
public void Plan_CancelledCaptureAndWorkerHaveNoMutation()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var unchanged = Unchanged(parts);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Equal(CuttingPlanStatus.Cancelled,
CuttingPlanService.Plan(new CuttingPlanRequest(parts), cancellation.Token).Status);
Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancellation.Token).Status);
unchanged();
}
[Fact]
public void Snapshot_OwnsMotionsPosesLocksAndOrder_NotLiveProgramsOrSettings()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var ownedEnds = snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)).ToArray();
foreach (var part in parts)
{
part.Program.Codes.Clear();
part.BaseDrawing.Program.Codes.Clear();
part.Location = new Vector(999, 999);
part.CuttingParameters.TabsEnabled = true;
part.LeadInsLocked = true;
}
Array.Reverse(parts);
var result = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(new[] { 1, 0, 2 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
Assert.Equal(ownedEnds, snapshot.Placements.SelectMany(p => p.Execution.Motions.Select(m => m.End)));
Assert.Equal(new Vector(4, 0), snapshot.Placements[0].Location);
Assert.All(snapshot.Placements, p => Assert.False(p.LeadInsLocked));
Assert.True(((ICollection<FixedProgramPlacement>)snapshot.Placements).IsReadOnly);
Assert.True(((ICollection<ExecutionMotion>)snapshot.Placements[0].Execution.Motions).IsReadOnly);
Assert.True(((ICollection<FixedProgramPlacement>)result.ProposedOrder).IsReadOnly);
}
[Fact]
public void Plan_DeterministicReferenceIdentity_NotDrawingNamesOrNameEquality()
{
var parts = Fixture();
foreach (var part in parts)
part.BaseDrawing.Name = "same";
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
for (var repeat = 0; repeat < 5; repeat++)
Assert.Equal(new[] { parts[1], parts[0], parts[2] },
CuttingPlanService.Plan(snapshot).ProposedOrder.Select(p => p.SourcePart));
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest([parts[0], parts[0]])).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput, CuttingPlanService.Plan(new CuttingPlanRequest([])).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest(parts, expansionBudget: 0)).Status);
Assert.Equal(CuttingPlanStatus.InvalidInput,
CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(double.NaN, 0))).Status);
}
[Fact]
public void ExplicitStart_UsesActualLeadoutDeparture_AndDefaultRemainsZero()
{
var part = Fixture()[1];
var defaultResult = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
var explicitResult = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(2.25, 2)));
Assert.Equal(CuttingPlanStatus.Ready, defaultResult.Status);
Assert.Equal(CuttingPlanStatus.Ready, explicitResult.Status);
Assert.Equal(Vector.Zero.DistanceTo(new Vector(2.25, 2)), defaultResult.RapidDistance, 10);
Assert.Equal(0, explicitResult.RapidDistance);
Assert.Equal(new Vector(2.25, 2), explicitResult.ProposedOrder[0].Execution.DeparturePoint);
Assert.Empty(Analyze(part).Findings);
Assert.Empty(AnalyzeFrom(Vector.Zero, part).Findings);
}
[Theory]
[InlineData("unsafe")]
[InlineData("duplicate")]
[InlineData("omitted")]
[InlineData("unknown")]
public void IndependentReplay_RefusesUnsafeOrIncompleteProposals(string fault)
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var unchanged = Unchanged(parts);
var order = fault switch
{
"unsafe" => new[] { 0, 1, 2 },
"duplicate" => [1, 0, 0],
"omitted" => [1, 0],
_ => [1, 0, 3]
};
var result = CuttingPlanService.Replay(snapshot, order, 0, default);
Assert.Equal(fault == "unsafe" ? CuttingPlanStatus.ConstraintConflict : CuttingPlanStatus.InvalidInput,
result.Status);
Assert.Empty(result.ProposedOrder);
Assert.False(result.IndependentlyReplayed);
if (fault == "unsafe")
{
Assert.Equal(new int?[] { 1, 2 }, result.Findings.Select(f => f.SourceOrdinal));
Assert.All(result.Findings, f => Assert.Same(parts[0], f.OtherSourcePart));
}
unchanged();
}
[Fact]
public void EqualRapidDistance_TiesKeepSourceOrdinal()
{
var parts = new[] { Rectangle("same", 10, -3, 2, 2), Rectangle("same", 10, 1, 2, 2) };
var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.Equal(parts, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Empty(Analyze(parts).Findings);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void OwnedReader_SharedSignedSubcallsPreserveTrueLeadoutDepartureAndOwnership(Mode mode)
{
var sub = new Program();
sub.MoveTo(1.25, 0);
sub.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
sub.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
sub.Codes.Add(new LinearMove(1.25, 0) { Layer = LayerType.Leadout });
sub.Mode = mode;
var program = new Program();
program.SubPrograms[-7] = sub;
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(5, 5) });
program.Codes.Add(new SubProgramCall { Id = -7, Program = sub, Offset = new Vector(15, 5) });
var before = sub.ToString();
var owned = ExecutionMotionReader.Read(program, new Vector(100, 50), Vector.Zero, default);
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
Assert.Equal(new Vector(106.25, 55), owned.Motions[3].End);
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
sub.Codes.Clear();
program.Codes.Clear();
Assert.Equal(new Vector(116.25, 55), owned.DeparturePoint);
Assert.Empty(new ReleasedContourState().Check(owned, Vector.Zero, 1));
Assert.NotEmpty(before);
}
[Fact]
public void Check_CopiedReleasedStateDoesNotLeakBranchObstacles()
{
var parts = Fixture();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts));
var parent = new ReleasedContourState();
var branch = parent.Copy();
Assert.Empty(branch.Check(snapshot.Placements[0].Execution, Vector.Zero, 1));
Assert.NotEmpty(branch.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
Assert.Empty(parent.Check(snapshot.Placements[1].Execution, new Vector(8.25, 2), 2));
}
private static Action Unchanged(Part[] parts)
{
var order = parts.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var codeRefs = parts.Select(p => p.Program.Codes.ToArray()).ToArray();
var text = parts.Select(p => p.Program.ToString()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var rotations = parts.Select(p => p.Rotation).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var parameters = parts.Select(p => p.CuttingParameters).ToArray();
var locks = parts.Select(p => p.LeadInsLocked).ToArray();
var manual = parts.Select(p => p.HasManualLeadIns).ToArray();
var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var subs = parts.SelectMany(p => p.Program.Codes.OfType<SubProgramCall>())
.Select(call => (call, call.Program, text: call.Program.ToString(), call.Offset, call.Rotation, call.Id)).ToArray();
return () =>
{
Assert.Equal(order, parts);
for (var index = 0; index < parts.Length; index++)
{
var part = parts[index];
Assert.Same(programs[index], part.Program);
Assert.Equal(codeRefs[index], part.Program.Codes);
Assert.Equal(text[index], part.Program.ToString());
Assert.Equal(locations[index], part.Location);
Assert.Equal(rotations[index], part.Rotation);
Assert.Same(bounds[index], part.BoundingBox);
Assert.Same(parameters[index], part.CuttingParameters);
Assert.Equal(locks[index], part.LeadInsLocked);
Assert.Equal(manual[index], part.HasManualLeadIns);
Assert.Equal(quantities[index], part.BaseDrawing.Quantity.Nested);
}
foreach (var sub in subs)
{
Assert.Same(sub.Program, sub.call.Program);
Assert.Equal(sub.text, sub.call.Program.ToString());
Assert.Equal(sub.Offset, sub.call.Offset);
Assert.Equal(sub.Rotation, sub.call.Rotation);
Assert.Equal(sub.Id, sub.call.Id);
}
};
}
private static Part[] Fixture() =>
[
Rectangle("A", 4, 0, 4, 4),
Rectangle("B", 0, 1, 2, 2),
Rectangle("C", 10, 1, 2, 2)
];
private static Part Rectangle(string name, double x, double y, double width, double height)
{
var clean = new Program();
clean.MoveTo(width, height / 2);
Contour(clean, width, height);
var part = new Part(new Drawing(name, clean), new Vector(x, y));
var placed = new Program();
placed.MoveTo(width + 0.25, height / 2);
placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
Contour(placed, width, height);
placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
Assert.True(part.RestoreLeadInProgram(placed, false));
part.CuttingParameters = new CuttingParameters();
return part;
}
private static void Contour(Program program, double width, double height)
{
program.LineTo(width, 0);
program.LineTo(0, 0);
program.LineTo(0, height);
program.LineTo(width, height);
program.LineTo(width, height / 2);
}
// Independent replay uses private placements/drawings, never an event-wired plate
// around the source drawings (which would change their quantity accounting).
private static PostVerificationReport Analyze(params Part[] parts) => AnalyzeFrom(Vector.Zero, parts);
private static PostVerificationReport AnalyzeFrom(Vector start, params Part[] parts)
{
var nest = new Nest();
var plate = nest.CreatePlate();
foreach (var source in parts)
{
var copy = new Part(new Drawing("replay", (Program)source.BaseDrawing.Program.Clone()), source.Location);
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
plate.Parts.Add(copy);
}
return PostVerificationAnalyzer.Analyze(nest, start);
}
}
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more