63 Commits
Author SHA1 Message Date
ajandClaude Opus 5.5 afdf196754 fix(help): let F1 set a placed part instead of opening help
The Help menu's F1 shortcut took the key before the plate view, so F1
in Clone/Add Part opened help rather than setting the part in place as
it always has. Actions can now claim F1; Clone does, and MainForm lets
the key through to the plate view while it is active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:33:45 -04:00
ajandClaude Opus 5.5 35be95b843 fix(cutoffs): leave leads out of the open-contour trim hull
When a part's outline is open (a tab gap, say), cut-off trimming falls back
to a convex hull. That hull still included lead-in/lead-out points, so it ran
from the lead's pierce point back to the part and stopped cut-offs far short
of the material. Cutting through a lead is harmless, so the hull now uses the
material contour only, as the closed-outline path already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:32:30 -04:00
ajandClaude Opus 5.5 4ae50c8a73 feat(cutoffs): trim, extend and swap cut-offs against crossing lines
Operators could only drag or delete a cut-off, so changing which line runs
through a junction (e.g. letting a vertical split the sheet to keep a whole
remnant beyond it) meant deleting and re-adding lines. Trim/Extend edits the
existing start/end limits instead, so no file-format or planner change is
needed: trims stop on the crossing line's centre, extends run to the next
crossing or the sheet edge, and Swap Through-Cut (right-click a junction)
does both at once, trimming the through line's far side because only the
origin-side edge is certain. Edits undo as one step and re-place the changed
segments with PlateSequencing.PlaceCutOffs so a new separator is cut last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:08:37 -04:00
ajandClaude Opus 5.5 a0c620690e fix(cutoffs): keep the cut sequence when cut-offs regenerate more segments
Regenerating a cut-off into more segments than were saved inserted each
segment at its old index, pushing later parts and cut-offs down; a reloaded
nest could cut its sheet separator before parts it splits off. Segments now
go after the parts saved before them, so a changed segment count never shifts
anything else. Files record every segment's place, and an older file's
sheet-separating cut-off loads last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:37:06 -04:00
ajandClaude Fable 5.1 07a9739774 perf(cutting): plan plates faster with bounded fallbacks and prefiltered checks
Plan Cutting spent most of its time on work that never changed the outcome:
many-holed parts paid quadratic shape-pair and ring-containment costs before
any search, every plate was captured twice, and failing plates burned their
whole budget in exhaustive fallback passes that never found a plan on 127
production plates.

- OverlapMaterial: skip native shape pairs whose bounds are clear by more
  than the query allowance; gate polygon touch and winding tests on vertex
  bounds.
- LeadMaterialSnapshot: gate ring enclosure tests on ring extents when
  finding the perimeter and rejecting nested cutouts.
- CuttingPlanBatch: capture each plate once; the kept-order retry derives
  from the same capture through CuttingPlanSnapshot.WithPreservedOrder.
- JointCuttingPlanSearch: end the kept-order full pass and the sequence-free
  recovery and full passes after FallbackStalls (2) stalls' worth of
  expansions when programs are regenerated; plates with only fixed programs
  keep the exhaustive search so a refusal stays genuine.

On 127 archive plates (lead-ins cleared, 35" scrap cut-offs) total time fell
from 164 s to 102 s with identical outcomes; the problem-nest corpus gives
the same status and expansion count on every plate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 09:35:33 -04:00
ajandClaude Opus 5.5 6a85593bd4 fix(planning): accept arc leads tangent to a straight edge at their joint
An arc lead-in meeting a straight window edge tangentially has a double
root at its joint. Rounding split it into two contacts a few 1e-8 apart,
or dropped it so the exact ray fallback reported the joint as an
uncertain contact. Either refused a valid entry, so N26-27U's windows
lost most of their entries and the plate never planned.

Treat a contact within 1e-6 of a joint its boundary passes through as
the joint, and let the exact line fallback skip ray hits at contacts
already recorded. The pinned-hole test now expects the other hole's
nearest entry rather than the fallback both runs used to share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:35:28 -04:00
ajandClaude Opus 5.5 c798be9b95 fix(undo): restore plates that hold only cut-offs
Undo regenerates each restored plate's cut-offs, which removes the
cut-off parts first. A plate holding nothing but cut-offs became empty
for a moment, so the new-plate sentinel dropped a trailing plate while
the restore was still enumerating the plates, throwing "Collection was
modified". Regenerate inside a PlateManager batch so the sentinel only
settles once every plate is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:17:51 -04:00
ajandClaude Opus 5.5 fe216be2b4 build(help): pin opennest-help v0.5.0
v0.5.0 adds the sheet-cutoff topic the Sheet Cut-Off tool opens on F1;
with v0.4.0 that F1 fell back to the help home page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:00:47 -04:00
ajandClaude Opus 5.5 a1d20bdeb5 feat(cutoff): tell users Space turns the manual cut-off line
Issue #13 asked whether a Sheet Cut-Off can be rotated; Space already
toggles vertical/horizontal, but nothing said so. The tool now shows a
"Space: turn horizontal" tag by the pointer, spells out its keys in the
status bar, and F1 opens a new sheet-cutoff help topic while it is
active (actions can now supply a status hint and a help topic).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:47:16 -04:00
ajandClaude Opus 5.5 41460481d6 fix(cutting): keep Plan Cutting's lead and tab edits
Plan Cutting saved its settings only on a successful Apply, so a plate
that would not plan lost them. Each open then laid the machine's leads
back over the last parameters, and the cutting panel rebuilt parameters
from scratch, dropping kerf and the machine/material/thickness stamp.

Save the settings when the dialog is accepted, keep the panel's
unexposed fields, and only apply the machine's leads when the last
parameters were not already taken from that machine, material, grade
and thickness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:58:14 -04:00
ajandClaude Opus 5.5 a9396ba065 feat(simulator): lock the nest to viewing while the cut simulator is open
The simulator reuses Plan Cutting's inspection lock: the canvas pans and
zooms and plates can be stepped through, but selection, part edits,
menus, toolbars, lists and Undo/Redo are disabled until it closes.
Escape or closing the nest closes the simulator first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:36:07 -04:00
ajandClaude Fable 5.1 101d7b5a50 fix(desktop): refuse fills on an outlined plate before the service
PlateFillService throws for a plate with a sheet outline, but the desktop
swallowed it: Fill (Ctrl+F) caught every exception and showed nothing, and
Fill Area's async void either completed silently or let it escape.

PlateFillService.Refusal(plate) names the reason, and every caller checks
it first: Fill and Fill Area put it in the status bar and add nothing,
Pattern Tile offers a new plate only, the MCP fill/pack tools return it as
an Error result, and the console exits 2 with it unless --autonest is given.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 00:23:10 -04:00
ajandClaude Fable 5.1 a0d063bb94 test(desktop): apply the Edit Plate dialog directly in the outline tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 22:21:50 -04:00
ajandClaude Fable 5.1 c21b5b006e fix(engine): keep outline members out of serialized stock
Rectangular stock serializes exactly as before, so result digests that
hash the whole result are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 22:21:01 -04:00
ajandClaude Fable 5.1 55ab8ec4a5 docs: describe irregular stock sheets
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 22:16:37 -04:00
ajandClaude Fable 5.1 662d3daf8c feat(desktop): draw and edit a plate's sheet outline
The plate view fills the outline, hatches obstacles and draws the usable
area; Edit Plate imports an outline from a CAD file or clears it, with
the size read-only while the sheet has one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 22:16:36 -04:00
ajandClaude Fable 5.1 360c7136dd feat(console): --outline makes the target plate an irregular sheet
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 22:16:36 -04:00
ajandClaude Fable 5.1 501d90b825 feat(engine): nest on outlined stock with the Irregular packer
NestPlateStock carries the outline; its usable material (less obstacles,
eroded by the largest edge spacing) becomes the FrontierPacker's
PackBoundary. The layout check and the runner's candidate validation
reject a part outside the outline or over an obstacle, area-based cost
and the area budget use the material area, salvage credits nothing, and
Rectangles, Circles, FlatBar and the fill strategies place nothing on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 22:16:36 -04:00
ajandClaude Fable 5.1 fa7a1f0fce feat(io): persist a plate's sheet outline
An optional per-plate `outline` key holds the vertices and obstacles;
rectangular plates write nothing new, an empty outlined plate is kept,
and a malformed value loads the saved size as a rectangle with a reader
warning. Undo snapshots restore it through the same path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 22:16:36 -04:00
ajandClaude Fable 5.1 672ed09074 feat(core): give a plate a sheet outline for irregular stock
A SheetOutline is a closed polygon in plate coordinates with optional
obstacle polygons, normalized to start at the origin; the plate's Size
stays its bounding box. Plate.Area is the material area, rotations turn
the outline, AutoSize and Snap to Standard Size leave it, manual cut-offs
are trimmed to the material the line crosses, the automatic planner
refuses it and no expected remnant is reported.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 22:16:35 -04:00
ajandClaude Fable 5.1 e5dc67346d feat(cutouts): pack a cutout with the frontier packer
CutoutPackerProposal runs the Irregular engine's FrontierPacker on one
hole, frame-local, with the hole as its boundary and already seated
inserts as obstacles, for all six strategy variants; every pose is
certified with NestLayoutCheck.Clears against the frame, the occupants
and the other poses, and the variant seating the most copies wins, ties
going to the tighter layout. CutoutRouter keeps that proposal over the
lattice-and-NFP one when it seats more copies, or the same number more
compactly, so the pre-pass gets it on every non-round hole.

Two copies of a bar in a frame window, which the lattice never tried and
the sampled NFP search scattered across the window (one at the inner
fit's centre), now stand together against one wall. The test rebuilds
that frame and bar from embedded G-code; the residual-accounting
pipeline tests use a hole that holds one insert so their second copy
stays an ordinary requirement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 17:44:08 -04:00
ajandClaude Fable 5.1 f22e839850 feat(engine): let the Irregular packer fill an arbitrary boundary
Add PackBoundary: a closed outline with obstacles, bound to one
NoFitCache, whose per-orientation inner-fit polygon (the clearance
footprint inside the outline eroded by the other half clearance) is
computed once and shared. FrontierPacker takes an optional boundary and
seeds each region from that inner fit, drops orientations with no fit
and subtracts obstacle no-fit polygons like clamp ones; without a
boundary it is unchanged, so rectangular stock layouts are identical.

CutoutLatticeFill.InnerFit now calls the shared kernel with the same
Clipper operations, so lattice results are unchanged. The engine's
strategy variants are internal so a cutout proposal can reuse them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-28 17:44:08 -04:00
ajandClaude Opus 5.5 b8e8cc387d fix(geometry): let parts push off a frame whose contour misses closing by 1e-6
PEP-rounded drawings can leave a contour 1e-6 short of closing. ShapeBuilder
chains it as a loop at Tolerance.ChainTolerance, but the slide classifier
re-recovered loops at 1e-7, so the whole part became unresolved and every
contact with it blocked. Parts placed in its cutout at exactly the part
spacing could not be pushed (Y/X keys) in any direction, even away from the
wall. Recover loops with the same tolerance ShapeBuilder uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:03:38 -04:00
ajandClaude Opus 5.5 c6b6678a2f feat(planning): raise the head over cut parts as the last resort
When no search finds a route that keeps every rapid off parts already
cut, Plan Cutting used to offer only an unverified safest route needing
explicit acceptance. Cincinnati's own software instead raises the head
for such a rapid. Now the safest-route fallback marks each planner-chosen
rapid that still crosses cut parts with a HEAD RAISE comment, and the
plan is Ready with every raise listed. The strict search never marks a
rapid, and the batch prefers fewer broken checks, then fewer raises,
then less travel, so a plate that can be cut head-down never gets one.

The reader flags marked rapids and the checker sets their crossings
apart, in replay and pre-post verification alike. The Cincinnati CL
post writes M47 before a marked rapid whatever the M47 mode (the
distance-based safety raise is unchanged); the CI Fiber post writes a
new Head raise block and refuses marked nests while it is blank, since
its raise code is unconfirmed. Rapids into locked programs, cut-offs
and hole sub-programs are never marked and keep their findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:37:33 -04:00
ajandClaude Opus 5.5 4179b53c97 fix(nesting): let parts with bolt holes go into other parts' cutouts
The cutout pre-pass offered lattice/NFP inserts only when they had no
cutouts at all, so a strap with 0.25" bolt holes was never tried in a
frame's window. On N26-264 that left both PT11s loose on the sheet and
Rectangles' staggered PT10s won, although both PT11s fit one PT10's end
window. Holes matter only if another part could land in them, so a part
whose every cutout is smaller in area than the job's smallest outline is
now an ordinary insert; proxies and parts with usable holes stay out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:32:24 -04:00
ajandClaude Opus 5.5 c8419761e0 test(planning): plan only the problem nests a change can affect
Every corpus nest guards Plan Cutting, yet agents ran the whole corpus
after any change, including nesting-engine work, and the 5k washers
alone take about 90 s of a two-minute run. Each nest now names the
planner areas it guards in a "checks:" comment line, and
OPENNEST_PROBLEM_NESTS_CHECKS limits a run to nests guarding those
areas. Untagged nests always run and an unknown area fails the run, so
nothing is skipped by accident. AGENTS.md now limits the corpus to Plan
Cutting changes and asks for the filter; the doc maps code to areas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:41:50 -04:00
ajandClaude Opus 5.5 16bd92567c fix(planning): swap the pair a blocked approach doubled back across
When a part could not be reached without crossing parts already cut, the
free search always learned "cut the blocked part before them". When the
crossed part was the one cut just before the part the tool left from, the
tool had gone on past it and come back: the cheap repair is to cut those
two the other way round. Without it, N26-23S learned a chain of wrong
rules (8 before 4 and 5, 3 before 1, ...) that excluded every valid order
and ran the full search out of budget, although swapping parts 5 and 6
gives a valid plan. It now plans ready in 786 expansions.

The problem-nest corpus keeps every outcome; three P260417-17 plates stay
Ready with more expansions (5341 -> 8937 the largest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:14:10 -04:00
ajandClaude Opus 5.5 cd51f7cfda fix(ui): stop the cut simulator's time readout flashing
Playback rewrites the status labels every frame, and a plain Label erases
its background before drawing the new text, so the time blinked. The status
values now use a double-buffered label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:18:27 -04:00
ajandClaude Opus 5.5 6f4e872c03 fix(ui): stop a grid's keyboard tooltip before the grid is disposed
Browsing nests in Database mode crashed with ObjectDisposedException on
DataGridView: selecting another nest drops the embedded version history,
and WinForms leaves its grid's keyboard-tooltip auto-pop timer running.
When the timer fired it hid the tooltip through the disposed grid.
GridStyle now hides that tooltip when a styled grid's window is destroyed,
which stops the timer for every grid in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:37:47 -04:00
ajandClaude Opus 5.5 588ae5ed09 test(ui): check cut-off ids over the cut, not for an orange-free label
8dded7f1 draws cut-off ids white on a disc of the cut-off line's orange,
so the old assertion that no orange touches the id failed on the Windows
CI job. The test now checks what the disc is for: the disc is there, and
the id's white strokes survive on the cut's own pixels. Drawing the lines
after the labels fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:27:37 -04:00
ajandClaude Opus 5.5 277ca36f42 feat(server): mirror the desktop update feed for Database-mode clients
Every installed copy checked GitHub Releases on its own, so a shop's
workstations shared GitHub's 60-requests-per-hour unauthenticated limit
per public address and each downloaded the ~90 MB package over the
internet. Shops large enough to hit that already run the nest server.

The server now mirrors the Velopack feed at /updates/{stable|dev}: one
GitHub API request per 30-minute refresh, each package downloaded once
and checked against the feed's size and SHA-256 before it is served. A
channel is mirrored only while clients ask for it, so stable-only shops
never download development builds. Database-mode clients read the
server first and fall back to GitHub when it has no feed (old server,
mirror off, not mirrored yet), downloading each package from whichever
source listed it. A hand-set feed URL still takes precedence.
OPENNEST_UPDATES=off turns the mirror off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:05:20 -04:00
ajandClaude Opus 5.5 8dded7f177 fix(ui): draw cut-off ids on a disc of the cut-off line's color
The plate-colored disc behind a cut-off id read as a notch in any part edge
the cut runs along. An orange-red disc with a white id reads as a tag on the
cut-off line instead, and still keeps the dashed line from striking it through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:44:54 -04:00
ajandClaude Opus 5.5 b65a9485c7 fix(ui): stop status-bar tooltips flashing on and off
The strip's built-in item tooltips open below the cursor. With the window
maximized the strip is at the bottom of the screen, so Windows pushes the
tooltip back over the cursor; the strip then sees a mouse leave, hides it,
re-shows it on the next hover, and it flickers. Show the tooltips above the
strip instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:41:15 -04:00
ajandClaude Opus 5.5 63915d6fb6 fix(ui): link Support Bed's help topic instead of a paragraph of inline text
The explanation at the top of the dialog was long and duplicated the help
guide. A link beside OK/Cancel (and F1) opens the existing support-grid
topic, which the pinned help release already maps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:36:42 -04:00
ajandClaude Opus 5.5 379e3fd433 fix(ui): preview the selected version in the database browser
Highlighting an earlier version on the Versions tab left the plates,
drawings and plate preview on the current version, so there was no way
to see what a historical version looked like without opening it. The
embedded history now reports its selection and the browser downloads
that version (debounced, cached per history view) and shows it, labelled
as historical; selecting the current version returns to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:30:45 -04:00
ajandClaude Opus 5.5 f1468e6f57 feat(library): let drawings without a material or thickness suit any nest
A drawing every nest needs, such as the operator's test square cut before
the job, had to be saved once per material and thickness or found with Show
all materials. A blank material or grade, or a zero thickness, now means
any: the server's library filter returns such drawings for every value,
and adding one to a nest is not a difference, so it keeps its cut pins and
takes the nest's material for the fields it left blank. Duplicate detection
still compares every key exactly on the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:28:19 -04:00
ajandClaude Opus 5.5 0be9e2201a feat(training): add sketch-based drawing search prototype
Python tool that finds drawings resembling a hand sketch, photo or MS Paint
outline. It indexes the OpenNest server's drawing library (read-only) and/or
DXF folders, compares DINOv2 embeddings of normalized renderings across all
8 rotations/mirrors, and writes an HTML page of the closest matches with an
optional overall-size filter.

train.py fine-tunes the embedding contrastively on synthetic sketches of the
library itself (no labelling); evaluate.py measures recall on held-out
drawings or a folder of real sketches. On 40,757 server drawings, held-out
synthetic sketches find the right shape in the top 20 75% of the time,
up from 18% zero-shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 06:09:40 -04:00
ajandClaude Opus 5.5 fcb61f3b3e feat(ui): choose Plan Cutting's plate scope in the panel
The panel gains a Current plate only / All plates selector, shown when the
two scopes differ. Changing it takes the unapplied scrap cut-offs off,
cancels any running plan and replans. The toolbar button opens with the
scope last chosen there (default all plates); the Plate and Nest menu items
keep their fixed scopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:12:18 -04:00
ajandClaude Opus 5.5 87e0a70878 feat(ui): preview leads on the Cutting Parameters form
Each lead tab draws a sample part with a hole and the configured leads and
pierce points, emphasizing the tab's own contour and updating as values change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:08:22 -04:00
ajandClaude Opus 5.5 8776eb4734 feat(ledger): keep MaterialLedger item codes from being double-booked
Auto Nest leaves out ledger item codes already on other open server nests,
and save and post re-check a nest's codes before going on.

- Server: GET /api/nests/stock-item-codes?exclude= lists the codes on
  non-deleted Draft, Quote and To Be Cut nests. Codes are stored in
  nest_stock_codes (schema v14) at every archive write; the upgrade reads
  each current archive once.
- Data: INestRepository.GetStockCodesInUseAsync, LedgerCodesInUse (5 s,
  failures become an unknown answer), the pure LedgerCodeFilter for ledger
  rows (reusable by release-time nesting) and LedgerCodeCheck for the
  re-check.
- Desktop: the Auto Nest ledger summary counts "(N on other nests)" or says
  other nests were not checked. Save, Save As and post open Ledger Stock
  Changed when codes are taken: proceed after a per-attempt
  acknowledgement, remove the codes (undoable), or cancel. A ledger that
  cannot be read skips the check with a status note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:43:08 -04:00
ajandClaude Opus 5.5 fd2e9c2f56 fix(settings): keep user settings across reinstalls and versions
WinForms user settings lived in user.config under %LOCALAPPDATA%\OpenNest,
the Velopack install folder that setup clears on a reinstall, and the
default provider starts empty at each assembly version. Store them in
%APPDATA%\OpenNest\user-settings.json instead, carrying over any values
the old user.config still holds on first run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:44:23 -04:00
ajandClaude Opus 5.5 3618b335cb feat(ui): edit a machine's support bed in Machine Configuration
Add a Support Bed tab beside Clamps that edits the grid or slats new
nests assigned the machine receive. A machine without a bed shows the
legacy support-grid.json values; the first edit stores them on the
machine in its units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:39:30 -04:00
ajandClaude Opus 5.5 7d9c64487d fix(update): check for updates one minute after startup
The first background check waited the full 30-minute interval, so
sessions shorter than that never checked or downloaded an update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:10:56 -04:00
ajandClaude Opus 5.5 cc066b457e feat(ui): use the new OpenNest application icon
Set it as the exe's ApplicationIcon and as the main and help windows'
icon (the main window showed the WinForms default). The .ico is
rendered from OpenNest.svg as 32-bit images from 16 to 256 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:57:58 -04:00
ajandClaude Opus 5.5 d2f814f1f9 feat(update): show the version and add Help > Check for Updates
The status bar shows the running version, and the Help menu gains
Check for Updates (an immediate check sharing the background check's
lock) and About OpenNest. A copy that is not a Velopack installation
offers to open the GitHub Releases page instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:46:08 -04:00
ajandClaude Opus 5.5 69d8187adc fix(ui): settle status-bar location promptly after the mouse stops
The trailing location update ran on a WinForms timer, and WM_TIMER is
only delivered once input and paint messages drain, so the label lagged
while the mouse moved and after it stopped. Moves now drive the 100 ms
throttle from a timestamp, and a 30 ms settle timer restarted by each
skipped move shows the final point. Unchanged text is not reassigned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:55:49 -04:00
ajandClaude Opus 5.5 4670577110 docs(update): note that a downloaded update applies at the next startup
VelopackApp keeps its default auto-apply on startup, so an update the
user did not restart for installs when OpenNest next opens. Also note
that nothing runs while it is closed and the first check waits 30
minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:13:52 -04:00
ajandClaude Opus 5.5 d505a50934 fix(update): skip update checks when no Velopack locator is set
With GitHub as the default source, UpdaterService always built an
UpdateManager, which throws unless VelopackApp.Build().Run() has set a
locator. MainApp does, but WinForms test hosts constructing MainForm do
not, so 78 OpenNest.WinForms.Tests failed. Without a locator there is no
installation to update, so the service now stays idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:39:27 -04:00
ajandClaude Opus 5.5 bc15c362e1 feat(release): publish green master pushes as an opt-in development build
dev-build.yml runs after Cross-platform tests pass on a master push,
packages that commit as X.Y.Z-dev.N (the minor after the newest vX.Y.Z
tag, N the commit count) and replaces the single rolling dev-build
pre-release. Only its publish job has release write access, and a run
whose commit is no longer master's head publishes nothing.

Tools > Options > Include development builds lets an installed copy take
that pre-release; everyone else still sees full releases only.
Publish-Windows.ps1 accepts -dev.N versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:31:36 -04:00
aj 9b4c1f51d0 Merge branch 'claude/velopack-github-updates' 2026-10-27 17:52:42 -04:00
aj eba15791e7 Merge remote-tracking branch 'origin/master' 2026-10-27 17:52:37 -04:00
ajandClaude Opus 5.5 7fe4de4b6d feat(release): ship self-updating Velopack packages on GitHub Releases
Publish-Windows.ps1 now packs the smoke-tested folder with the pinned vpk
CLI (.config/dotnet-tools.json, 1.2.0 to match the Velopack package) into
velopack/: Setup.exe, Portable.zip, the full nupkg, releases.win.json and
RELEASES, checked for the full package and the portable layout and hashed
into OpenNest.vX.Y.Z.velopack.sha256. The release workflow uploads them
with the candidate.

With no feed URL set, installed copies now check GitHub Releases (full
releases only) instead of skipping update checks; a URL in Options still
selects a self-hosted feed. Deploy-OpenNest.ps1 uses the pinned vpk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:52:30 -04:00
ajandClaude Opus 5.5 f4317bc52f fix(planning): learn to reach a sheet separator from beyond the parts in its way
A sheet separator is cut after everything, so when the approach to its
pierce crossed parts already cut, the usual "cut it before them" rule
contradicted that and the search learned nothing. It fell back to the
full search, which spent the budget or found a route that climbed the
column and jumped back down past the cut parts (N26-23M).

When every "cut the blocked part first" rule would contradict a required
order, learn the other way round: cut the part the approach left from
before the parts it crossed, so the tool arrives from beyond them. A
separator blocked this way now ends the attempt at once, like any
cutoff whose rule can be learned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:36:26 -04:00
ajandClaude Opus 5.5 207f126b9e fix(ui): move the saved-nests Find bar above the grid
At the bottom of the form the search box read as part of the Details
panel. It now sits under the Nests header, next to the list it filters,
and comes first in the tab order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:03:32 -04:00
ajandClaude Opus 5.5 0f1e5fb568 feat(planning): start contours at rounded corners, not mid-edge
Plan Cutting ranked a convex corner first, but a corner rounded by a small
fillet had no corner vertex: its tangent joints ranked level with straight
edge midpoints, so travel decided and a large part (P230323-02 plate 8,
PT06, 0.015 corner fillets) started in the middle of a 64-inch edge. A start
there can leave a step where the cut closes after the head has put heat into
the plate; at a corner it is hidden.

A convex fillet tangent to straight edges at both ends, turning less than a
half circle and reaching no further along either edge than its length, now
makes its two joints convex-corner entries. Each carries its reach from the
sharp corner it rounds, so ranking counts it on both sides of that corner.
Longer curves, half-circle ends and inside fillets keep their tangent joints.
Every perimeter on that plate now starts at a corner; the problem-nest corpus
has no plate below its baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:42:30 -04:00
ajandClaude Opus 5.5 0ede9443de fix(cutting): route a part's holes from the end of the segment cut before it
On P230323-02 plate 7 the frame part cut after a cut-off segment took its
upper small hole first, so the rapid up from the segment's end at the
bottom of the sheet crossed the rapid from the lower hole to the window.
The free search plans parts as if segments were absent, so the part's hole
route was ranked from where the tool was before the segment (the previous
part's end, top right), not from where it really is.

After CutOffInsertion places the segments, each holed part cut straight
after one now keeps its outside entry and order but takes the hole route
and preferred hole entries planned from the segment's end, when that
chain has an entry for every hole and passes the checks. The search's
look-through of segments keeps ranking outside entries from the
pre-segment point (so routes stay as planned without the lines) but plans
hole routes from the real position, so a kept-order replan of an applied
plan picks the same holes. Plates without cut-offs are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:42:29 -04:00
ajandClaude Opus 5.5 58b8467c39 fix(ui): keep Draw Support visible while inspecting a cutting plan
The support overlay was drawn inside the live-plate-only paint block, and
the Plan Cutting preview plate never copied the support bed, so opening
the cut planner hid the grid points and each part's support marks. The
overlay now follows the displayed plate, the preview carries the bed, and
tab-point clearance falls back to the live plate's tab size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:25:09 -04:00
ajandClaude Opus 5.5 eedff85904 fix(ui): place bend lines in the program frame
Drawing.Bends stay in the DXF's source frame while CadImporter moves the
program so its first pierce sits at the origin (Source.Offset). The plate
renderer added part.Location to raw bend points, so every bend and etch
mark was shifted by its drawing's pierce offset, visible on the drawing
library set preview and on plates alike. Remove Source.Offset before
rotating and placing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:57:45 -04:00
ajandClaude Opus 5.5 cc16590881 ci: run FrontEnd tests on Linux and Desktop tests in release builds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:47:41 -04:00
aj 8ad32e4864 Merge branch 'refs/heads/claude/retarget-net8-d903de' 2026-10-26 20:47:22 -04:00
ajandClaude Opus 5.5 7198f7a4f7 build(desktop): scaffold OpenNest.Desktop shared library
Add OpenNest.Desktop (net8.0), the toolkit-neutral layer the WinForms app
and OpenNest.Avalonia will share, and OpenNest.Desktop.Tests with a guard
that the library references no WinForms/WindowsDesktop, System.Drawing.Common
or Avalonia assembly, project setting or (transitive) package.

Both desktop front ends reference it; OpenNest.Avalonia joins OpenNest.sln
and the new tests join the Linux CI matrix. No code moves yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:34:06 -04:00
ajandClaude Opus 5.5 ee842c822f build(gpu): target net8.0
OpenNest.Gpu uses no System.Drawing, P/Invoke or Windows API: PartBitmap is a
plain int grid and ILGPU's CPU, CUDA and OpenCL backends are cross-platform.
GpuSlideContactTests pass on Linux against the net8.0 build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:33:20 -04:00
ajandClaude Opus 5.5 512b861739 build(frontend-tests): target net8.0
FrontEnd.Tests only matched MCP's old net8.0-windows marker; MCP and every
project it references are net8.0 and it never used WindowsDesktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:33:20 -04:00
243 changed files with 11640 additions and 576 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"vpk": {
"version": "1.2.0",
"commands": [
"vpk"
],
"rollForward": false
}
}
}
+3 -1
View File
@@ -19,9 +19,11 @@ jobs:
matrix:
project:
- OpenNest.Tests
- OpenNest.Desktop.Tests
- OpenNest.Engine.Tests
- OpenNest.IO.Tests
- OpenNest.Server.Tests
- OpenNest.FrontEnd.Tests
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
@@ -70,7 +72,7 @@ jobs:
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.
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
runs-on: windows-2022
timeout-minutes: 20
defaults:
+128
View File
@@ -0,0 +1,128 @@
name: Development build
# Every master push whose cross-platform tests pass becomes the rolling
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
# newest release tag and N the commit count. Installed copies take it only
# with Tools > Options > Include development builds.
on:
workflow_run:
workflows: ['Cross-platform tests']
types: [completed]
branches: [master]
permissions:
contents: read
concurrency:
group: dev-build
cancel-in-progress: true
jobs:
package:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: windows-2022
timeout-minutes: 30
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Resolve development version
id: version
run: |
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
$latest = @($releases)[-1]
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
Write-Host "Development version $version (after v$latest)"
"version=$version" >> $env:GITHUB_OUTPUT
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
- name: Upload development build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: OpenNest-dev-build
path: |
artifacts/*.zip
artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error
retention-days: 7
compression-level: 0
publish:
needs: package
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: read
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
VERSION: ${{ needs.package.outputs.version }}
steps:
- name: Skip if master has moved on
id: current
run: |
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
if [ "$head" != "$SOURCE_SHA" ]; then
echo "master is at $head; its own run publishes the newer build."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- name: Download development build
if: steps.current.outputs.skip != 'true'
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
- name: Verify checksums
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
sha256sum -c ./*.zip.sha256
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
- name: Replace the dev-build pre-release
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
if gh release view dev-build >/dev/null 2>&1; then
gh release delete dev-build --cleanup-tag --yes
fi
cat > notes.md <<EOF
Automatic development build of master at $SOURCE_SHA.
It may contain unfinished features. Installed copies receive it only with
**Tools > Options > Include development builds**; full releases are unaffected.
EOF
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
--target "$SOURCE_SHA" --prerelease --latest=false \
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
- name: Read back the pre-release
if: steps.current.outputs.skip != 'true'
run: |
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
grep -qx 'prerelease=true draft=false assets=8' readback.txt
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
+6 -1
View File
@@ -68,7 +68,7 @@ jobs:
# 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.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.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." }
}
@@ -96,6 +96,11 @@ jobs:
path: |
artifacts/*.zip
artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error
retention-days: 14
compression-level: 0
+10 -4
View File
@@ -22,13 +22,15 @@ dotnet build OpenNest.sln
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.Desktop.Tests/OpenNest.Desktop.Tests.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
# Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
```
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
@@ -38,8 +40,9 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
- `OpenNest.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
## Geometry and ownership safeguards
@@ -48,15 +51,18 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
- `Plate.Outline`/`NestPlateStock.Outline` (`SheetOutline`) make a sheet irregular; `Size` stays its bounding box. Only Irregular fills it (`PackBoundary` eroded by the largest edge spacing); every other engine and fill strategy must place nothing on it, validation must reject parts outside it or over its obstacles, and automatic cut-offs refuse it. See [irregular stock](docs/irregular-stock.md).
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [Irregular stock](docs/irregular-stock.md): outlined sheets, what honors the outline and what refuses it
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
@@ -70,6 +76,6 @@ Read the relevant contract before changing its behavior:
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
@@ -11,6 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
+34 -2
View File
@@ -132,14 +132,26 @@ static class NestConsole
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|| options.OutlineFile != null || options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
{
Console.Error.WriteLine(
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size, --outline or --template."
);
return 1;
}
if (options.OutlineFile != null && !options.AutoNest)
{
Console.Error.WriteLine("Error: --outline needs --autonest; single-plate fill strategies fill rectangles only.");
return 1;
}
if (options.OutlineFile != null && !File.Exists(options.OutlineFile))
{
Console.Error.WriteLine($"Error: Outline file not found: {options.OutlineFile}");
return 1;
}
if (options.InputFiles.Count == 0)
{
PrintUsage();
@@ -197,6 +209,12 @@ static class NestConsole
ApplyTemplate(plate, options);
ApplyOverrides(plate, options);
if (!options.AutoNest && PlateFillService.Refusal(plate) is { } refusal)
{
Console.Error.WriteLine($"Error: {refusal} Use --autonest.");
return 2;
}
var drawing = ResolveDrawing(nest, options);
if (drawing == null)
@@ -276,6 +294,9 @@ static class NestConsole
if (Size.TryParse(args[++i], out var sz))
o.PlateSize = sz;
break;
case "--outline" when i + 1 < args.Length:
o.OutlineFile = args[++i];
break;
case "--check-overlaps":
o.CheckOverlaps = true;
break;
@@ -526,6 +547,13 @@ static class NestConsole
if (options.PlateSize.HasValue && !hasDxfOnly)
plate.Size = options.PlateSize.Value;
if (options.OutlineFile != null)
{
// The outline is the sheet: its bounds replace any size given.
plate.Outline = SheetOutline.FromGeometry(CadImporter.Import(options.OutlineFile).Entities);
Console.WriteLine($"Outline: {options.OutlineFile} ({plate.Outline})");
}
}
static Drawing ResolveDrawing(Nest nest, Options options)
@@ -996,6 +1024,9 @@ static class NestConsole
--quantity <n> Max parts to place (default: 0 = unlimited)
--spacing <value> Override part spacing
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--outline <dxf> Make the plate an irregular sheet: the file's largest closed contour is
the sheet, other closed contours are obstacles; sets the size. Only
--autonest with the Irregular (or Default) engine nests on it
--output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
@@ -1037,6 +1068,7 @@ static class NestConsole
public int Quantity;
public double? Spacing;
public Size? PlateSize;
public string OutlineFile;
public bool CheckOverlaps;
public bool NoSave;
public bool KeepParts;
+2
View File
@@ -544,6 +544,8 @@ public static class AutomaticCutOffPlanner
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
"Sheet length and width must be positive and finite.", nameof(plate));
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
// Strips are planned across a rectangle's known span; an outlined sheet gets manual cut-offs only.
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", nameof(plate));
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
Nonnegative(plate.EdgeSpacing.Bottom)),
@@ -70,9 +70,11 @@ internal enum SupportEntries
/// <summary>
/// One automatic start candidate: the owned contour choice plus its preference kind and a
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
/// owner; nothing here exposes or mutates the underlying shape.
/// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
/// ranking can count it on both of that corner's sides.
/// </summary>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
{
/// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
if (target != null)
{
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
// sides within tolerance); count how many of them are facing sides.
// sides within tolerance, a fillet's joint within its reach of the corner it
// rounds); count how many of them are facing sides.
var left = p.X - minX;
var right = maxX - p.X;
var bottom = p.Y - minY;
var top = maxY - p.Y;
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
if (facingRight && right <= near) facing++;
if (!facingRight && left <= near) facing++;
if (facingTop && top <= near) facing++;
if (!facingTop && bottom <= near) facing++;
}
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
@@ -41,8 +41,10 @@ public static class ExecutionMotionReader
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
var raised = HeadRaise.RaisedMask(current.Codes);
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
{
var code = current.Codes[codeIndex];
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
@@ -106,7 +108,8 @@ public static class ExecutionMotionReader
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));
moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex],
ReferenceEquals(current, program) ? codeIndex : null));
pos = end;
first = false;
}
@@ -121,11 +121,16 @@ public sealed class LeadMaterialSnapshot
? "A display perimeter must enclose every other contour."
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
// A ring encloses a point only inside the union of its curves' extents (each at least
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
(extent, curve) => extent.Union(curve.Extent))).ToArray();
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
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 (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
{
if (outer >= 0)
throw new ArgumentException("Material perimeter is ambiguous.");
@@ -142,10 +147,11 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("A display perimeter must enclose every other contour.");
if (ringDisplay[outer] && rings.Count == 1)
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
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))
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
var holes = holeRings.Select(i => rings[i]).ToArray();
for (var i = 0; i < holeRings.Length; i++)
for (var j = i + 1; j < holeRings.Length; j++)
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
@@ -294,13 +294,25 @@ public static class LeadPathValidator
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = curve.Contacts(boundary, out var overlap);
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
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 !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
// A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a
// double root at the joint that rounding splits into two contacts a few 1e-8 apart; one
// within JointTolerance of a joint the boundary itself passes through is still the joint.
static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary)
{
var distance = point.DistanceTo(joint);
return distance <= PostVerificationGeometry.Epsilon
|| distance <= JointTolerance && boundary.Contains(joint);
}
}
// As SlideContact's: far below any kerf, far above a tangent root's rounding drift.
private const double JointTolerance = 1e-6;
}
@@ -38,20 +38,31 @@ public sealed class OwnedExecution
public sealed class ExecutionMotion
{
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve)
PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null)
{
Start = start;
End = end;
Rapid = rapid;
Layer = layer;
Curve = curve;
HeadRaised = headRaised;
Code = code;
}
public Vector? Start { get; }
public Vector End { get; }
public bool Rapid { get; }
public LayerType Layer { get; }
/// <summary>A rapid made with the head raised over parts already cut (<see cref="HeadRaise"/>).</summary>
public bool HeadRaised { get; }
/// <summary>
/// The index of the code this motion runs in the read program's own codes; null for a motion
/// inside a sub-program and for the rapid a program that opens with a cut is given.
/// </summary>
public int? Code { get; }
public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code);
}
@@ -602,7 +602,7 @@ public sealed class PreparedContours
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
=> CataloguePoints(shape, token)
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
.ToList();
/// <summary>
@@ -763,12 +763,14 @@ public sealed class PreparedContours
/// <summary>
/// The preferred catalogue points for one contour: for each entity, its convex corner
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
/// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
/// nothing; a single whole circle yields no points at all.
/// </summary>
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CataloguePoints(
Shape shape, CancellationToken token)
{
var found = new List<(int, Vector, AutomaticEntryKind)>();
var found = new List<(int, Vector, AutomaticEntryKind, double)>();
var count = shape.Entities.Count;
if (count == 1 && shape.Entities[0] is Circle)
{
@@ -792,29 +794,65 @@ public sealed class PreparedContours
switch (corner.Kind)
{
case ContourCuttingStrategy.CornerKind.Convex:
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
break;
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Line && Next(i) is Arc:
// A line leaving into an arc: the tangent joint. The reverse travel
// order classifies the same joint from the arc, matched below.
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
found.Add(Joint(i, vertex, (i + 1) % count));
break;
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
// of a plain straight edge are not preferred automatic starts here.
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Arc && Next(i) is Line:
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
found.Add(Joint(i, vertex, i));
break;
}
}
if (entity is Line line)
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
}
return found;
Entity Next(int index) => shape.Entities[(index + 1) % count];
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
FilletReach(shape, arcOrdinal) is { } reach
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
}
/// <summary>
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
/// than a half circle the convex way, and reaching back along each edge no further than the
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
/// </summary>
private static double? FilletReach(Shape shape, int arcOrdinal)
{
var count = shape.Entities.Count;
if (shape.Entities[arcOrdinal] is not Arc arc
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
return null;
var sweep = arc.SweepAngle();
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
return null;
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
return null;
var winding = ContourCuttingStrategy.DetermineWinding(shape);
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
return null;
var reach = arc.Radius * System.Math.Tan(sweep / 2);
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
bool IsSmooth(Vector vertex, Entity entity) =>
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
@@ -54,9 +54,11 @@ public sealed class ReleasedContourState
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
/// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is
/// returned apart: a fixed emission is accepted as given and only reported. The checker
/// state advances exactly as it would without ownership.
/// state advances exactly as it would without ownership. Crossings made by a rapid with the
/// head raised are returned apart as well, as Raised: they are the raise's reason, not a fault.
/// </summary>
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
IReadOnlyList<PostVerificationFinding> Raised) Check(
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
CancellationToken token)
{
@@ -67,8 +69,10 @@ public sealed class ReleasedContourState
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
var fixedFindings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
var raisedFindings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
raisedFindings: raisedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
}
/// <summary>
@@ -97,7 +101,7 @@ public sealed class ReleasedContourState
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
bool proveOpenCuts = false, int contoursBefore = 0)
bool proveOpenCuts = false, int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
{
var current = new CutGroup();
var unfinished = new List<CutGroup>();
@@ -166,16 +170,22 @@ public sealed class ReleasedContourState
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}.")
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
}, owned);
if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
continue;
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
+ $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}.")
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
};
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
if (move.HeadRaised)
raisedFindings?.Add(crossing);
else
Add(crossing, owned);
}
}
else if (move.Layer == LayerType.Leadin)
@@ -867,10 +867,18 @@ namespace OpenNest.CNC.CuttingStrategy
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner.
/// </summary>
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
/// <summary>
/// The corner a contour of <paramref name="winding"/> would make turning from travel
/// direction <paramref name="tangentIn"/> to <paramref name="tangentOut"/>, classified
/// exactly as a shared vertex is (see <see cref="TryClassifyAutomaticStartCorner"/>).
/// </summary>
internal static CornerKind ClassifyTurn(Vector tangentIn, Vector tangentOut, RotationType winding)
{
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
var dot = tangentIn.DotProduct(tangentOut);
var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.CNC
{
/// <summary>
/// A rapid the head must make raised because it passes over parts already cut, which may have
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>. Rapid checks accept
/// a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
/// See docs/cutting-planner.md.
/// </summary>
public static class HeadRaise
{
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
public const string Marker = "HEAD RAISE";
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsMarker(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised. A
/// marker must be followed directly by a rapid; anything else is a malformed program
/// (<see cref="ArgumentException"/>), since a post could raise before the wrong move.
/// </summary>
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
for (var i = 0; i < codes.Count; i++)
{
if (!IsMarker(codes[i]))
continue;
if (i + 1 >= codes.Count || codes[i + 1] is not RapidMove)
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
mask[i + 1] = true;
}
return mask;
}
/// <summary>
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
/// <paramref name="rapids"/>, which must be rapids not marked already.
/// </summary>
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(rapids);
var marked = RaisedMask(program.Codes);
var copy = (Program)program.Clone();
for (var i = copy.Codes.Count - 1; i >= 0; i--)
{
if (!rapids.Contains(i))
continue;
if (copy.Codes[i] is not RapidMove || marked[i])
throw new ArgumentException("Only an unmarked rapid can be marked for a head raise.");
copy.Codes.Insert(i, new Comment(Marker));
}
return copy;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
/// the post's head-raise code, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, string postName)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !IsMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCode)
throw new InvalidOperationException(
$"{name} has rapids that must raise the head over parts already cut, but the {postName} "
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
try
{
RaisedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
+64 -12
View File
@@ -262,7 +262,8 @@ namespace OpenNest
{
var bounds = plate.BoundingBox(includeParts: false);
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
var lockedFarEdge = LockedFarEdge(plate);
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
double lineStart,
lineEnd,
@@ -281,6 +282,13 @@ namespace OpenNest
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
}
// An outlined sheet is cut only where the line crosses material: each crossing is a
// span of its own, started at the material's edge and overtravelled past the far
// one, within the user's limits.
var spans = plate.Outline == null
? new List<(double Start, double End)> { (lineStart, lineEnd) }
: MaterialSpans(plate, cutPosition, lineStart, lineEnd, settings.Overtravel, lockedFarEdge);
var exclusions = new List<(double Start, double End)>();
foreach (var part in plate.Parts)
@@ -325,27 +333,71 @@ namespace OpenNest
}
var segments = new List<(double Start, double End)>();
var current = lineStart;
foreach (var ex in merged)
foreach (var (spanStart, spanEnd) in spans)
{
var clampedStart = System.Math.Max(ex.Start, lineStart);
var clampedEnd = System.Math.Min(ex.End, lineEnd);
var current = spanStart;
if (clampedStart > current)
segments.Add((current, clampedStart));
foreach (var ex in merged)
{
var clampedStart = System.Math.Max(ex.Start, spanStart);
var clampedEnd = System.Math.Min(ex.End, spanEnd);
current = System.Math.Max(current, clampedEnd);
if (clampedStart > current)
segments.Add((current, clampedStart));
current = System.Math.Max(current, clampedEnd);
}
if (current < spanEnd)
segments.Add((current, spanEnd));
}
if (current < lineEnd)
segments.Add((current, lineEnd));
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
return segments;
}
/// <summary>
/// Where the line at <paramref name="cutPosition"/> crosses the sheet's outlined material,
/// in ascending order along the axis and clipped to the line's limits. Each span starts at
/// the material edge and runs <paramref name="overtravel"/> past the far one unless a limit
/// or a head-locked far edge ends it there.
/// </summary>
private List<(double Start, double End)> MaterialSpans(Plate plate, double cutPosition,
double lineStart, double lineEnd, double overtravel, (double Edge, bool High)? lockedFarEdge)
{
var vertical = Axis == CutOffAxis.Vertical;
var crossings = new List<double>();
foreach (var path in plate.Outline.Material(plate.Outline.PlacementOrigin(plate.Quadrant)))
{
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
var (aAcross, aAlong) = vertical ? (a.x, a.y) : (a.y, a.x);
var (bAcross, bAlong) = vertical ? (b.x, b.y) : (b.y, b.x);
// Half-open on the across axis, so a line through a vertex counts one edge.
if (aAcross <= cutPosition == bAcross <= cutPosition)
continue;
crossings.Add(aAlong + (bAlong - aAlong) * (cutPosition - aAcross) / (bAcross - aAcross));
}
}
crossings.Sort();
var spans = new List<(double Start, double End)>();
for (var i = 0; i + 1 < crossings.Count; i += 2)
{
var start = System.Math.Max(crossings[i], lineStart);
var end = crossings[i + 1];
var limited = EndLimit != null && end >= EndLimit.Value
|| lockedFarEdge is var (edge, high) && high && end == edge;
end = System.Math.Min(limited ? end : end + overtravel, lineEnd);
if (end > start)
spans.Add((start, end));
}
return spans;
}
private static readonly List<(double Start, double End)> EmptyExclusions = new();
private List<(double Start, double End)> GetPartExclusions(
+274
View File
@@ -0,0 +1,274 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>The limits one cut-off takes when a <see cref="CutOffEdit"/> is applied.</summary>
public sealed record CutOffLimitChange(CutOff CutOff, double? StartLimit, double? EndLimit);
/// <summary>
/// A worked-out trim, extend or swap: the new limits of each cut-off it changes, or why it was
/// refused. Nothing changes until <see cref="Apply"/>; the caller then regenerates the plate's
/// cut-offs and places their segments in the cutting order.
/// </summary>
public sealed class CutOffEdit
{
private CutOffEdit(IReadOnlyList<CutOffLimitChange> changes, string refusal, string notice)
{
Changes = changes;
Refusal = refusal;
Notice = notice;
}
public IReadOnlyList<CutOffLimitChange> Changes { get; }
/// <summary>Why the edit cannot be made; null when it can.</summary>
public string Refusal { get; }
/// <summary>Something the operator should know once it is made, such as a head-locked overcut turning off.</summary>
public string Notice { get; }
public bool Succeeded => Refusal == null;
internal static CutOffEdit Refused(string reason) => new(System.Array.Empty<CutOffLimitChange>(), reason, null);
internal static CutOffEdit Of(IReadOnlyList<CutOffLimitChange> changes, string notice) => new(changes, null, notice);
public void Apply()
{
if (!Succeeded)
throw new System.InvalidOperationException(Refusal);
foreach (var change in Changes)
{
change.CutOff.StartLimit = change.StartLimit;
change.CutOff.EndLimit = change.EndLimit;
}
}
}
/// <summary>
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
/// start and end limits (see docs/automatic-scrap-cutoffs.md, "Trim and extend"). A trim stops
/// on the crossing line's centre, inside its kerf; an extend runs to the next crossing line
/// beyond the current end, or to the sheet edge, which clears the limit.
/// </summary>
public static class CutOffTrim
{
public const string NotOnPlate = "That cut-off is not on this plate.";
public const string NothingToTrimTo = "No cut-off crosses this line to trim it to; delete it instead.";
public const string MiddlePiece = "Trimming the middle piece would split the line in two; trim an end piece instead.";
public const string TooShort = "Trimming here would leave a line shorter than the minimum segment length.";
public const string AtSheetEdge = "This end already runs to the sheet edge.";
public const string NotAJunction = "These cut-offs do not meet with one running through and the other stopping at it.";
public const string OvercutOff = "The head-locked overcut is off while this line stops short of the far edge.";
public const string OvercutOn = "The head-locked overcut is back on at the far edge.";
/// <summary>
/// Removes the piece of <paramref name="target"/> picked at <paramref name="pick"/>, between
/// the crossing cut-offs on either side of it (or the line's end): the limit on that side
/// moves to the crossing line.
/// </summary>
public static CutOffEdit Trim(Plate plate, CutOff target, Vector pick, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = System.Math.Clamp(Along(target, pick), start, end);
var crossings = Crossings(plate, target)
.Where(c => c > start + Tolerance.Epsilon && c < end - Tolerance.Epsilon)
.ToList();
if (crossings.Count == 0)
return CutOffEdit.Refused(NothingToTrimTo);
var below = crossings.Where(c => c <= at).DefaultIfEmpty(double.NaN).Max();
var above = crossings.Where(c => c > at).DefaultIfEmpty(double.NaN).Min();
if (!double.IsNaN(below) && !double.IsNaN(above))
return CutOffEdit.Refused(MiddlePiece);
var (newStart, newEnd) = double.IsNaN(above)
? (target.StartLimit, (double?)below)
: ((double?)above, target.EndLimit);
var remaining = double.IsNaN(above) ? below - start : end - above;
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Refused(TooShort);
return Single(plate, target, newStart, newEnd);
}
/// <summary>
/// Lengthens the end of <paramref name="target"/> nearer <paramref name="pick"/> to the next
/// crossing cut-off beyond it, or to the sheet edge when there is none. A cut-off sitting at
/// the current end is skipped, so picking again extends one more step.
/// </summary>
public static CutOffEdit Extend(Plate plate, CutOff target, Vector pick)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = Along(target, pick);
var high = System.Math.Abs(at - end) <= System.Math.Abs(at - start);
return ExtendEnd(plate, target, high, out var limit)
? Single(plate, target, high ? target.StartLimit : limit, high ? limit : target.EndLimit)
: CutOffEdit.Refused(AtSheetEdge);
}
/// <summary>
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
/// extends past it (as <see cref="Extend"/> does), and the one that ran through is trimmed
/// there, losing the side away from the plate's origin. Only the origin-side sheet edge is
/// certain, so the trimmed line keeps starting there.
/// </summary>
public static CutOffEdit Swap(Plate plate, CutOff first, CutOff second, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(first) || !plate.CutOffs.Contains(second))
return CutOffEdit.Refused(NotOnPlate);
if (Junction(plate, first, second) is not var (through, stopper, point))
return CutOffEdit.Refused(NotAJunction);
var (start, end) = NominalSpan(plate, through);
var farHigh = CutOff.FarEdgeIsHigh(through.Axis, plate.Quadrant);
var remaining = farHigh ? point - start : end - point;
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Refused(TooShort);
var throughChange = farHigh
? new CutOffLimitChange(through, through.StartLimit, point)
: new CutOffLimitChange(through, point, through.EndLimit);
var (stopStart, stopEnd) = NominalSpan(plate, stopper);
var stopperPoint = Line(through);
var high = System.Math.Abs(stopperPoint - stopEnd) <= System.Math.Abs(stopperPoint - stopStart);
if (!ExtendEnd(plate, stopper, high, out var limit))
return CutOffEdit.Refused(NotAJunction);
var stopperChange = high
? new CutOffLimitChange(stopper, stopper.StartLimit, limit)
: new CutOffLimitChange(stopper, limit, stopper.EndLimit);
var notices = new[] { Notice(plate, throughChange), Notice(plate, stopperChange) }
.Where(n => n != null).Distinct().ToList();
return CutOffEdit.Of(new[] { stopperChange, throughChange },
notices.Count == 0 ? null : string.Join(" ", notices));
}
/// <summary>
/// The two cut-offs meeting within <paramref name="tolerance"/> of <paramref name="point"/>,
/// one running through the junction and the other stopping at it; null when there is none.
/// </summary>
public static (CutOff Through, CutOff Stopper)? JunctionAt(Plate plate, Vector point, double tolerance)
{
if (plate?.CutOffs == null)
return null;
(CutOff, CutOff)? best = null;
var bestDistance = double.MaxValue;
foreach (var vertical in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical))
foreach (var horizontal in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Horizontal))
{
if (Junction(plate, vertical, horizontal) is not var (through, stopper, _))
continue;
var distance = new Vector(vertical.Position.X, horizontal.Position.Y).DistanceTo(point);
if (distance <= tolerance && distance < bestDistance)
{
best = (through, stopper);
bestDistance = distance;
}
}
return best;
}
/// <summary>
/// Where <paramref name="cutOff"/>'s line runs along its axis: its limits, or the sheet
/// edges where it has none. Overtravel is not part of the nominal line.
/// </summary>
public static (double Start, double End) NominalSpan(Plate plate, CutOff cutOff)
{
var sheet = plate.BoundingBox(includeParts: false);
var vertical = cutOff.Axis == CutOffAxis.Vertical;
return (cutOff.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
cutOff.EndLimit ?? (vertical ? sheet.Top : sheet.Right));
}
private static (CutOff Through, CutOff Stopper, double Point)? Junction(Plate plate, CutOff first, CutOff second)
{
if (first == second || first.Axis == second.Axis)
return null;
var firstAt = Line(second);
var secondAt = Line(first);
var (firstStart, firstEnd) = NominalSpan(plate, first);
var (secondStart, secondEnd) = NominalSpan(plate, second);
if (!Within(firstAt, firstStart, firstEnd) || !Within(secondAt, secondStart, secondEnd))
return null;
var firstStops = AtEnd(firstAt, firstStart, firstEnd);
var secondStops = AtEnd(secondAt, secondStart, secondEnd);
if (firstStops == secondStops)
return null;
return firstStops ? (second, first, secondAt) : (first, second, firstAt);
}
// The limit an end takes when extended: the next crossing line beyond it, or null for the
// sheet edge. False when that end already runs to the sheet edge.
private static bool ExtendEnd(Plate plate, CutOff target, bool high, out double? limit)
{
limit = null;
if ((high ? target.EndLimit : target.StartLimit) == null)
return false;
var (start, end) = NominalSpan(plate, target);
var crossings = Crossings(plate, target);
if (high)
{
var next = crossings.Where(c => c > end + Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Min();
limit = double.IsNaN(next) ? null : next;
}
else
{
var next = crossings.Where(c => c < start - Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Max();
limit = double.IsNaN(next) ? null : next;
}
return true;
}
// Where the perpendicular cut-offs whose lines reach this one cross it, along its axis.
private static List<double> Crossings(Plate plate, CutOff target)
{
var line = Line(target);
return plate.CutOffs
.Where(other => other != target && other.Axis != target.Axis)
.Where(other =>
{
var (start, end) = NominalSpan(plate, other);
return Within(line, start, end);
})
.Select(Line)
.ToList();
}
private static CutOffEdit Single(Plate plate, CutOff target, double? start, double? end)
{
var change = new CutOffLimitChange(target, start, end);
return CutOffEdit.Of(new[] { change }, Notice(plate, change));
}
private static string Notice(Plate plate, CutOffLimitChange change)
{
var cutOff = change.CutOff;
if (!(cutOff.HeadLockOvercut > 0))
return null;
var high = CutOff.FarEdgeIsHigh(cutOff.Axis, plate.Quadrant);
var before = (high ? cutOff.EndLimit : cutOff.StartLimit) == null;
var after = (high ? change.EndLimit : change.StartLimit) == null;
return before == after ? null : after ? OvercutOn : OvercutOff;
}
// The cut-off's own coordinate across its axis: X for a vertical line, Y for a horizontal one.
private static double Line(CutOff cutOff) =>
cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y;
private static double Along(CutOff cutOff, Vector point) =>
cutOff.Axis == CutOffAxis.Vertical ? point.Y : point.X;
private static bool Within(double value, double start, double end) =>
value >= start - Tolerance.Epsilon && value <= end + Tolerance.Epsilon;
private static bool AtEnd(double value, double start, double end) =>
System.Math.Abs(value - start) <= Tolerance.Epsilon || System.Math.Abs(value - end) <= Tolerance.Epsilon;
}
}
+74 -7
View File
@@ -31,11 +31,17 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
}
// Sampling can hide a crossing or tangency between curves. Reject native
// contour contact before asking the polygon approximation about containment.
// A contact lies within both entities' bounds, so shapes whose bounds are clear by more
// than any native query's allowance cannot touch: a part with thousands of holes does
// not query every pair.
var boxes = shapes.Select(shape => shape.BoundingBox).ToList();
for (var i = 0; i < shapes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
for (var j = 0; j < i; j++)
{
if (ClearOfNativeQueries(boxes[i], boxes[j]))
continue;
shapes[i].Intersects(shapes[j], out var intersections);
if (intersections.Count > 0)
throw new ArgumentException("Native material contours cross or touch.");
@@ -44,23 +50,70 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
var outer = ordered[0];
var holes = ordered.Skip(1).ToList();
var outerBounds = BoundsOf(outer);
var holeBounds = holes.Select(BoundsOf).ToList();
for (var i = 0; i < holes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
if (BoundariesTouch(outer, holes[i], cancellationToken)
|| !Inside(outer, holes[i].Vertices[0]))
if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken)
|| !Inside(outer, outerBounds, holes[i].Vertices[0]))
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
for (var j = 0; j < i; j++)
{
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|| Inside(holes[i], holes[j].Vertices[0])
|| Inside(holes[j], holes[i].Vertices[0]))
// Exact polygon tests: holes whose vertex bounds are apart can neither touch
// nor contain each other's vertices.
if (!Overlap(holeBounds[i], holeBounds[j]))
continue;
if (BoundariesTouch(holes[i], holeBounds[i], holes[j], holeBounds[j], cancellationToken)
|| Inside(holes[i], holeBounds[i], holes[j].Vertices[0])
|| Inside(holes[j], holeBounds[j], holes[i].Vertices[0]))
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
}
}
return new OverlapMaterial(outer, holes);
}
/// <summary>
/// The gap beyond which no native intersection query counts two entities as touching: ten
/// times the 0.00001 bounding-box allowance of native intersections, as the planner's
/// <c>PostVerificationGeometry.Extent.ClearMargin</c>.
/// </summary>
private const double NativeClearMargin = 1e-3;
// True only when both boxes are finite and apart by more than the native allowance on some axis.
private static bool ClearOfNativeQueries(Box a, Box b) =>
IsFinite(a) && IsFinite(b)
&& (a.Right + NativeClearMargin < b.Left || b.Right + NativeClearMargin < a.Left
|| a.Top + NativeClearMargin < b.Bottom || b.Top + NativeClearMargin < a.Bottom);
private static bool IsFinite(Box box) => double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top);
/// <summary>Exact vertex bounds of a sampled polygon, for skipping segment and winding tests.</summary>
private readonly record struct Bounds(double MinX, double MinY, double MaxX, double MaxY)
{
internal bool Contains(Vector point) =>
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
}
private static Bounds BoundsOf(Polygon polygon)
{
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
foreach (var vertex in polygon.Vertices)
{
minX = System.Math.Min(minX, vertex.X);
minY = System.Math.Min(minY, vertex.Y);
maxX = System.Math.Max(maxX, vertex.X);
maxY = System.Math.Max(maxY, vertex.Y);
}
return new(minX, minY, maxX, maxY);
}
// Closed-interval overlap: two segments that touch share a point inside both bounds.
private static bool Overlap(Bounds a, Bounds b) =>
a.MaxX >= b.MinX && b.MaxX >= a.MinX && a.MaxY >= b.MinY && b.MaxY >= a.MinY;
internal OverlapMaterial Transform(double rotation, Vector offset) =>
new(TransformPolygon(Outer, rotation, offset),
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
@@ -171,13 +224,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
throw new ArgumentException("Material contour could not be completely triangulated.");
}
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
// Exact: a segment of a whose bounds are clear of b's bounds touches no segment of b.
private static bool BoundariesTouch(Polygon a, Bounds aBounds, Polygon b, Bounds bBounds,
CancellationToken cancellationToken)
{
if (!Overlap(aBounds, bBounds))
return false;
for (var i = 0; i + 1 < a.Vertices.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var start = a.Vertices[i];
var end = a.Vertices[i + 1];
var segment = new Bounds(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));
if (!Overlap(segment, bBounds))
continue;
for (var j = 0; j + 1 < b.Vertices.Count; j++)
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1]))
return true;
}
return false;
@@ -200,6 +263,10 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
// Boundary contact is rejected before this winding-number test is used for topology.
// A point outside the polygon's vertex bounds has winding zero.
private static bool Inside(Polygon polygon, Bounds bounds, Vector point) =>
bounds.Contains(point) && Inside(polygon, point);
private static bool Inside(Polygon polygon, Vector point)
{
var winding = 0;
@@ -68,6 +68,10 @@ internal static class PostVerificationGeometry
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
}
/// <summary>True when <paramref name="point"/> lies within the extent, its edges included.</summary>
internal bool Contains(Vector point) =>
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
@@ -397,9 +401,9 @@ internal static class PostVerificationGeometry
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) && nativeContactCount == 0 && ExactLineContact(this, other))
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other, points))
throw new NotSupportedException("Native contact query is numerically uncertain.");
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this, points))
throw new NotSupportedException("Native contact query is numerically uncertain.");
return points;
@@ -415,16 +419,35 @@ internal static class PostVerificationGeometry
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
/// native query missed, because rounding can drop the tangent root of its quadratic.
/// For the same reason a ray hit within <see cref="RaySeamTolerance"/> of a
/// <paramref name="recorded"/> contact is that contact: an arc lead ending tangent on a
/// straight edge, mid-edge, has its end recorded and its double root dropped.
/// </summary>
private static bool ExactLineContact(Curve line, Curve curve)
private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList<Vector> recorded)
{
var direction = (line.End - line.Start) * (1 / line.Length);
var startRecorded = curve.Contains(line.Start);
var endRecorded = curve.Contains(line.End);
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward))
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
return (forward > 0 && Unrecorded(line.Start, direction, forward))
|| (backward > 0 && Unrecorded(line.End, direction * -1, backward));
bool Unrecorded(Vector origin, Vector ray, double length)
{
var hits = new List<double>();
curve.ContactAfterStart(origin, ray, length, hits);
foreach (var distance in hits)
{
var hit = origin + ray * distance;
var known = false;
foreach (var point in recorded)
known |= point.DistanceTo(hit) <= RaySeamTolerance;
if (!known)
return true;
}
return false;
}
}
+6 -1
View File
@@ -41,6 +41,11 @@ namespace OpenNest.Geometry
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
internal const double JointTolerance = 1e-6;
// Loops are recovered with ShapeBuilder's chaining tolerance: a source contour that
// closes within it (PEP rounding leaves 1e-6 gaps) is a loop everywhere else, and an
// unrecovered loop leaves every contact with the whole part unresolved and blocking.
internal const double ChainTolerance = Tolerance.ChainTolerance;
private readonly List<Entity> entities;
private readonly int[] loopOf;
private readonly int[] previous;
@@ -373,7 +378,7 @@ namespace OpenNest.Geometry
}
}
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
private static double SignedArea(List<Entity> entities, int first, int last)
{
+120 -32
View File
@@ -98,6 +98,9 @@ namespace OpenNest
// A turned sheet is the same sheet; any other size is no longer the coded item.
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
stockItemCodes = Array.Empty<string>();
// An outline is a particular sheet; any other size is a rectangle again.
if (outline != null && (outline.Size.Width != value.Width || outline.Size.Length != value.Length))
outline = null;
size = value;
ResolveClampRail();
}
@@ -105,6 +108,26 @@ namespace OpenNest
private Size size;
/// <summary>
/// The sheet's material when it is not a rectangle (see <see cref="SheetOutline"/>), in
/// plate coordinates; null for a rectangular sheet. Setting one makes <see cref="Size"/> its
/// bounding box, and setting any other size drops it. Only the Irregular whole-job engine
/// nests on an outlined sheet; cut-offs are trimmed to it, automatic cut-offs refuse it, and
/// <see cref="Area"/> is its material area.
/// </summary>
public SheetOutline Outline
{
get { return outline; }
set
{
outline = value;
if (value != null)
Size = value.Size;
}
}
private SheetOutline outline;
private static bool SameSheet(Size a, Size b) =>
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
@@ -303,11 +326,13 @@ namespace OpenNest
public void RegenerateCutOffs(CutOffSettings settings)
{
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
var places = new List<int>();
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (!Parts[i].BaseDrawing.IsCutOff)
continue;
places.Add(i);
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
if (cutoff != null)
{
@@ -343,7 +368,7 @@ namespace OpenNest
indices[k] = previous = best >= 0 ? best : previous;
}
return indices;
});
}, _ => places);
}
/// <summary>
@@ -353,38 +378,78 @@ namespace OpenNest
/// from it are added at the end. Callers must remove existing cut-off parts first.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) =>
RegenerateCutOffs(settings, (cutoff, extents) =>
sequence != null && sequence.TryGetValue(cutoff, out var index)
? Enumerable.Range(index, extents.Count).ToArray() : null);
RegenerateCutOffs(settings, null, sequence);
/// <summary>
/// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>,
/// but each cut-off lists one index per segment in ascending order along its axis. A list
/// whose length differs from the regenerated segment count places the segments
/// consecutively from its smallest index.
/// consecutively at its smallest index.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
RegenerateCutOffs(settings, sequence, null);
/// <summary>
/// Places each cut-off's regenerated segments where its saved segments were, among the
/// parts saved around them: <paramref name="places"/> lists every saved segment's place;
/// <paramref name="firstPlaces"/> gives only the first, for files that recorded no more
/// (their segments were consecutive, but how many is unknown). Settings other than the
/// saved ones can regenerate a different number of segments; the parts keep their
/// places relative to the cut-offs either way. Where only a first place is known the
/// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> places,
IReadOnlyDictionary<CutOff, int> firstPlaces) =>
RegenerateCutOffs(settings, (cutoff, extents) =>
{
if (sequence == null || !sequence.TryGetValue(cutoff, out var indices) || indices == null || indices.Count == 0)
return null;
if (indices.Count != extents.Count)
return Enumerable.Range(indices.Min(), extents.Count).ToArray();
// Program order can run toward the origin; the file lists segments ascending.
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
var result = new int[extents.Count];
for (var rank = 0; rank < ascending.Length; rank++)
result[ascending[rank]] = indices[rank];
return result;
});
if (places != null && places.TryGetValue(cutoff, out var indices) && indices is { Count: > 0 })
{
if (indices.Count != extents.Count)
return Enumerable.Repeat(indices.Min(), extents.Count).ToArray();
// Program order can run toward the origin; the file lists segments ascending.
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
var result = new int[extents.Count];
for (var rank = 0; rank < ascending.Length; rank++)
result[ascending[rank]] = indices[rank];
return result;
}
return firstPlaces != null && firstPlaces.TryGetValue(cutoff, out var first)
? Enumerable.Repeat(first, extents.Count).ToArray() : null;
}, counts =>
{
var saved = new HashSet<int>();
foreach (var list in places?.Values ?? [])
saved.UnionWith(list ?? []);
if (firstPlaces == null)
return saved;
// A cut-off saved by its first place alone held consecutive places, as many as it
// has segments now unless that runs into the next cut-off's first place.
var starts = saved.Concat(firstPlaces.Values).Distinct().Order().ToArray();
foreach (var (cutoff, first) in firstPlaces)
{
var next = starts.FirstOrDefault(start => start > first, int.MaxValue);
var count = System.Math.Max(1, System.Math.Min(counts.GetValueOrDefault(cutoff, 1), next - first));
saved.UnionWith(Enumerable.Range(first, count));
}
return saved;
}, cutoff => firstPlaces != null && firstPlaces.ContainsKey(cutoff));
// sequence gives a cut-off's segment indices in program order from its segments' extents
// along its axis (NaN when unmeasurable), or null to append them.
// sequence gives a cut-off's saved place for each segment in program order, from its
// segments' extents along its axis (NaN when unmeasurable), or null to append them.
// savedPlaces gives every place cut-off segments held in the saved order, from each
// cut-off's regenerated segment count; the parts held the others, in their order.
// separateLast says which cut-offs go after everything when they split the sheet.
private void RegenerateCutOffs(CutOffSettings settings,
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence)
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence,
Func<IReadOnlyDictionary<CutOff, int>, IEnumerable<int>> savedPlaces,
Func<CutOff, bool> separateLast = null)
{
var cache = BuildPerimeterCache(this);
var sheet = BoundingBox(includeParts: false);
var placed = new List<(int Index, int Order, Part Part)>();
var separating = new List<Part>();
var counts = new Dictionary<CutOff, int>();
var partCount = Parts.Count;
// Regenerate and materialize each cut-off's segments
foreach (var cutoff in CutOffs)
@@ -399,20 +464,34 @@ namespace OpenNest
var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray();
var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray();
var indices = sequence?.Invoke(cutoff, extents);
counts[cutoff] = parts.Length;
for (var k = 0; k < parts.Length; k++)
{
if (indices != null && k < indices.Count)
// A line splitting the sheet comes after every part and every other cut-off.
if (separateLast?.Invoke(cutoff) == true && CutOff.SeparatesSheet(parts[k], cutoff, sheet))
separating.Add(parts[k]);
else if (indices != null && k < indices.Count)
placed.Add((indices[k], placed.Count, parts[k]));
else
Parts.Add(parts[k]);
}
}
// Lowest index first: each insert then lands on its saved index, because every part
// sequenced before it is already in place. Segments sharing an index keep their
// program order (a later insert at the same index lands before an earlier one).
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenByDescending(p => p.Order))
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
// Saved places count every cut-off segment then on the plate, which may now be more or
// fewer. A segment goes after the parts saved before it and the segments placed before
// it, so a changed count never shifts a later cut-off or part. Segments sharing a place
// keep their program order.
var saved = (savedPlaces?.Invoke(counts) ?? []).Distinct().Order().ToArray();
var inserted = 0;
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenBy(p => p.Order))
{
var savedBefore = Array.BinarySearch(saved, index);
savedBefore = savedBefore < 0 ? ~savedBefore : savedBefore;
var partsBefore = System.Math.Clamp(index - savedBefore, 0, partCount);
Parts.Insert(partsBefore + inserted++, part);
}
foreach (var part in separating)
Parts.Add(part);
}
/// <summary>
@@ -456,10 +535,13 @@ namespace OpenNest
// Leads are cutting paths in scrap, not boundary edges. Chaining them
// into the outline can make a closed part appear open and replace its
// recesses with a convex hull. Keep the full geometry for the existing
// conservative fallback when the material contour really is open.
// recesses with a convex hull. A cut-off may cross a lead, so the open-
// contour hull also leaves them out: hulling a lead's pierce point would
// stop cut-offs far short of the part.
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
e.Layer != SpecialLayers.Leadout).ToList();
if (outline.Count == 0)
return null;
var profile = new Geometry.ShapeProfile(outline);
if (profile.Perimeter.IsClosed())
@@ -469,7 +551,7 @@ namespace OpenNest
return perimeter;
}
var points = entities.CollectPoints();
var points = outline.CollectPoints();
if (points.Count < 3)
return null;
@@ -526,7 +608,9 @@ namespace OpenNest
{
const double oneAndHalfPI = System.Math.PI * 1.5;
var turned = Outline?.Rotated90(rotationDirection);
Size = new Size(Size.Length, Size.Width);
Outline = turned;
// After Size swap above, new Size.Width = old Length (old X extent),
// new Size.Length = old Width (old Y extent).
@@ -632,10 +716,12 @@ namespace OpenNest
}
Rotate(System.Math.PI, centerpt);
Outline = Outline?.Rotated180();
}
else
{
Rotate(System.Math.PI);
Outline = Outline?.Rotated180();
Quadrant = (Quadrant + 2) % 4;
if (Quadrant == 0)
@@ -841,7 +927,8 @@ namespace OpenNest
/// </example>
public void AutoSize(double roundingFactor = 1.0)
{
if (Parts.Count == 0)
// An outlined sheet is a particular piece of material; it cannot be resized to fit.
if (Parts.Count == 0 || Outline != null)
return;
var bounds = Parts.GetBoundingBox();
@@ -890,7 +977,7 @@ namespace OpenNest
/// </summary>
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
{
if (Parts.Count == 0)
if (Parts.Count == 0 || Outline != null)
return default;
var bounds = Parts.GetBoundingBox();
@@ -942,12 +1029,13 @@ namespace OpenNest
}
/// <summary>
/// Gets the area of the top surface of the plate.
/// Gets the area of the top surface of the plate: the material inside its
/// <see cref="Outline"/> when it has one, otherwise the whole rectangle.
/// </summary>
/// <returns></returns>
public double Area()
{
return Size.Width * Size.Length;
return Outline?.Area ?? Size.Width * Size.Length;
}
/// <summary>
+1
View File
@@ -133,6 +133,7 @@ namespace OpenNest
var followsDefaults = trailing != null
&& trailing.Parts.Count == 0
&& trailing.CutOffs.Count == 0
&& trailing.Outline == null
&& trailing.Size.Width == defaults.Size.Width
&& trailing.Size.Length == defaults.Size.Length
&& trailing.Quadrant == defaults.Quadrant
+5 -1
View File
@@ -38,10 +38,14 @@ public sealed class PlateRemnant
/// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
/// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
/// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks.
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks. A plate
/// with an <see cref="Plate.Outline"/> reports none.
/// </summary>
public static PlateRemnant Expected(Plate plate, double minimumDimension)
{
// No rectangular strip is guaranteed inside an outlined sheet.
if (plate.Outline != null)
return null;
// A part with no material (marks only) bounds nothing.
var bounds = plate.Parts
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
+293
View File
@@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// The material of a sheet that is not a rectangle: a remnant, a drop, or a sheet with a
/// burned or damaged area. A closed polygon in plate coordinates (the sheet's lower-left
/// frame, as <see cref="Plate.ClampZones"/>) with optional obstacle polygons for areas inside
/// it that must stay empty. The polygon lies inside the true material and every obstacle
/// polygon contains its true area, so chording never adds room. Its bounding box starts at
/// the origin and is the sheet's <see cref="Size"/>; rectangular code paths keep using that
/// box. Immutable.
/// </summary>
public sealed class SheetOutline : IEquatable<SheetOutline>
{
/// <summary>Chord deviation when importing arcs; the outline is also eroded, and each
/// obstacle inflated, by this much so the stored polygons never reach past the true edges.</summary>
public const double ChordTolerance = SpacingOutline.ChordTolerance;
private const int Precision = ClipperBridge.Precision;
private const double ArcTolerance = 0.001;
private const double MiterLimit = 2.0;
private readonly PathD outline;
private readonly PathsD obstacles;
private readonly PathsD material;
/// <summary>
/// Builds an outline from its vertices and optional obstacles, each a simple polygon of at
/// least three finite vertices with a positive area. Winding is normalized and the whole
/// is translated so the outline's bounds start at the origin; an obstacle wholly outside
/// the outline is dropped.
/// </summary>
/// <exception cref="ArgumentException">A polygon is missing, degenerate, nonfinite or
/// self-intersecting.</exception>
public SheetOutline(IEnumerable<Vector> points, IEnumerable<IEnumerable<Vector>> obstacles = null)
{
ArgumentNullException.ThrowIfNull(points);
var path = SimplePolygon(points, "outline");
var box = Clipper.GetBounds(path);
var shift = new PointD(-box.left, -box.top); // RectD top is the minimum Y.
outline = Clipper.TranslatePath(path, shift.x, shift.y);
Size = new Size(box.Height, box.Width);
this.obstacles = new PathsD();
if (obstacles != null)
{
var index = 0;
foreach (var obstacle in obstacles)
{
index++;
var obstaclePath = Clipper.TranslatePath(SimplePolygon(obstacle, $"obstacle {index}"), shift.x, shift.y);
var inside = Clipper.Intersect(new PathsD { outline }, new PathsD { obstaclePath }, FillRule.NonZero, Precision);
if (inside.Sum(Clipper.Area) > 0)
this.obstacles.Add(obstaclePath);
}
}
material = Clipper.Difference(new PathsD { outline }, this.obstacles, FillRule.NonZero, Precision);
Area = material.Sum(Clipper.Area);
if (!(Area > 0))
throw new ArgumentException("The obstacles leave no material inside the outline.", nameof(obstacles));
Points = outline.Select(p => new Vector(p.x, p.y)).ToArray();
Obstacles = this.obstacles
.Select(o => (IReadOnlyList<Vector>)o.Select(p => new Vector(p.x, p.y)).ToArray())
.ToArray();
}
/// <summary>Vertices of the outline, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<Vector> Points { get; }
/// <summary>Vertices of each obstacle, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Obstacles { get; }
/// <summary>The bounding box of the outline: the plate's size.</summary>
public Size Size { get; }
/// <summary>Material area: the outline less its obstacles.</summary>
public double Area { get; }
/// <summary>
/// Where the plate's lower-left corner lies in the placement frame of its parts for
/// <paramref name="quadrant"/>: -Length on X in quadrants 2 and 3, -Width on Y in 3 and 4,
/// exactly as <see cref="ClampZoneFrame"/> and the engine stock translate.
/// </summary>
public Vector PlacementOrigin(int quadrant) =>
new(quadrant is 2 or 3 ? -Size.Length : 0, quadrant is 3 or 4 ? -Size.Width : 0);
/// <summary>
/// The outline less its obstacles, translated by <paramref name="origin"/>: outer paths
/// positive, holes negative.
/// </summary>
public PathsD Material(Vector origin) => Translate(material, origin);
/// <summary>
/// The material eroded by <paramref name="inset"/> (round joins) and translated by
/// <paramref name="origin"/>: where a part may lie when it must keep <paramref name="inset"/>
/// from every edge and obstacle. Empty when nothing is left. A nonpositive inset returns
/// the material itself.
/// </summary>
public PathsD Usable(double inset, Vector origin)
{
if (!(inset > 0))
return Material(origin);
var eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
return Translate(eroded, origin);
}
/// <summary>
/// Whether a region lies inside the material eroded by <paramref name="inset"/>, allowing
/// <paramref name="slack"/> outside it: nothing of <paramref name="region"/> (outer paths
/// positive, holes negative, in the frame of <paramref name="origin"/>) is left after
/// subtracting the usable area grown by the slack. The test is exact on the stored polygons;
/// it never loosens for arcs.
/// </summary>
public bool Holds(PathsD region, double inset, Vector origin, double slack)
{
var usable = Usable(inset, origin);
if (slack > 0)
{
usable = Clipper.InflatePaths(usable, slack, JoinType.Miter, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
}
var outside = Clipper.Difference(region, usable, FillRule.NonZero, Precision);
return outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0;
}
/// <summary>This outline turned a quarter turn about the origin, re-normalized to start
/// at the origin, as <see cref="Plate.Rotate90"/> turns the sheet.</summary>
public SheetOutline Rotated90(RotationType direction)
{
Vector Turn(Vector p) => direction == RotationType.CW ? new Vector(p.Y, -p.X) : new Vector(-p.Y, p.X);
return new SheetOutline(Points.Select(Turn), Obstacles.Select(o => o.Select(Turn)));
}
/// <summary>This outline turned half a turn, re-normalized to start at the origin.</summary>
public SheetOutline Rotated180() =>
new(Points.Select(p => new Vector(-p.X, -p.Y)), Obstacles.Select(o => o.Select(p => new Vector(-p.X, -p.Y))));
/// <summary>
/// An outline from imported geometry: the largest closed contour becomes the outline and
/// every other closed contour an obstacle. Arcs are chorded at <see cref="ChordTolerance"/>,
/// then the outline is eroded and each obstacle inflated by that much, so the stored
/// polygons stay within the true edges. Marks and open contours are ignored.
/// </summary>
/// <exception cref="ArgumentException">No closed contour with a positive area.</exception>
public static SheetOutline FromGeometry(IEnumerable<Entity> entities)
{
ArgumentNullException.ThrowIfNull(entities);
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
ShapeProfile profile;
try
{
profile = material.Count == 0 ? null : new ShapeProfile(material);
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
throw new ArgumentException("The geometry has no closed outline.", nameof(entities), exception);
}
if (profile?.Perimeter == null)
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var perimeter = ClipperBridge.ToPath(ClipperBridge.Flatten(profile.Perimeter, ChordTolerance, circumscribe: false), positive: true);
if (perimeter.Count < 3 || !(Clipper.Area(perimeter) > 0))
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var outer = Clipper.InflatePaths(new PathsD { perimeter },
-ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
var outline = outer.Where(p => p.Count >= 3).OrderByDescending(Clipper.Area).FirstOrDefault()
?? throw new ArgumentException("The outline is too thin to hold material.", nameof(entities));
var obstacles = new List<IEnumerable<Vector>>();
foreach (var cutout in profile.Cutouts)
{
var hole = ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, ChordTolerance, circumscribe: false), positive: true);
if (hole.Count < 3 || !(Clipper.Area(hole) > 0))
continue;
var grown = Clipper.InflatePaths(new PathsD { hole },
ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
foreach (var path in grown.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
obstacles.Add(path.Select(p => new Vector(p.x, p.y)).ToList());
}
return new SheetOutline(outline.Select(p => new Vector(p.x, p.y)), obstacles);
}
public bool Equals(SheetOutline other)
{
if (other is null)
return false;
if (ReferenceEquals(this, other))
return true;
if (Points.Count != other.Points.Count || Obstacles.Count != other.Obstacles.Count)
return false;
for (var i = 0; i < Points.Count; i++)
if (!Same(Points[i], other.Points[i]))
return false;
for (var k = 0; k < Obstacles.Count; k++)
{
if (Obstacles[k].Count != other.Obstacles[k].Count)
return false;
for (var i = 0; i < Obstacles[k].Count; i++)
if (!Same(Obstacles[k][i], other.Obstacles[k][i]))
return false;
}
return true;
}
public override bool Equals(object obj) => Equals(obj as SheetOutline);
public override int GetHashCode()
{
var hash = new System.HashCode();
hash.Add(Points.Count);
hash.Add(Obstacles.Count);
foreach (var p in Points)
{
hash.Add(BitConverter.DoubleToInt64Bits(p.X));
hash.Add(BitConverter.DoubleToInt64Bits(p.Y));
}
return hash.ToHashCode();
}
public override string ToString() =>
$"{Size} outline, {Points.Count} vertices" + (Obstacles.Count == 0 ? "" : $", {Obstacles.Count} obstacle(s)");
/// <summary>Exact bits: two outlines are the same stock only when nothing moved.</summary>
private static bool Same(Vector a, Vector b) =>
BitConverter.DoubleToInt64Bits(a.X) == BitConverter.DoubleToInt64Bits(b.X)
&& BitConverter.DoubleToInt64Bits(a.Y) == BitConverter.DoubleToInt64Bits(b.Y);
private static PathsD Translate(PathsD paths, Vector origin) =>
origin.X == 0 && origin.Y == 0
? new PathsD(paths.Select(p => new PathD(p)))
: Clipper.TranslatePaths(paths, origin.X, origin.Y);
/// <summary>A counterclockwise copy without the closing or repeated vertices, after
/// checking it is one simple polygon of positive area.</summary>
private static PathD SimplePolygon(IEnumerable<Vector> points, string what)
{
ArgumentNullException.ThrowIfNull(points, nameof(points));
var path = new PathD();
foreach (var p in points)
{
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y))
throw new ArgumentException($"The {what} has a nonfinite vertex.", nameof(points));
if (path.Count > 0 && path[^1].x == p.X && path[^1].y == p.Y)
continue;
path.Add(new PointD(p.X, p.Y));
}
if (path.Count > 1 && path[0].x == path[^1].x && path[0].y == path[^1].y)
path.RemoveAt(path.Count - 1);
if (path.Count < 3)
throw new ArgumentException($"The {what} needs at least three distinct vertices.", nameof(points));
var area = Clipper.Area(path);
if (!(System.Math.Abs(area) > 0))
throw new ArgumentException($"The {what} has no area.", nameof(points));
if (area < 0)
path.Reverse();
// Start at the lowest-left vertex so equal polygons list their vertices alike.
var start = 0;
for (var i = 1; i < path.Count; i++)
if (path[i].y < path[start].y || path[i].y == path[start].y && path[i].x < path[start].x)
start = i;
if (start != 0)
path = new PathD(path.Skip(start).Concat(path.Take(start)));
// A simple polygon unions to itself: one path of the same area. A figure eight or a
// bow tie does not, and a sheet edge that crosses itself is not a sheet.
var union = Clipper.Union(new PathsD { path }, new PathsD(), FillRule.NonZero, Precision);
var scale = System.Math.Pow(10, -Precision);
if (union.Count != 1 || System.Math.Abs(Clipper.Area(union[0]) - System.Math.Abs(area)) > scale * (path.Count + 1) * Perimeter(path))
throw new ArgumentException($"The {what} must be a simple polygon that does not cross itself.", nameof(points));
return path;
}
private static double Perimeter(PathD path)
{
var length = 0.0;
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
length += System.Math.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
}
return length;
}
}
}
+8
View File
@@ -79,6 +79,14 @@ public interface INestRepository
Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not allocate nest names.");
/// <summary>
/// The ledger item codes on the plates of every open (Draft, Quote or To Be Cut) nest,
/// except <paramref name="excludeNestId"/> when it is not empty.
/// </summary>
Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not list stock item codes.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
+15 -6
View File
@@ -44,7 +44,8 @@ public enum LibraryInsertKind
/// Decides how one library drawing revision enters a nest, before anything changes:
/// a new exact copy carrying its library link, more quantity on the copy already there,
/// or a refusal. Material, grade or thickness that differ from the nest still allow the
/// add, but the drawing's cut pins are dropped (D3). Different units, a different revision
/// add, but the drawing's cut pins are dropped (D3); a blank material or grade, or a zero
/// thickness, on the record suits any nest and is never a difference. Different units, a different revision
/// of the same drawing, or another drawing with the same name refuse it.
/// </summary>
public sealed class LibraryDrawingInsert
@@ -75,7 +76,7 @@ public sealed class LibraryDrawingInsert
/// <summary>
/// For a new copy, true when the library drawing's cut pins come with it: the library
/// material, grade and thickness match the nest's. Otherwise the copy has no pins. An
/// material, grade and thickness match the nest's or are unset. Otherwise the copy has no pins. An
/// added quantity leaves the existing copy as it is.
/// </summary>
public bool KeepPins { get; }
@@ -140,7 +141,12 @@ public sealed class LibraryDrawingInsert
var drawing = package.Drawing;
drawing.Name = record.Name;
drawing.Customer = record.Customer;
drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
// A field the record leaves unset means any material, so the copy takes the nest's.
var nestMaterial = nest.Material ?? new Material();
drawing.Material = new Material(
IsSet(record.MaterialName) ? record.MaterialName : nestMaterial.Name,
IsSet(record.MaterialGrade) ? record.MaterialGrade : nestMaterial.Grade,
drawing.Material?.Density ?? 0);
(record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
drawing.Quantity.Required = quantity;
drawing.Library = link;
@@ -177,21 +183,24 @@ public sealed class LibraryDrawingInsert
private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty<string>(), reason);
// A blank material or grade, or a zero thickness, on the record suits any nest.
private static List<string> Differences(Nest nest, DrawingLibraryRecord record)
{
var differences = new List<string>();
var material = nest.Material ?? new Material();
if (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
if (IsSet(record.MaterialName) && SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})");
if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
if (IsSet(record.MaterialGrade) && SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})");
var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
if (thickness != nestThickness)
if (thickness != 0 && thickness != nestThickness)
differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
return differences;
}
private static bool IsSet(string? value) => SharedListNames.Key(value).Length > 0;
private static string Describe(DrawingLibraryRecord record) =>
string.IsNullOrWhiteSpace(record.Customer)
? $"library drawing '{record.Name}' revision {record.Revision}"
+19
View File
@@ -73,6 +73,25 @@ public static class MachineCuttingDefaults
return true;
}
/// <summary>
/// Whether <paramref name="parameters"/> are stamped with the machine, material, grade and
/// thickness <see cref="ApplyTo(CuttingParameters, MachineConfig?, Nest)"/> would record for
/// the nest: they started from the machine's settings for it, and any lead edits since are
/// the user's choice for that same job, which the machine's settings must not replace.
/// </summary>
public static bool IsRecordedFor(CuttingParameters parameters, MachineConfig? machine, Nest nest)
{
ArgumentNullException.ThrowIfNull(parameters);
ArgumentNullException.ThrowIfNull(nest);
if (Find(machine, nest) == null)
return false;
var tolerance = ThicknessToleranceInches * Scale(Units.Inches, nest.Units);
return string.Equals(parameters.MachineName, machine!.Name, StringComparison.Ordinal)
&& string.Equals(parameters.MaterialName, nest.Material.Name, StringComparison.Ordinal)
&& string.Equals(parameters.Grade ?? "", nest.Material.Grade ?? "", StringComparison.Ordinal)
&& System.Math.Abs(parameters.Thickness - nest.Thickness) <= tolerance;
}
/// <summary>
/// Replaces the part clearance, overtravel, minimum segment and direction of
/// <paramref name="settings"/> with the machine's for the nest; the head-lock choice is kept.
@@ -0,0 +1,95 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>A plate's item code that is no longer the nest's to use, and why.</summary>
/// <param name="PlateNumber">The plate's 1-based position in the nest.</param>
public sealed record LedgerCodeConflict(int PlateNumber, string ItemCode, string Reason);
/// <summary>
/// The save and post re-check of a nest's <see cref="Plate.StockItemCodes"/>: finds codes the
/// ledger no longer has free for this nest or that other open nests are coded with, and takes
/// them off the plates when the programmer chooses to. Pure; the caller fetches the inputs.
/// </summary>
public static class LedgerCodeCheck
{
/// <summary>True when any plate has a coded sheet.</summary>
public static bool HasCodes(IEnumerable<Plate> plates) =>
(plates ?? throw new ArgumentNullException(nameof(plates))).Any(plate => plate.StockItemCodes.Count > 0);
/// <param name="plates">The nest's plates in order.</param>
/// <param name="freeItems">The ledger's free stock for the nest's material, after every hold.</param>
/// <param name="ownHolds">
/// The ledger's holds for this nest: an open one keeps its sheet for this nest, so the
/// sheet counts as available here although the ledger no longer lists it as free.
/// </param>
/// <param name="inUse">Other open nests' codes; an unknown answer checks the ledger alone.</param>
/// <returns>One conflict per plate and code, in plate order then the plate's code order.</returns>
public static IReadOnlyList<LedgerCodeConflict> Find(IList<Plate> plates, IEnumerable<LedgerStockItem> freeItems,
IEnumerable<LedgerHold>? ownHolds, LedgerCodesInUse inUse)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(freeItems);
ArgumentNullException.ThrowIfNull(inUse);
var available = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var item in freeItems.Where(item => item != null && item.Quantity > 0))
available[item.ItemCode] = available.GetValueOrDefault(item.ItemCode) + item.Quantity;
foreach (var hold in (ownHolds ?? Array.Empty<LedgerHold>()).Where(hold => hold != null && LedgerHoldStatuses.IsOpen(hold.Status)))
available[hold.ItemCode] = available.GetValueOrDefault(hold.ItemCode) + 1;
// A bundle's code repeats once per sheet across every plate it is used on.
var needed = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var code in plates.SelectMany(plate => plate.StockItemCodes))
needed[code] = needed.GetValueOrDefault(code) + 1;
var reasons = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (code, count) in needed)
{
var parts = new List<string>();
var free = available.GetValueOrDefault(code);
if (free == 0)
parts.Add("not free in MaterialLedger");
else if (free < count)
parts.Add($"only {free} of the {count} sheets needed free in MaterialLedger");
var nests = inUse.NestsUsing(code);
if (nests.Count > 0)
parts.Add((nests.Count == 1 ? "on nest " : "on nests ") + string.Join(", ", nests));
if (parts.Count > 0)
reasons[code] = Capitalize(string.Join("; ", parts));
}
var conflicts = new List<LedgerCodeConflict>();
for (var i = 0; i < plates.Count; i++)
{
foreach (var code in plates[i].StockItemCodes.Distinct(StringComparer.Ordinal))
{
if (reasons.TryGetValue(code, out var reason))
conflicts.Add(new LedgerCodeConflict(i + 1, code, reason));
}
}
return conflicts;
}
/// <summary>
/// Takes every copy of <paramref name="codes"/> off the plates, keeping the layout and the
/// other codes in order; the sheets left uncoded are matched by size in ML. Returns how
/// many sheets lost their code.
/// </summary>
public static int Remove(IEnumerable<Plate> plates, IEnumerable<string> codes)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(codes);
var remove = codes.ToHashSet(StringComparer.Ordinal);
var removed = 0;
foreach (var plate in plates)
{
var kept = plate.StockItemCodes.Where(code => !remove.Contains(code)).ToList();
if (kept.Count == plate.StockItemCodes.Count)
continue;
removed += plate.StockItemCodes.Count - kept.Count;
plate.StockItemCodes = kept;
}
return removed;
}
private static string Capitalize(string text) => text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text[1..];
}
@@ -0,0 +1,70 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// Takes item codes already on other open nests out of ledger stock rows, so two nests are
/// not planned on the same sheet before ML holds exist for either. Pure: rows are rebuilt,
/// never changed.
/// </summary>
public static class LedgerCodeFilter
{
/// <summary>
/// <paramref name="rows"/> without <paramref name="codesInUse"/>: every copy of a code
/// leaves the row's <see cref="LedgerStockRow.ItemCodes"/> and
/// <see cref="LedgerStockRow.ItemLocations"/>, its quantity drops by the copies removed, and
/// a row left with none is dropped. Row ids and order are kept. <paramref name="removed"/>
/// counts the sheets and drops taken out.
/// </summary>
public static IReadOnlyList<LedgerStockRow> Exclude(IEnumerable<LedgerStockRow> rows,
IEnumerable<string> codesInUse, out int removed)
{
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(codesInUse);
var exclude = codesInUse as IReadOnlySet<string> ?? codesInUse.ToHashSet(StringComparer.Ordinal);
removed = 0;
var kept = new List<LedgerStockRow>();
foreach (var row in rows)
{
if (row == null)
continue;
var codes = row.ItemCodes.Where(code => !exclude.Contains(code)).ToList();
var dropped = row.ItemCodes.Count - codes.Count;
if (dropped == 0)
{
kept.Add(row);
continue;
}
removed += dropped;
if (codes.Count == 0)
continue;
var itemLocations = row.ItemLocations
.Where(pair => !exclude.Contains(pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
// A location stays listed while an item left in the row is there, or when the row
// never said which item it held.
var locations = row.Locations
.Where(location => itemLocations.ContainsValue(location)
|| !row.ItemLocations.Values.Contains(location, StringComparer.Ordinal))
.ToList();
kept.Add(row with
{
Quantity = codes.Count,
ItemCodes = codes,
Locations = locations,
ItemLocations = itemLocations,
});
}
return kept;
}
/// <summary>
/// <paramref name="mapping"/> with <paramref name="codesInUse"/> taken out of its rows and
/// the sheets removed counted in <see cref="LedgerStockMapping.OnOtherNests"/>.
/// </summary>
public static LedgerStockMapping Exclude(LedgerStockMapping mapping, IEnumerable<string> codesInUse)
{
ArgumentNullException.ThrowIfNull(mapping);
var rows = Exclude(mapping.Rows, codesInUse, out var removed);
return mapping with { Rows = rows, OnOtherNests = mapping.OnOtherNests + removed };
}
}
@@ -0,0 +1,78 @@
using System.Net.Http;
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// The ledger item codes other open server nests are coded with, or why they could not be
/// read. ML's free counts drop only once a hold exists, so these codes are what another
/// Auto Nest run must leave out until then. Reading them never blocks: a failure is a result.
/// </summary>
public sealed record LedgerCodesInUse
{
/// <summary>The nest server gets this long to answer, so a stock load or save never waits on it.</summary>
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
private LedgerCodesInUse(IReadOnlyList<NestStockCodeUse> uses, string? unavailable)
{
Uses = uses;
Unavailable = unavailable;
Codes = uses.Select(use => use.ItemCode).ToHashSet(StringComparer.Ordinal);
}
/// <summary>One entry per code and nest; empty when <see cref="Unavailable"/>.</summary>
public IReadOnlyList<NestStockCodeUse> Uses { get; }
/// <summary>The distinct codes of <see cref="Uses"/>.</summary>
public IReadOnlySet<string> Codes { get; }
/// <summary>Why the codes could not be read; null when they were.</summary>
public string? Unavailable { get; }
public bool IsKnown => Unavailable == null;
public static LedgerCodesInUse Known(IEnumerable<NestStockCodeUse> uses) =>
new((uses ?? throw new ArgumentNullException(nameof(uses))).Where(use => use != null).ToList(), null);
public static LedgerCodesInUse Unknown(string reason) =>
new(Array.Empty<NestStockCodeUse>(), string.IsNullOrWhiteSpace(reason) ? "not available" : reason);
/// <summary>The names of the other nests using <paramref name="code"/>, in name order.</summary>
public IReadOnlyList<string> NestsUsing(string code) =>
Uses.Where(use => string.Equals(use.ItemCode, code, StringComparison.Ordinal))
.Select(use => use.NestName)
.Distinct(StringComparer.Ordinal)
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
/// <summary>
/// Reads the codes from <paramref name="repository"/>, leaving out the nest
/// <paramref name="excludeNestId"/> (the one being edited). A null repository is File
/// mode. Only the caller's own cancellation throws.
/// </summary>
public static async Task<LedgerCodesInUse> ReadAsync(INestRepository? repository, Guid excludeNestId,
CancellationToken cancellationToken, TimeSpan? timeout = null)
{
if (repository == null)
return Unknown("nests are saved as files");
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout ?? RequestTimeout);
try
{
return Known(await repository.GetStockCodesInUseAsync(excludeNestId, timeoutSource.Token));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Unknown("the nest server did not answer");
}
catch (NotSupportedException)
{
return Unknown("the nest server needs an update");
}
catch (Exception ex) when (ex is HttpRequestException or IOException or System.Text.Json.JsonException
or ArgumentException or InvalidOperationException)
{
return Unknown("the nest server could not be reached");
}
}
}
@@ -51,7 +51,12 @@ public sealed record LedgerStockMapping(
public int Drops => Rows.Where(r => r.IsDrop).Sum(r => r.Quantity);
public int SubstituteRows => Rows.Count(r => r.IsSubstitute);
/// <summary>E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)".</summary>
/// <summary>Sheets and drops left out because other open nests are coded with them (<see cref="LedgerCodeFilter"/>).</summary>
public int OnOtherNests { get; init; }
/// <summary>
/// E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (3 on other nests) (2 skipped: 2 unknown shape)".
/// </summary>
public string Summary
{
get
@@ -64,6 +69,8 @@ public sealed record LedgerStockMapping(
var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}";
if (SubstituteRows > 0)
text += $", {Plural(SubstituteRows, "substitute row")}";
if (OnOtherNests > 0)
text += $" ({OnOtherNests} on other nests)";
return skipped.Count == 0
? text
: $"{text} ({SkippedUnknownShape + SkippedTooSmall} skipped: {string.Join(", ", skipped)})";
+18
View File
@@ -0,0 +1,18 @@
namespace OpenNest.Data;
/// <summary>
/// A ledger item code an open server nest's plates are coded with, returned by
/// <c>GET /api/nests/stock-item-codes</c>. One entry per code and nest; a bundle code used
/// for several sheets appears once.
/// </summary>
public sealed class NestStockCodeUse
{
public string ItemCode { get; init; } = "";
public Guid NestId { get; init; }
public string NestName { get; init; } = "";
/// <summary>Draft, Quote or To Be Cut: the statuses whose sheets are still to be cut.</summary>
public NestStatus Status { get; init; }
}
+13
View File
@@ -151,6 +151,19 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return allocation.Number;
}
public async Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
CancellationToken cancellationToken = default)
{
var query = "api/nests/stock-item-codes" + (excludeNestId == Guid.Empty ? "" : "?exclude=" + excludeNestId.ToString("D"));
using var response = await _httpClient.GetAsync(Url(query), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotSupportedException("This nest server does not list stock item codes (HTTP 404). Update the nest server.");
await NestServerHttp.EnsureSuccessAsync(response, "list stock item codes", cancellationToken);
var uses = await response.Content.ReadFromJsonAsync<List<NestStockCodeUse>>(NestServerHttp.JsonOptions, cancellationToken);
return uses?.Where(use => use != null && !string.IsNullOrWhiteSpace(use.ItemCode)).ToList()
?? new List<NestStockCodeUse>();
}
public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
@@ -0,0 +1,81 @@
using System.Reflection;
using System.Text.Json;
using System.Xml.Linq;
namespace OpenNest.Desktop.Tests;
/// <summary>
/// OpenNest.Desktop is shared by the WinForms and Avalonia front ends, so it must stay free of
/// any UI toolkit: no WinForms/WindowsDesktop framework, System.Drawing.Common or Avalonia.
/// </summary>
public class DesktopDependencyTests
{
private static readonly string[] ForbiddenPrefixes =
[
"System.Windows.Forms",
"Microsoft.WindowsDesktop",
"WindowsBase",
"PresentationCore",
"PresentationFramework",
"System.Drawing.Common",
"Avalonia",
];
[Fact]
public void DesktopAssembly_ReferencesNoUiToolkit()
{
var assembly = Assembly.Load(new AssemblyName("OpenNest.Desktop"));
var references = assembly.GetReferencedAssemblies().Select(a => a.Name!);
Assert.Empty(references.Where(IsForbidden));
}
[Fact]
public void DesktopProject_TargetsPlainNet8WithoutUiFrameworks()
{
var project = XDocument.Load(Path.Combine(DesktopProjectDirectory(), "OpenNest.Desktop.csproj"));
var elements = project.Descendants().ToList();
var targets = elements
.Where(e => e.Name.LocalName is "TargetFramework" or "TargetFrameworks")
.SelectMany(e => e.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToList();
Assert.Equal(["net8.0"], targets);
Assert.DoesNotContain(elements, e =>
e.Name.LocalName is "UseWindowsForms" or "UseWPF" or "UseWindowsDesktop"
&& e.Value.Trim().Equals("true", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(elements, e => e.Name.LocalName == "FrameworkReference");
var packages = elements
.Where(e => e.Name.LocalName == "PackageReference")
.Select(e => (string?)e.Attribute("Include") ?? string.Empty);
Assert.Empty(packages.Where(IsForbidden));
}
[Fact]
public void DesktopProject_RestoresNoUiToolkitPackagesTransitively()
{
var assetsPath = Path.Combine(DesktopProjectDirectory(), "obj", "project.assets.json");
Assert.True(File.Exists(assetsPath), $"Restore output not found: {assetsPath}");
using var assets = JsonDocument.Parse(File.ReadAllText(assetsPath));
var libraries = assets.RootElement.GetProperty("libraries").EnumerateObject()
.Select(library => library.Name.Split('/')[0]);
Assert.Empty(libraries.Where(IsForbidden));
}
private static bool IsForbidden(string name) =>
ForbiddenPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
private static string DesktopProjectDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "OpenNest.sln")))
directory = directory.Parent;
Assert.NotNull(directory);
return Path.Combine(directory.FullName, "OpenNest.Desktop");
}
}
@@ -0,0 +1,16 @@
<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.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Desktop/OpenNest.Desktop.csproj" />
</ItemGroup>
</Project>
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Toolkit-neutral desktop logic shared by the WinForms and Avalonia front ends.
No UI toolkit: OpenNest.Desktop.Tests guards against WinForms, WindowsDesktop,
System.Drawing.Common and Avalonia references. -->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Desktop</RootNamespace>
<AssemblyName>OpenNest.Desktop</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,334 @@
using System.Globalization;
using System.Text.RegularExpressions;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
/// <summary>The frontier packer as a cutout proposal: inserts settle against the cutout wall
/// and each other instead of landing at the first sampled point that clears.</summary>
[Collection("Cutout pipeline registry")]
public class CutoutPackerProposalTests
{
private const double Spacing = 0.3;
/// <summary>A 13.148 x 1.25 bar with a shallow notch in one long edge and eight bolt holes.</summary>
private const string BarCode = """
G91
G00X0Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X1.875Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X4.625Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X1.875Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X-11.8865Y-1.0625
G01X0Y0.75
G02X0.5Y0.5I0.5J0
G01X3.824Y0
G02X0.25Y-0.25I0J-0.25
G01X0Y-0.125
G03X0.25Y-0.25I0.25J-0
G01X3.5Y0
G03X0.25Y0.25I0J0.25
G01X0Y0.125
G02X0.25Y0.25I0.25J0
G01X3.824Y0
G02X0.5Y-0.5I0J-0.5
G01X0Y-0.75
G01X-13.148Y0
""";
/// <summary>One 12.25-wide window whose near end carries a notched tab, open to the given
/// wall height above the tab; the frame below stacks four such windows.</summary>
private static string Window(double wallHeight) => $"""
G01X0.125Y0
G01X0Y0.841458
G02X0.5Y0.5I0.5J0
G01X0Y-0.3125
G01X1.125Y0
G01X0Y0.3125
G01X1.25Y0
G01X0Y-0.3125
G01X1.125Y0
G01X0Y0.3125
G01X4Y0
G01X0Y-0.3125
G01X1.125Y0
G01X0Y0.3125
G01X1.25Y0
G01X0Y-0.3125
G01X1.125Y0
G01X0Y0.3125
G02X0.5Y-0.5I0J-0.5
G01X0Y-0.841458
G01X0.125Y0
G01X0Y{wallHeight.ToString(CultureInfo.InvariantCulture)}
G03X-0.125Y0.125I-0.125J0
G01X-12Y0
G03X-0.125Y-0.125I0J-0.125
G01X0Y-{wallHeight.ToString(CultureInfo.InvariantCulture)}
""";
/// <summary>A 14.82 x 69.72 frame with a plain 12.25 x 13.4 window and three tabbed windows
/// of rising height; only the last is tall enough to stand the bar in.</summary>
private static readonly string FrameCode = $"""
G91
G00X0Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X1.875Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X-3.9375Y2.033896
G01X12.25Y0
G01X0Y13.297
G03X-0.125Y0.125I-0.125J0
G01X-12Y0
G03X-0.125Y-0.125I0J-0.125
G01X0Y-13.297
G00X8.5625Y-2.033896
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X1.875Y0
G03X0Y0I-0.125J0
G00X0.5Y0
G03X0Y0I-0.125J0
G00X-11.4375Y16.955896
{Window(14.375)}
G00X0Y16
{Window(14.375)}
G00X0Y16
{Window(19.453)}
G00X-1.283896Y-47.797
G01X0Y68.25
G02X0.125Y0.125I0.125J0
G01X14.567791Y0
G02X0.125Y-0.125I0J-0.125
G01X0Y-68.25
G02X-0.125Y-0.125I-0.125J0
G01X-0.2615Y0
G02X-0.125Y0.125I0J0.125
G01X0Y0.051
G01X-0.323396Y0
G01X0Y-0.897396
G02X-0.5Y-0.5I-0.5J-0
G01X-12.148Y0
G02X-0.5Y0.5I0J0.5
G01X0Y0.897396
G01X-0.323396Y0
G01X0Y-0.051
G02X-0.125Y-0.125I-0.125J0
G01X-0.2615Y0
G02X-0.125Y0.125I0J0.125
""";
/// <summary>Height of the tab on a tabbed window's near end.</summary>
private const double TabHeight = 0.841458 + 0.5;
private static Program Bar() => Incremental(BarCode);
private static Program Frame() => Incremental(FrameCode);
[Fact]
public void FrameHasFourWindowsAndTheBarIsSolid()
{
var frame = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
var bar = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
Assert.Equal(4, frame.Cutouts.Count(c => System.Math.Abs(c.Area()) > 100));
Assert.Equal(14.818, frame.Bounds.Length, 3);
Assert.Equal(69.721, frame.Bounds.Width, 3);
Assert.Equal(13.148, bar.Bounds.Length, 3);
Assert.Equal(1.25, bar.Bounds.Width, 3);
}
[Fact]
public void TwoBarsStackAgainstAWindowWallInsteadOfScatteringAcrossIt()
{
var frame = JobBuilder.Part("PT10", Frame(), 1);
var bar = JobBuilder.Part("PT11", Bar(), 2);
var geometry = JobPartGeometry.Read(frame.Geometry);
var seated = new List<(int Hole, IReadOnlyList<NestJobPlacement> Poses)>();
for (var hole = 0; hole < geometry.Cutouts.Count; hole++)
{
if (System.Math.Abs(geometry.Cutouts[hole].Area()) < 100)
continue;
var poses = CutoutRouter.Fill(frame, hole, new[] { bar }, Spacing);
if (poses.Count == 2)
seated.Add((hole, poses));
}
var (window, stacked) = Assert.Single(seated);
AssertClear(frame, bar, stacked, Spacing);
AssertStacked(geometry.Cutouts[window], JobPartGeometry.Read(bar.Geometry), stacked);
}
[Fact]
public void PackerAloneSeatsBothBarsAgainstTheWall()
{
var frame = JobBuilder.Part("PT10", Frame(), 1);
var bar = JobBuilder.Part("PT11", Bar(), 2);
var geometry = JobPartGeometry.Read(frame.Geometry);
var tallest = Enumerable.Range(0, geometry.Cutouts.Count)
.OrderByDescending(i => System.Math.Abs(geometry.Cutouts[i].Area())).First();
var poses = CutoutPackerProposal.Fill(frame, tallest, new[] { bar }, Spacing);
Assert.Equal(2, poses.Count);
Assert.Equal(new[] { 0, 1 }, poses.Select(p => p.InstanceIndex));
AssertClear(frame, bar, poses, Spacing);
AssertStacked(geometry.Cutouts[tallest], JobPartGeometry.Read(bar.Geometry), poses);
}
[Fact]
public void PackerKeepsClearOfInsertsAlreadySeated()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 3, 2, 6);
var geometry = JobPartGeometry.Read(insert.Geometry);
var occupant = new NestJobPlacement("occupant", 0, -1.5, -1, 0);
var poses = CutoutPackerProposal.Fill(frame, 0, new[] { insert }, 0.25,
new[] { (geometry, occupant) }, null, default);
Assert.NotEmpty(poses);
foreach (var pose in poses)
Assert.True(NestLayoutCheck.Clears(geometry, occupant, geometry, pose, 0.25), $"{pose} meets the occupant");
AssertClear(frame, insert, poses, 0.25);
}
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void AutoNestSeatsBothBarsStackedInOneWindow(string engine)
{
var frameDrawing = new Drawing("PT10", Frame());
var barDrawing = new Drawing("PT11", Bar());
var request = new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = barDrawing, Quantity = 2 }, new NestItem { Drawing = frameDrawing, Quantity = 2 } },
new[] { new NestPlateStock("sheet", new Size(60, 120), 1, Spacing) },
new NestJobOptions(maxPlates: 1));
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
var plate = Assert.Single(result.Plates);
var bars = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, barDrawing)).ToArray();
var frames = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, frameDrawing)).ToArray();
Assert.Equal(2, bars.Length);
Assert.Equal(2, frames.Length);
var frameGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
var barGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
// Both bars lie in one window of one frame; express them in that frame's coordinates.
var seated = new List<(Part Frame, Shape Window, IReadOnlyList<NestJobPlacement> Poses)>();
foreach (var host in frames)
{
var local = bars.Select(bar => Local(host, bar)).ToArray();
foreach (var window in frameGeometry.Cutouts.Where(c => System.Math.Abs(c.Area()) > 100))
if (local.All(pose => Inside(window, Extent(barGeometry, pose))))
seated.Add((host, window, local));
}
var (_, hole, poses) = Assert.Single(seated);
AssertStacked(hole, barGeometry, poses);
}
private static NestJobPlacement Local(Part frame, Part part)
{
var dx = part.Location.X - frame.Location.X;
var dy = part.Location.Y - frame.Location.Y;
var cosine = System.Math.Cos(-frame.Rotation);
var sine = System.Math.Sin(-frame.Rotation);
return new NestJobPlacement("PT11", 0, dx * cosine - dy * sine, dx * sine + dy * cosine,
part.Rotation - frame.Rotation);
}
private static bool Inside(Shape window, Box box)
{
var bounds = window.BoundingBox;
return box.Left > bounds.Left && box.Right < bounds.Right && box.Bottom > bounds.Bottom && box.Top < bounds.Top;
}
/// <summary>Both bars stand in the window against one wall and against each other, not in
/// the middle: each touches the wall or the other bar across the window, and the near tab
/// or the far wall along it, within the spacing plus a small tolerance.</summary>
private static void AssertStacked(Shape window, JobPartGeometry bar, IReadOnlyList<NestJobPlacement> poses)
{
const double slack = 0.05;
var hole = window.BoundingBox;
var boxes = poses.Select(p => Extent(bar, p)).ToArray();
Assert.Equal(2, boxes.Length);
foreach (var box in boxes)
{
Assert.True(Inside(window, box), $"{box} is not inside the window {hole}");
var other = boxes.Single(b => !ReferenceEquals(b, box));
var toWall = System.Math.Min(box.Left - hole.Left, hole.Right - box.Right);
var toOther = System.Math.Max(other.Left - box.Right, box.Left - other.Right);
Assert.True(toWall <= Spacing + slack || toOther <= Spacing + slack,
$"{box} floats {toWall:F3} from the walls and {toOther:F3} from the other bar");
var toEnd = System.Math.Min(box.Bottom - hole.Bottom - TabHeight, hole.Top - box.Top);
Assert.True(toEnd <= Spacing + slack, $"{box} floats {toEnd:F3} from the window ends");
}
var gap = System.Math.Max(boxes[1].Left - boxes[0].Right, boxes[0].Left - boxes[1].Right);
Assert.InRange(gap, Spacing - 1e-6, Spacing + slack);
var center = (boxes.Min(b => b.Left) + boxes.Max(b => b.Right)) / 2;
Assert.True(System.Math.Abs(center - (hole.Left + hole.Right) / 2) > 2,
"the pair sits in the middle of the window");
}
private static Box Extent(JobPartGeometry geometry, NestJobPlacement pose)
{
var shape = (Shape)geometry.Perimeter.Clone();
shape.Rotate(pose.Rotation);
var box = shape.BoundingBox;
return new Box(box.Left + pose.X, box.Bottom + pose.Y, box.Length, box.Width);
}
private static void AssertClear(NestJobPart frame, NestJobPart insert, IReadOnlyList<NestJobPlacement> poses,
double spacing)
{
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
var geometry = JobPartGeometry.Read(insert.Geometry);
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var pose in poses)
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, spacing), $"{pose} meets the frame");
for (var i = 0; i < poses.Count; i++)
for (var j = i + 1; j < poses.Count; j++)
Assert.True(NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing),
$"{poses[i]} meets {poses[j]}");
}
/// <summary>Incremental (G91) G-code: rapids, lines and arcs with centers relative to the start.</summary>
private static Program Incremental(string code)
{
var program = new Program();
double x = 0, y = 0;
foreach (var line in code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var words = Regex.Matches(line, @"([A-Z])(-?\d*\.?\d+)")
.ToDictionary(m => m.Groups[1].Value[0], m => double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture));
var g = (int)words['G'];
if (g == 91)
continue;
var end = new Vector(x + words.GetValueOrDefault('X'), y + words.GetValueOrDefault('Y'));
var center = new Vector(x + words.GetValueOrDefault('I'), y + words.GetValueOrDefault('J'));
program.Codes.Add(g switch
{
0 => new RapidMove(end.X, end.Y),
1 => new LinearMove(end.X, end.Y),
2 => new ArcMove(end, center, RotationType.CW),
_ => new ArcMove(end, center, RotationType.CCW),
});
(x, y) = (end.X, end.Y);
}
return program;
}
}
@@ -0,0 +1,89 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
/// <summary>An insert with holes of its own goes into a frame's cutout when none of its holes
/// fits any part of the job (bolt holes in a strap); otherwise it stays independent, since a
/// later insert could land in its hole.</summary>
[Collection("Cutout pipeline registry")]
public class CutoutPipelineHoledInsertTests
{
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public List<NestJob> Jobs { get; } = new();
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
Jobs.Add(job);
return solve(job);
}
}
// A 20 x 20 frame with a 12 x 14 window.
private static Program Frame() => WithSquareHoles(TestShapes.Rectangle(20, 20), (4, 3, 12, 14));
// A 13 x 1.25 strap with two 0.25 bolt holes: it fits the window only upright.
private static Program Strap()
{
var program = TestShapes.Rectangle(13, 1.25);
foreach (var x in new[] { 1.0, 12.0 })
{
program.Codes.Add(new RapidMove(x + 0.125, 0.625));
program.Codes.Add(new ArcMove(x - 0.125, 0.625, x, 0.625, RotationType.CCW));
program.Codes.Add(new ArcMove(x + 0.125, 0.625, x, 0.625, RotationType.CCW));
}
return program;
}
private static Program WithSquareHoles(Program program, params (double X, double Y, double W, double H)[] holes)
{
foreach (var (x, y, w, h) in holes)
{
program.Codes.Add(new RapidMove(x, y));
program.Codes.Add(new LinearMove(x + w, y));
program.Codes.Add(new LinearMove(x + w, y + h));
program.Codes.Add(new LinearMove(x, y + h));
program.Codes.Add(new LinearMove(x, y));
}
return program;
}
private static NestPipelineRequest Request(string engine, params Program[] programs) =>
new(engine,
programs.Select((p, i) => new NestItem { Drawing = new Drawing($"d{i}", p), Quantity = 1 }).ToArray(),
// Only the frame fits beside nothing else: the strap must go in its window.
new[] { new NestPlateStock("sheet", new Size(20.5, 20.5), 1, 0.25) });
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void StrapWithBoltHolesGoesInTheFrameWindow(string engine)
{
var request = Request(engine, Frame(), Strap());
Assert.Equal(NestJobStatus.Incomplete, NestPipeline.RunWithoutCutouts(request).Status);
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new[] { request.Items[1].Drawing, request.Items[0].Drawing },
Assert.Single(result.Plates).Parts.Select(p => p.BaseDrawing));
}
[Fact]
public void InsertWhoseHoleFitsAnotherPartStaysIndependent()
{
// The 10 x 10 part's 4 x 4 hole fits the 2 x 2 part, so it is never reserved in the
// frame's window; the 2 x 2 part goes into that hole, the smaller frame, instead.
var holed = WithSquareHoles(TestShapes.Rectangle(10, 10), (3, 3, 4, 4));
var engine = new Stub(job => new NestJobResultBuilder(job).Build(NestJobStopReason.NoPlacementFound));
NestPipeline.RunCutoutTrial(engine, "stub", Request("stub", Frame(), holed, TestShapes.Rectangle(2, 2)));
Assert.Equal(new[] { "part-0", "__cutout-proxy-part-1-0" },
Assert.Single(engine.Jobs).Parts.Select(p => p.Id));
}
}
@@ -12,9 +12,10 @@ public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
private static NestPipelineRequest Request(string engine = "Irregular")
/// <param name="hole">Hole diameter: ten holds two 3 x 3 inserts, six holds exactly one.</param>
private static NestPipelineRequest Request(string engine = "Irregular", double hole = 10)
{
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
var frame = new Drawing("frame", TestShapes.Ring(20, hole));
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
return new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = frame, Quantity = 1 },
@@ -112,7 +113,8 @@ public class CutoutPipelinePreviewTests
[Fact]
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
{
var request = Request("stub");
// One insert is reserved in the hole; the other must stay an ordinary requirement.
var request = Request("stub", hole: 6);
request.Items[1].Quantity = 2;
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
@@ -171,7 +173,8 @@ public class CutoutPipelinePreviewTests
[Fact]
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
{
var request = Request("stub");
// One insert is reserved in the hole; the other must stay an ordinary requirement.
var request = Request("stub", hole: 6);
request.Items[1].Quantity = 2;
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
{
@@ -273,7 +276,8 @@ public class CutoutPipelinePreviewTests
[Fact]
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
{
var initial = Request("stub");
// One hole seat: the first requirement row fills it, the second row stays separate.
var initial = Request("stub", hole: 6);
var request = initial with
{
Items = new[] { initial.Items[0], initial.Items[1],
@@ -0,0 +1,247 @@
using Clipper2Lib;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Irregular stock: a <see cref="SheetOutline"/> on the stock reaches the Irregular packer as its
/// boundary, every placement stays inside the outline and off its obstacles, the layout check and
/// the runner's validation reject one that does not, and engines that only fill rectangles place
/// nothing on it.
/// </summary>
public class IrregularStockTests
{
private const double Spacing = 0.5;
/// <summary>A 96 x 48 sheet (X by Y) missing its upper-right 48 x 24 corner.</summary>
private static SheetOutline Ell() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48),
});
/// <summary>A 96 x 48 sheet whose upper-right corner is cut off by a diagonal from (72, 48) to (96, 24).</summary>
private static SheetOutline Chamfered() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(72, 48), new Vector(0, 48),
});
/// <summary>A whole 96 x 48 sheet with a 20 x 20 burned patch in the middle.</summary>
private static SheetOutline Burned() => new(
new[] { new Vector(0, 0), new Vector(96, 0), new Vector(96, 48), new Vector(0, 48) },
new[] { new[] { new Vector(38, 14), new Vector(58, 14), new Vector(58, 34), new Vector(38, 34) } });
private static NestPlateStock Stock(SheetOutline outline, int quadrant = 1, Spacing edge = default, int? quantity = 1) =>
new("drop", outline.Size, quantity, Spacing, edge, quadrant, null, null, outline);
private static NestJobPart Square(string id, double side, int quantity) =>
new(id, PartGeometrySnapshot.FromProgram(TestShapes.Rectangle(side, side)), quantity);
private static void AssertInsideOutline(NestJob job, NestJobResult result)
{
Assert.Empty(NestLayoutCheck.Violations(job, result));
foreach (var sheet in result.Plates)
{
var usable = sheet.Stock.UsableOutline();
foreach (var pose in sheet.Placements)
{
var geometry = JobPartGeometry.Read(job.Parts.Single(p => p.Id == pose.PartId).Geometry);
var perimeter = (Shape)geometry.Perimeter.Clone();
perimeter.Rotate(pose.Rotation);
perimeter.Offset(pose.X, pose.Y);
var path = ClipperBridge.ToPath(ClipperBridge.Flatten(perimeter, 0.001, circumscribe: true), positive: true);
var outside = Clipper.Difference(new PathsD { path }, usable, FillRule.NonZero, 4);
Assert.True(outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0,
$"{pose.PartId} at ({pose.X:F2},{pose.Y:F2}) leaves the usable outline");
}
}
}
[Fact]
public void Stock_CarriesTheOutlineAndItsArea()
{
var stock = Stock(Ell(), quadrant: 3, edge: new Spacing(1, 0.5, 0.25, 0.75));
Assert.Equal(96 * 48 - 48 * 24, stock.Area, 6);
Assert.Equal(1, stock.OutlineInset);
Assert.Equal(new Vector(-96, -48), stock.PlacementOrigin);
var usable = stock.UsableOutline();
var bounds = Clipper.GetBounds(usable);
Assert.Equal(-95, bounds.left, 6);
Assert.Equal(-47, bounds.top, 6);
Assert.Equal(-1, bounds.right, 6);
Assert.Equal(-1, bounds.bottom, 6);
Assert.Throws<ArgumentException>(() =>
new NestPlateStock("bad", new Size(48, 100), 1, Spacing, default, 1, null, null, Ell()));
}
[Fact]
public void Mapper_CarriesTheOutlineBothWays()
{
var plate = new Plate { Outline = Ell(), Quadrant = 2 };
var stock = DrawingJobMapper.FromPlate("s", plate, null);
Assert.Equal(Ell(), stock.Outline);
var rebuilt = DrawingJobMapper.CreatePlate(stock);
Assert.Equal(Ell(), rebuilt.Outline);
Assert.Equal(plate.Size, rebuilt.Size);
Assert.Equal(2, rebuilt.Quadrant);
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void Irregular_FillsAnEllSheetWithoutEnteringTheMissingCorner(int quadrant)
{
// Six 20 x 20 squares fit: four along the 96 x 24 arm and two more on the 48 x 48 column.
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell(), quadrant)]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(6, result.Plates.Sum(p => p.Placements.Count));
AssertInsideOutline(job, result);
}
[Fact]
public void Irregular_KeepsTheLargestEdgeSpacingFromADiagonalEdge()
{
var job = new NestJob([Square("sq", 10, 40)], [Stock(Chamfered(), edge: new Spacing(0.25, 1.5, 0.25, 0.25))]);
var result = new IrregularNestingEngine().Solve(job);
// The chamfer removes a 24 x 24 triangle (288) from 4608; at 1.5 inset about 36 squares fit.
Assert.InRange(result.Plates.Sum(p => p.Placements.Count), 30, 40);
AssertInsideOutline(job, result);
}
[Fact]
public void Irregular_NestsAroundAnObstacle()
{
var job = new NestJob([Square("sq", 15, 12)], [Stock(Burned())]);
var result = new IrregularNestingEngine().Solve(job);
Assert.True(result.Plates.Sum(p => p.Placements.Count) >= 10);
AssertInsideOutline(job, result);
}
[Fact]
public void Default_UsesIrregularForAnOutlinedSheetAndRectanglesPlacesNothing()
{
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell())]);
var rectangles = new RectanglesNestingEngine().Solve(job);
Assert.Equal(0, rectangles.Plates.Sum(p => p.Placements.Count));
Assert.Equal(NestJobStopReason.NoPlacementFound, rectangles.StopReason);
var fill = new FixedStrategyNestingEngine("Fill").Solve(job);
Assert.Equal(0, fill.Plates.Sum(p => p.Placements.Count));
var chosen = new DefaultNestingEngine().Solve(job);
Assert.Equal(6, chosen.Plates.Sum(p => p.Placements.Count));
AssertInsideOutline(job, chosen);
}
[Fact]
public void Default_PrefersARectangleWhenAnOutlinedSheetIsAlsoOffered()
{
var rectangle = new NestPlateStock("sheet", new Size(48, 96), 1, Spacing);
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell()), rectangle]);
var result = new DefaultNestingEngine().Solve(job);
Assert.Equal(6, result.Plates.Sum(p => p.Placements.Count));
Assert.Empty(NestLayoutCheck.Violations(job, result));
}
[Fact]
public void LayoutCheck_RejectsAPartInTheMissingCornerOrOverAnObstacle()
{
var corner = new NestJob([Square("sq", 20, 2)], [Stock(Ell())]);
var inCorner = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, corner.Plates[0], [new NestJobPlacement("sq", 0, 1, 1, 0), new NestJobPlacement("sq", 1, 60, 26, 0)])],
[new PartFulfillment("sq", 2, 2, 0)], [new StockUsage("drop", 1, 0)]);
var violation = Assert.Single(NestLayoutCheck.Violations(corner, inCorner));
Assert.Contains("(60.00,26.00)", violation);
Assert.Contains("outline", violation);
var burned = new NestJob([Square("sq", 20, 1)], [Stock(Burned())]);
var onPatch = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, burned.Plates[0], [new NestJobPlacement("sq", 0, 30, 20, 0)])],
[new PartFulfillment("sq", 1, 1, 0)], [new StockUsage("drop", 1, 0)]);
Assert.Contains(NestLayoutCheck.Violations(burned, onPatch), v => v.Contains("obstacle"));
// A placement inside the arm, exactly at the edge, passes.
var fine = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, corner.Plates[0], [new NestJobPlacement("sq", 0, 0, 0, 0), new NestJobPlacement("sq", 1, 76, 4, 0)])],
[new PartFulfillment("sq", 2, 2, 0)], [new StockUsage("drop", 1, 0)]);
Assert.Empty(NestLayoutCheck.Violations(corner, fine));
}
[Fact]
public void Runner_RefusesACandidateOutsideTheOutline()
{
var job = new NestJob([Square("sq", 20, 1)], [Stock(Ell())]);
var candidate = new PlateCandidate([new NestJobPlacement("sq", 0, 60, 26, 0)]);
var parts = job.Parts.ToDictionary(p => p.Id);
var remaining = new Dictionary<string, int> { ["sq"] = 1 };
var exception = Assert.Throws<InvalidOperationException>(() =>
NestJobValidator.ValidateCandidate(candidate, job.Plates[0], remaining, parts));
Assert.Contains("usable stock area", exception.Message);
NestJobValidator.ValidateCandidate(new PlateCandidate([new NestJobPlacement("sq", 0, 76, 4, 0)]),
job.Plates[0], remaining, parts);
}
[Fact]
public void Pipeline_CommitsTheOutlineToThePlate()
{
var nest = new Nest("drop");
var drawing = new Drawing("sq", TestShapes.Rectangle(20, 20));
drawing.Quantity.Required = 6;
nest.Drawings.Add(drawing);
var template = new Plate { Outline = Ell(), PartSpacing = Spacing };
nest.Plates.Add(template);
var request = new NestPipelineRequest("Irregular",
[new NestItem { Drawing = drawing, Quantity = 6 }], NestStockBuilder.SinglePlate(template), new NestJobOptions());
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("\n", result.Violations));
var applied = NestPipelineCommit.ApplyToPlates(result, () => template);
var plate = Assert.Single(applied);
Assert.Equal(Ell(), plate.Outline);
Assert.Equal(6, plate.Parts.Count);
}
[Fact]
public void FillService_RefusesAnOutlinedPlate()
{
var plate = new Plate { Outline = Ell() };
var item = new NestItem { Drawing = new Drawing("sq", TestShapes.Rectangle(20, 20)), Quantity = 1 };
var exception = Assert.Throws<InvalidOperationException>(() =>
PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, default));
Assert.Contains("rectangular", exception.Message);
// Callers check the refusal first and show it instead of catching the exception.
Assert.Equal(exception.Message, PlateFillService.Refusal(plate));
Assert.Null(PlateFillService.Refusal(new Plate(48, 96)));
Assert.Null(PlateFillService.Refusal(null));
}
[Fact]
public void RepeatedPlates_DoNotGroupAnOutlinedSheetWithItsRectangle()
{
var outlined = Stock(Ell(), quantity: null);
var plain = new NestPlateStock("drop", new Size(48, 96), null, Spacing);
Assert.False(RepeatedPlateGrouping.SameStock(outlined, plain));
Assert.True(RepeatedPlateGrouping.SameStock(outlined, Stock(Ell(), quantity: null)));
}
}
@@ -121,11 +121,14 @@ public sealed class CuttingPlanBatch
{
var plate = plates[index];
var budget = PlateBudget(plate.Parts.Count, budgetMultiplier);
var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
// One capture serves both searches: the order flag and budget are search settings
// over the same owned geometry, programs and freshness record.
var captured = 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);
preservePartOrder: preservePartOrder), token);
var keepOrder = preservePartOrder ? captured : captured.WithPreservedOrder();
var reorder = preservePartOrder ? null
: reorderBudget is { } reorderLimit ? captured.WithSearchLimits(reorderLimit, captured.MaxEntries) : captured;
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
var fallback = reorder ?? keepOrder;
if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry)
@@ -212,9 +215,10 @@ public sealed class CuttingPlanBatch
/// <summary>
/// Safest first, then fastest: the replayed safest route from the free order and from the
/// current order, whichever breaks fewer checks (most severe first), then has less rapid
/// travel. A route whose planner-chosen motions pass every check is taken at once, even
/// when fixed lead-ins still warn. Null when neither walk finds a route.
/// current order, whichever breaks fewer checks (most severe first), then raises the head
/// fewer times, then has less rapid travel. A route whose planner-chosen motions pass every
/// check head-down is taken at once, even when fixed lead-ins still warn. Null when neither
/// walk finds a route.
/// </summary>
private static (CuttingPlanResult Result, bool FreeOrder)? SafestRoute(Entry entry, CancellationToken token)
{
@@ -225,10 +229,9 @@ public sealed class CuttingPlanBatch
{
if (CuttingPlanService.PlanSafestRoute(snapshot, token) is not { } candidate)
continue;
if (!candidate.IsSafestRouteFallback)
if (!candidate.IsSafestRouteFallback && candidate.HeadRaises == 0)
return (candidate, freeOrder);
if (best is not { } known || candidate.SafestRoute < known.Result.SafestRoute
|| candidate.SafestRoute == known.Result.SafestRoute && candidate.RapidDistance < known.Result.RapidDistance)
if (best is not { } known || Safer(candidate, known.Result))
best = (candidate, freeOrder);
}
}
@@ -239,6 +242,17 @@ public sealed class CuttingPlanBatch
return best;
}
// Fewer broken checks (a route that breaks none first), then fewer head raises, then less travel.
private static bool Safer(CuttingPlanResult candidate, CuttingPlanResult known)
{
var severity = (candidate.SafestRoute ?? default).CompareTo(known.SafestRoute ?? default);
if (severity != 0)
return severity < 0;
if (candidate.HeadRaises != known.HeadRaises)
return candidate.HeadRaises < known.HeadRaises;
return candidate.RapidDistance < known.RapidDistance;
}
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
int firstPassBudget = FirstPassBudget)
{
@@ -447,6 +461,8 @@ public sealed class CuttingPlanProposal
Quadrant = source.Quadrant,
PartSpacing = source.PartSpacing,
EdgeSpacing = source.EdgeSpacing,
// View > Draw Support shows the proposal on the same bed.
SupportBed = source.SupportBed,
};
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
foreach (var zone in source.ClampZones)
@@ -512,8 +528,11 @@ public sealed class CuttingPlanProposal
+ $"regenerated, {kept} kept as is; rapid travel "
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}"
+ (plate.ReversedCutOffCount == 0 ? ""
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.");
// A ready plan's findings are advisory notes (support tabs that may not hold a part level).
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.")
+ (result.HeadRaises == 0 ? ""
: $" No route found keeps the head down over every cut part, so {Count(result.HeadRaises, "rapid")} "
+ $"{(result.HeadRaises == 1 ? "raises" : "raise")} the head; each is listed below.");
// A ready plan's findings are advisory notes (head raises, support tabs that may not hold a part level).
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
yield return line;
yield break;
@@ -120,6 +120,13 @@ public sealed class CuttingPlanSnapshot
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
Dependencies, BestEffort)
{ SupportTabsSkipped = SupportTabsSkipped };
/// <summary>The same capture planned along its current part order.</summary>
internal CuttingPlanSnapshot WithPreservedOrder() =>
new(Placements, StartPoint, ExpansionBudget, Failure, Findings, Regeneration,
preservePartOrder: true, MaxEntries, ExpansionObserver, PlateState, OwnedParameters,
Dependencies, BestEffort)
{ SupportTabsSkipped = SupportTabsSkipped };
}
public sealed class FixedProgramPlacement
@@ -367,6 +374,12 @@ public sealed class CuttingPlanResult
/// <summary>Whether this is a safest-route fallback rather than a route that passed every check.</summary>
public bool IsSafestRouteFallback => SafestRoute != null;
/// <summary>
/// The rapids that raise the head over parts already cut, each listed in <see cref="Findings"/>.
/// Only a safest-route fallback marks them, when no search kept every rapid head-down.
/// </summary>
public int HeadRaises { get; init; }
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
internal CuttingPlanSnapshot Snapshot { get; set; }
}
@@ -350,10 +350,13 @@ public static class CuttingPlanService
/// <summary>
/// The safest route the greedy walk finds, independently replayed; null when there is none.
/// A route that passes every check is Ready. One that does not is an UNVERIFIED BestEffort
/// carrying every violation, applicable only with explicit acceptance. A route the replay
/// cannot check completely, or with any motion into material (a lead that does not fit,
/// a cut-order or nested-lead conflict), is never offered: the strict refusal stands.
/// Its planner-chosen rapids that still cross parts already cut raise the head
/// (<see cref="RaisedRapids"/>): only here, after the strict search found no route avoiding
/// them. A route that then passes every check is Ready, listing its raises. One that does not
/// is an UNVERIFIED BestEffort carrying every violation, applicable only with explicit
/// acceptance. A route the replay cannot check completely, or with any motion into material
/// (a lead that does not fit, a cut-order or nested-lead conflict), is never offered: the
/// strict refusal stands.
/// </summary>
internal static CuttingPlanResult PlanSafestRoute(CuttingPlanSnapshot snapshot, CancellationToken token)
{
@@ -366,6 +369,14 @@ public static class CuttingPlanService
var order = JointCuttingPlanSearch.SafestRoute(snapshot.WithSearchLimits(budget, snapshot.MaxEntries), token);
if (order == null)
return null;
try
{
order = RaisedRapids.Mark(snapshot, order, token);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
// A route that cannot be read for marking keeps its crossings as findings.
}
var result = ReplayPrograms(snapshot, order, 0, token, safestRoute: true);
if (result.Status is not (CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort)
|| result.SafestRoute is { Severe: > 0 })
@@ -397,6 +408,9 @@ public static class CuttingPlanService
// Violations inside fixed emissions: accepted as given, reported, never refused (D1a).
// Capture adds the drawing pins it could not honour; their contours were planned normally.
var warnings = new List<CuttingPlanFinding>(snapshot.Findings);
// Rapids that raise the head over cut parts are listed, never refused.
var raises = new List<CuttingPlanFinding>();
var raisedRapids = 0;
var materialOwners = snapshot.Placements.Where(p => !p.IsCutOff).ToArray();
var materials = materialOwners.Select(p => p.Material).ToArray();
foreach (var proposal in order)
@@ -433,8 +447,10 @@ public static class CuttingPlanService
findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions);
}
var isFixed = proposal.FixedMotions(execution);
var (strict, fixedFindings) = checker.Check(execution, position, source.SourceOrdinal + 1,
var (strict, fixedFindings, raised) = checker.Check(execution, position, source.SourceOrdinal + 1,
source.IsCutOff, isFixed, token);
raises.AddRange(RaisedRapids.Notes(snapshot, source, raised));
raisedRapids += RaisedRapids.Count(raised);
findings.AddRange(JointCuttingPlanSearch.Map(snapshot, strict));
warnings.AddRange(fixedFindings.Select(f => FixedLeadWarnings.FromChecker(snapshot, source, f)));
var fixedLeads = new List<FixedLeadFailure>();
@@ -464,12 +480,14 @@ public static class CuttingPlanService
}
token.ThrowIfCancellationRequested();
// Support tabs that cannot hold their part level are noted, never refused.
var notes = order.SelectMany(SupportTabCapture.Notes).Concat(SupportTabCapture.Skipped(snapshot)).ToList();
var notes = raises.Concat(order.SelectMany(SupportTabCapture.Notes))
.Concat(SupportTabCapture.Skipped(snapshot)).ToList();
if (safestRoute && findings.Count != 0 && findings.All(f => f.Kind != PostVerificationKind.Incomplete))
return new(CuttingPlanStatus.BestEffort, order, findings.Concat(warnings).Concat(notes).Distinct(),
expansions, distance)
{
SafestRoute = RouteSeverity.Of(findings.Distinct().Select(f => f.Kind)),
HeadRaises = raisedRapids,
};
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
@@ -479,9 +497,11 @@ public static class CuttingPlanService
// UNVERIFIED: never Ready, applicable only through explicit warning acceptance.
if (warnings.Count != 0)
return new(CuttingPlanStatus.BestEffort, order, warnings.Concat(notes).Distinct(), expansions,
distance, independentlyReplayed: true);
distance, independentlyReplayed: true)
{ HeadRaises = raisedRapids };
return new(CuttingPlanStatus.Ready, order, notes, expansions: expansions,
rapidDistance: distance, independentlyReplayed: true);
rapidDistance: distance, independentlyReplayed: true)
{ HeadRaises = raisedRapids };
}
private static CuttingPlanFinding DependencyFinding(CuttingPlanSnapshot snapshot,
@@ -28,6 +28,14 @@ internal static class JointCuttingPlanSearch
/// the contours are those of the blocked part and of the part cut just before it.
/// </summary>
internal const int StallExpansionsPerEntry = 8;
/// <summary>
/// Stalls' worth of expansions each search that backtracks without a stall limit (the full
/// pass along a kept order, and the sequence-free recovery and full passes once an order
/// teaches nothing) may spend before it ends. Beyond that they only retry entry combinations
/// of parts already cut, so the budget they would take goes to the lighter fallbacks instead.
/// </summary>
internal const int FallbackStalls = 2;
private const int MaximumRecoveryHoles = 30;
// The most holes recovery's search alone served; parts with more try the reading-order sweep first.
private const int SearchedRecoveryHoles = 16;
@@ -52,7 +60,7 @@ internal static class JointCuttingPlanSearch
{
var inserted = CutOffInsertion.Insert(snapshot, outcome.Order, attached, walk.CountExpansion, token);
if (inserted != null)
return walk.Ready(inserted);
return walk.Ready(walk.FollowSegments(inserted));
}
catch (BudgetExceededException)
{
@@ -71,6 +79,10 @@ internal static class JointCuttingPlanSearch
var count = snapshot.Placements.Count;
var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
// Fixed programs have no entries to retry, so their search space is small and running
// it out is a genuine refusal; only a search that regenerates programs is bounded.
int? FallbackLimit() => snapshot.Placements.Any(p => p.Prepared != null)
? (int)System.Math.Min(int.MaxValue, walk.Expansions + (long)FallbackStalls * stall) : null;
var preferEndpoints = count > 1 && maxContours > 1;
// A supplied blank alone on its sheet (its holes are the whole part) goes to contour
// recovery first; any other lone part keeps the plain search, whose routes are shorter.
@@ -91,7 +103,7 @@ internal static class JointCuttingPlanSearch
if (recovered.Order != null)
return walk.Ready(recovered.Order);
}
var kept = walk.Follow(keptSequence, null, null);
var kept = walk.Follow(keptSequence, null, null, expansionLimit: FallbackLimit());
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
}
@@ -133,11 +145,12 @@ internal static class JointCuttingPlanSearch
// A lone holed part has no part order to learn: its holes still get recovery.
if (preferEndpoints || maxContours > 1)
{
var recovery = walk.Follow(null, stall, null, contourRecovery: true);
var recovery = walk.Follow(null, stall, null, contourRecovery: true,
expansionLimit: FallbackLimit());
if (recovery.Order != null)
return walk.Ready(recovery.Order);
}
var full = walk.Follow(null, null, null);
var full = walk.Follow(null, null, null, expansionLimit: FallbackLimit());
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
}
// Back up to just before the earliest part the blocked approach crossed, keep the
@@ -195,27 +208,43 @@ internal static class JointCuttingPlanSearch
}, token);
}
// "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.
// When the approach crossed the part cut just before the one it left from, the tool went on
// past that part and came back across it: "cut those two the other way round", so the tool
// leaves from the nearer one (two stacked strips, cut far one first). Otherwise "cut the
// blocked part before every part whose cut contour its approach crossed", unless that
// would contradict an order already required. When every such rule would (a sheet separator
// is cut after everything), the blocked part cannot come earlier, so the tool must arrive
// from beyond those parts instead: "cut the part it left from before them". 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;
var rules = Rules(attempt, prerequisites).ToArray();
foreach (var (first, then) in rules)
prerequisites[then].Add(first);
return rules.Length != 0;
}
// True when a crossing of the blocked part's approach would teach a new order rule.
private static bool CanLearn(Attempt attempt, HashSet<int>[] prerequisites) =>
attempt.Blocked is int blocked && attempt.Crossed.Any(crossed => crossed != blocked
&& !prerequisites[crossed].Contains(blocked) && !Precedes(crossed, blocked, prerequisites));
Rules(attempt, prerequisites).Any();
// The rules Learn would add, as (cut first, cut then) pairs.
private static IEnumerable<(int First, int Then)> Rules(Attempt attempt, HashSet<int>[] prerequisites)
{
if (attempt.Blocked is not int blocked)
return [];
if (attempt.LastCut?.SourceOrdinal is int last && attempt.CutBeforeLast?.SourceOrdinal is int previous
&& attempt.Crossed.Contains(previous) && !prerequisites[previous].Contains(last)
&& !Precedes(previous, last, prerequisites))
return [(last, previous)];
var before = New(blocked);
return before.Length != 0 || attempt.LastCut?.SourceOrdinal is not int departed ? before : New(departed);
(int, int)[] New(int first) => attempt.Crossed.Order()
.Where(crossed => crossed != blocked && crossed != first && !prerequisites[crossed].Contains(first)
&& !Precedes(crossed, first, prerequisites))
.Select(crossed => (first, crossed)).ToArray();
}
// True when 'first' must already come before 'second' through the prerequisite chain.
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
@@ -311,9 +340,10 @@ internal static class JointCuttingPlanSearch
/// further along the order; it never backtracks behind its starting node.
/// With <paramref name="prerequisites"/>, an approach into a cutoff that crosses parts
/// already cut ends the attempt at once when that teaches "cut it before them".
/// With <paramref name="expansionLimit"/>, the attempt ends once the walk's expansions reach it.
/// </summary>
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false,
HashSet<int>[] prerequisites = null)
HashSet<int>[] prerequisites = null, int? expansionLimit = null)
{
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
var attempt = new Attempt(sequence);
@@ -332,12 +362,15 @@ internal static class JointCuttingPlanSearch
attempt.Order = node.Order;
return attempt;
}
// A pass held to a share of the budget ends there, as a stalled one does.
if (expansionLimit is int end && Expansions >= end)
return attempt;
// A cutoff is a fixed program cut the same way wherever it goes, so replanning
// the parts before it can only change where its approach starts. Cutting it
// earlier never makes its own cut harder; learn that rather than stall here.
// earlier never makes its own cut harder, and a sheet separator that cannot come
// earlier needs the approach from elsewhere; learn that rather than stall here.
if (prerequisites != null && sequence != null && attempt.Blocked is int blocked
&& snapshot.Placements[blocked] is { IsCutOff: true, SeparatesSheet: false }
&& CanLearn(attempt, prerequisites))
&& snapshot.Placements[blocked].IsCutOff && CanLearn(attempt, prerequisites))
return attempt;
if (attempt.Advance(node))
progressExpansions = Expansions;
@@ -523,8 +556,9 @@ internal static class JointCuttingPlanSearch
CountExpansion(source);
var checker = node.Checker.Copy();
if (Check(cut, cut.Execution, node.Position, checker, attempt))
// A segment is transparent: the part after it ranks its entries and hole route
// from where the tool was before the segment, as planned without it.
// A segment is transparent to the part after it: that part ranks its outside entry
// from where the tool was before the segment, as planned without it. Its hole
// route starts where the tool is, the segment's end, as FollowSegments plans it.
yield return new(new([.. node.Order, cut], cut.Execution.DeparturePoint, checker, null, node,
IsSegment(cut) ? node.RoutePosition : null),
cut.Execution.RapidDistanceFrom(node.Position), cut.SourceOrdinal, -1, -1);
@@ -559,7 +593,7 @@ internal static class JointCuttingPlanSearch
}
var preference = node.Active?.Preference;
if (node.Active?.Perimeter is { } outside && preference == null)
preference = PlanHoles(node.Active, outside);
preference = PlanHoles(node.Active.Source, node.Active.Boundary.Position, outside);
var choices = node.Active?.Choices ?? [];
var arrival = node.Active?.Arrival ?? node.Position;
var before = node.Active?.Before ?? node.Checker;
@@ -1216,12 +1250,13 @@ internal static class JointCuttingPlanSearch
return ordered;
}
private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter)
// The hole route and preferred hole entries from the tool's global arrival onto the part,
// ending at the outside entry.
private HolePreference PlanHoles(FixedProgramPlacement source, Vector arrival, ContourChoice perimeter)
{
var source = active.Source;
var prepared = source.Prepared;
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
var localArrival = active.Boundary.RoutePosition - source.Location;
var localArrival = arrival - source.Location;
var centres = CuttingHoleOrder.Centres(prepared, localArrival, token);
var endpoint = perimeter.Point;
var entries = new Dictionary<int, ContourChoice>();
@@ -1255,6 +1290,72 @@ internal static class JointCuttingPlanSearch
return new(route, entries, pierces, endpoint);
}
/// <summary>
/// The order with segments placed, where each holed part cut straight after a segment
/// takes the hole route and entries planned from the segment's end, where the tool
/// really is. The free search planned the part as if the segment were not there, from
/// where the tool was before it. The part's outside entry and the order stay, so the
/// route is still the one planned without the lines; a kept order replanned later plans
/// the same holes from the same point. A part keeps its planned holes when the new
/// route has no preferred entry for every hole or does not pass the checks.
/// </summary>
internal IReadOnlyList<FixedProgramPlacement> FollowSegments(IReadOnlyList<FixedProgramPlacement> order)
{
var followed = order.ToArray();
var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint;
try
{
for (var index = 0; index < followed.Length; index++)
{
if (index > 0 && IsSegment(followed[index - 1])
&& FromSegment(followed[index], position, checker) is { } replanned)
followed[index] = replanned;
var placement = followed[index];
checker.Check(placement.Execution, position, placement.SourceOrdinal + 1, placement.IsCutOff,
placement.FixedMotions(placement.Execution), token);
position = placement.Execution.DeparturePoint;
}
}
catch (BudgetExceededException)
{
// The plan is complete without this refinement; keep it as placed.
return order;
}
return followed;
}
// The part re-planned from arrival with its outside entry kept, or null to keep it.
private FixedProgramPlacement FromSegment(FixedProgramPlacement placement, Vector arrival,
ReleasedContourState checker)
{
var source = snapshot.Placements[placement.SourceOrdinal];
var prepared = source.Prepared;
if (prepared is not { HasCutPerimeter: true } || prepared.PerimeterOrdinal == 0
|| placement.ContourChoices.Count != prepared.Count
|| placement.ContourChoices[^1].ContourOrdinal != prepared.PerimeterOrdinal)
return null;
var perimeter = placement.ContourChoices[^1];
var preference = PlanHoles(source, arrival, perimeter);
if (preference.Entries.Count != prepared.PerimeterOrdinal)
return null;
var choices = preference.Route.Select(hole => preference.Entries[hole]).Append(perimeter).ToArray();
Program program;
OwnedExecution execution;
try
{
CountExpansion(source);
program = prepared.Emit(choices);
execution = ExecutionMotionReader.Read(program, source.Location, arrival, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
{
return null;
}
return Check(source, execution, arrival, checker.Copy(), new Attempt(null), choices)
? source.Propose(program, execution, choices, token) : null;
}
// The placements cut at a node, and the excluded ones, which count as cut throughout.
private HashSet<int> Finished(Node node)
{
@@ -1504,6 +1605,9 @@ internal static class JointCuttingPlanSearch
/// <summary>The part cut last before the deepest boundary, or null at the start point.</summary>
internal FixedProgramPlacement LastCut => Deepest?.Order is { Length: > 0 } order ? order[^1] : null;
/// <summary>The part cut just before <see cref="LastCut"/>, or null.</summary>
internal FixedProgramPlacement CutBeforeLast => Deepest?.Order is { Length: > 1 } order ? order[^2] : null;
/// <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];
@@ -1568,8 +1672,8 @@ internal static class JointCuttingPlanSearch
ContourChoice Perimeter = null, HolePreference Preference = null);
/// <summary>
/// A search state; Previous links a whole-part boundary to the boundary before it. Anchor,
/// when set, is where the tool was before the cut-off segments cut last: entries and hole
/// routes rank from <see cref="RoutePosition"/>, every rapid is checked from Position.
/// when set, is where the tool was before the cut-off segments cut last: outside entries
/// rank from <see cref="RoutePosition"/>; hole routes and every rapid start from Position.
/// </summary>
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
ActivePart Active, Node Previous, Vector? Anchor = null)
@@ -0,0 +1,93 @@
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>
/// The last resort for a route no search could keep head-down: each planner-chosen rapid that
/// crosses parts already cut is marked to raise the head (<see cref="HeadRaise"/>). Only the
/// safest-route fallback uses it, after the strict search found no route avoiding every crossing,
/// so a plate that can be cut head-down never gets a raise. Rapids into or inside a fixed emission,
/// and rapids inside sub-programs, are never marked: their crossings stay findings.
/// </summary>
internal static class RaisedRapids
{
/// <summary>
/// <paramref name="order"/> with the crossing rapids of regenerated parts marked, walked as
/// replay walks it. Parts with nothing to mark keep their proposal.
/// </summary>
internal static IReadOnlyList<FixedProgramPlacement> Mark(CuttingPlanSnapshot snapshot,
IReadOnlyList<FixedProgramPlacement> order, CancellationToken token)
{
var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint;
var marked = new List<FixedProgramPlacement>(order.Count);
foreach (var proposal in order)
{
token.ThrowIfCancellationRequested();
var program = proposal.CopyProgram();
var execution = ExecutionMotionReader.Read(program, proposal.Location, position, token);
var placement = proposal;
var rapids = proposal.Prepared == null || proposal.IsCutOff ? []
: Crossing(checker.Copy(), proposal, execution, position, token);
if (rapids.Count != 0)
{
var raised = HeadRaise.Mark(program, rapids);
execution = ExecutionMotionReader.Read(raised, proposal.Location, position, token);
placement = proposal.Propose(raised, execution, proposal.ContourChoices, token);
}
checker.Check(execution, position, proposal.SourceOrdinal + 1, proposal.IsCutOff,
proposal.FixedMotions(execution), token);
marked.Add(placement);
position = execution.DeparturePoint;
}
return marked;
}
// The own-code indices of the planner's rapids that cross parts already cut.
private static HashSet<int> Crossing(ReleasedContourState checker, FixedProgramPlacement proposal,
OwnedExecution execution, Vector arrival, CancellationToken token)
{
var isFixed = proposal.FixedMotions(execution);
var strict = checker.Check(execution, arrival, proposal.SourceOrdinal + 1, false, isFixed, token).Strict;
var codes = new HashSet<int>();
foreach (var finding in strict)
{
if (finding.Kind != PostVerificationKind.RapidCrossing || finding.RapidEnd is not { } end)
continue;
for (var index = 0; index < execution.Motions.Count; index++)
{
var motion = execution.Motions[index];
if (motion.Rapid && motion.Code is int code && isFixed?.Invoke(index) != true
&& motion.End == end && (index == 0 ? arrival : motion.Start) == finding.RapidStart)
codes.Add(code);
}
}
return codes;
}
/// <summary>
/// One advisory note per raised rapid of <paramref name="source"/>, naming the cut parts it
/// passes over, from the replay's raised crossings (check positions are source ordinals + 1).
/// </summary>
internal static IEnumerable<CuttingPlanFinding> Notes(CuttingPlanSnapshot snapshot,
FixedProgramPlacement source, IReadOnlyList<PostVerificationFinding> raised) =>
raised.GroupBy(f => (f.RapidStart, f.RapidEnd)).Select(rapid =>
{
var parts = rapid.Select(f => f.OtherPartNumber).OfType<int>().Distinct().Order().ToArray();
var other = parts.Length == 1 ? snapshot.Placements[parts[0] - 1] : null;
var over = other != null ? "a part already cut" : $"parts {string.Join(", ", parts)}, already cut,";
return new CuttingPlanFinding(source.SourceOrdinal, source.SourcePart, other?.SourceOrdinal,
other?.SourcePart, null, $"A rapid raises the head over {over} because no route found keeps the head down.")
{ Witness = rapid.First() };
});
/// <summary>The raised rapids in <paramref name="raised"/>: crossings of one rapid count once.</summary>
internal static int Count(IEnumerable<PostVerificationFinding> raised) =>
raised.Select(f => (f.RapidStart, f.RapidEnd)).Distinct().Count();
}
@@ -58,7 +58,8 @@ public static class DrawingJobMapper
plate.EdgeSpacing,
plate.Quadrant,
cost: null,
clampZones: new List<ClampZone>(plate.ClampZones)
clampZones: new List<ClampZone>(plate.ClampZones),
outline: plate.Outline
)
{ ClampRail = plate.ClampRail };
}
@@ -126,6 +127,7 @@ public static class DrawingJobMapper
// them into the placement frame through ClampZoneFrame, exactly as the stock did.
foreach (var zone in stock.SheetClampZones)
plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds));
plate.Outline = stock.Outline;
return plate;
}
}
+4 -2
View File
@@ -377,8 +377,9 @@ internal static class ClampRemovalRecovery
var e = stock.EdgeSpacing;
var zones = string.Join(",", stock.SheetClampZones.Select(z =>
FormattableString.Invariant($"{z.Id}:{z.Bounds.X:R}/{z.Bounds.Y:R}/{z.Bounds.Length:R}/{z.Bounds.Width:R}")));
var outline = stock.Outline == null ? "" : FormattableString.Invariant($"|o{stock.Outline.GetHashCode():X}");
return FormattableString.Invariant(
$"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}");
$"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}{outline}");
}
private sealed class ProbeBudget(int solves)
@@ -428,7 +429,8 @@ internal static class ClampRemovalRecovery
stock.EdgeSpacing,
stock.Quadrant,
stock.Cost,
sheetZones
sheetZones,
stock.Outline
)
{ ClampRail = stock.ClampRail };
}
@@ -7,6 +7,7 @@ using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -189,13 +190,7 @@ internal static class CutoutLatticeFill
.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);
return PackBoundary.InnerFit(grown, new PathsD { hole });
}
/// <summary>Drops copies that fail the production clearance check against the frame; any
@@ -0,0 +1,203 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs.Cutouts;
/// <summary>
/// Frame-local cutout proposal that packs one closed cutout with the Irregular engine's
/// <see cref="FrontierPacker"/>, taking the cutout as the packing boundary and every insert
/// requirement at once: parts settle against the cutout wall and each other instead of at the
/// first sampled point that clears. Each of the engine's strategy variants packs the hole; the
/// variant seating the most copies wins, ties going to the tighter layout. Every pose is then
/// certified with <see cref="NestLayoutCheck.Clears"/> against the frame, the occupants and the
/// other accepted poses, exactly as the lattice and NFP proposals are. Never changes a job.
/// </summary>
internal static class CutoutPackerProposal
{
private const double MaxCoordinate = 1e8;
private const double MaxSpacing = 100;
private const double Tie = 1e-6;
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
Fill(frame, cutoutIndex, inserts, spacing,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), null, token);
/// <summary>Poses in the frame's own coordinates, in packing order, clearing the frame,
/// the <paramref name="occupied"/> inserts and each other; instance indices count per
/// requirement.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing,
IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CutoutMemo? memo,
CancellationToken token)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(inserts);
ArgumentNullException.ThrowIfNull(occupied);
token.ThrowIfCancellationRequested();
if (inserts.Count == 0 || !double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
return Array.Empty<NestJobPlacement>();
var frameGeometry = memo?.Read(frame.Geometry) ?? JobPartGeometry.TryRead(frame.Geometry);
if (frameGeometry == null || !WithinRange(frameGeometry.Bounds))
return Array.Empty<NestJobPlacement>();
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
var cutout = frameGeometry.Cutouts[cutoutIndex];
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
return Array.Empty<NestJobPlacement>();
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
foreach (var insert in inserts)
{
var geometry = memo?.Read(insert.Geometry) ?? JobPartGeometry.TryRead(insert.Geometry);
if (geometry == null || !WithinRange(geometry.Bounds))
return Array.Empty<NestJobPlacement>();
geometries[insert.Id] = geometry;
}
// Inscribed: the flattened cutout never extends past the real one.
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false), new Vector()));
if (hole.Count < 3)
return Array.Empty<NestJobPlacement>();
var box = cutout.BoundingBox;
var stock = new NestPlateStock("cutout", new Size(box.Width, box.Length), 1, spacing);
var types = PartCatalog.Build(new NestJob(inserts, new[] { stock }));
if (types.All(t => t.Orientations.Count == 0))
return Array.Empty<NestJobPlacement>();
token.ThrowIfCancellationRequested();
var nfps = new NoFitCache(spacing);
var obstacles = occupied.Select(o => Outline(o.Geometry, o.Pose)).ToList();
var boundary = new PackBoundary(hole, obstacles, nfps);
var pairs = new Dictionary<int, IReadOnlyList<PairPose>>();
var counter = new WorkCounter();
var demand = types.Select(t => t.Part.Quantity).ToArray();
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
var preparedFrame = NestLayoutCheck.Prepare(frameGeometry, framePose, spacing);
var preparedOccupied = new NestLayoutCheck.PreparedPlacement[occupied.Count];
for (var i = 0; i < occupied.Count; i++)
{
token.ThrowIfCancellationRequested();
preparedOccupied[i] = NestLayoutCheck.Prepare(occupied[i].Geometry, occupied[i].Pose, spacing);
}
List<NestJobPlacement>? best = null;
foreach (var (axis, beta) in IrregularNestingEngine.Variants)
{
token.ThrowIfCancellationRequested();
var packer = new FrontierPacker(types, nfps, pairs, stock, axis, beta, counter, boundary: boundary);
var fill = packer.Fill(demand, token);
var poses = new List<NestJobPlacement>(fill.Parts.Count);
foreach (var placed in fill.Parts)
{
token.ThrowIfCancellationRequested();
var part = types[placed.Orientation.TypeIndex].Part;
var pose = new NestJobPlacement(part.Id, 0, System.Math.Round(placed.X, 8),
System.Math.Round(placed.Y, 8), placed.Orientation.Rotation);
if (Clears(pose, geometries[part.Id], preparedFrame, preparedOccupied, poses, geometries, spacing, memo, token))
poses.Add(pose);
}
if (poses.Count == 0)
continue;
if (best == null || poses.Count > best.Count
|| (poses.Count == best.Count && Tighter(poses, best, id => geometries[id])))
best = poses;
}
token.ThrowIfCancellationRequested();
if (best == null)
return Array.Empty<NestJobPlacement>();
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
return best.Select(p =>
{
var index = counts.GetValueOrDefault(p.PartId);
counts[p.PartId] = index + 1;
return p with { InstanceIndex = index };
}).ToArray();
}
/// <summary>True when <paramref name="candidate"/> is the more compact layout: a smaller
/// bounding box of the placed inserts, then one lower, then one further left.</summary>
internal static bool Tighter(IReadOnlyList<NestJobPlacement> candidate,
IReadOnlyList<NestJobPlacement> incumbent, Func<string, JobPartGeometry> geometry)
{
if (candidate.Count == 0)
return false;
if (incumbent.Count == 0)
return true;
var a = Envelope(candidate, geometry);
var b = Envelope(incumbent, geometry);
var areaA = a.Length * a.Width;
var areaB = b.Length * b.Width;
if (System.Math.Abs(areaA - areaB) > Tie * System.Math.Max(1, System.Math.Max(areaA, areaB)))
return areaA < areaB;
if (System.Math.Abs(a.Bottom - b.Bottom) > Tie)
return a.Bottom < b.Bottom;
return a.Left < b.Left - Tie;
}
private static Box Envelope(IReadOnlyList<NestJobPlacement> poses, Func<string, JobPartGeometry> geometry)
{
var left = double.PositiveInfinity;
var bottom = double.PositiveInfinity;
var right = double.NegativeInfinity;
var top = double.NegativeInfinity;
foreach (var pose in poses)
{
var shape = (Shape)geometry(pose.PartId).Perimeter.Clone();
shape.Rotate(pose.Rotation);
var bounds = shape.BoundingBox;
left = System.Math.Min(left, bounds.Left + pose.X);
bottom = System.Math.Min(bottom, bounds.Bottom + pose.Y);
right = System.Math.Max(right, bounds.Right + pose.X);
top = System.Math.Max(top, bounds.Top + pose.Y);
}
return new Box(left, bottom, right - left, top - bottom);
}
private static bool Clears(NestJobPlacement pose, JobPartGeometry geometry,
NestLayoutCheck.PreparedPlacement frame, NestLayoutCheck.PreparedPlacement[] occupied,
List<NestJobPlacement> accepted, IReadOnlyDictionary<string, JobPartGeometry> geometries,
double spacing, CutoutMemo? memo, CancellationToken token)
{
foreach (var other in occupied)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(other, geometry, pose))
return false;
}
foreach (var other in accepted)
{
token.ThrowIfCancellationRequested();
var otherGeometry = geometries[other.PartId];
var clears = memo?.Clears(otherGeometry, other, geometry, pose, spacing)
?? NestLayoutCheck.Clears(otherGeometry, other, geometry, pose, spacing);
if (!clears)
return false;
}
token.ThrowIfCancellationRequested();
return NestLayoutCheck.Clears(frame, geometry, pose);
}
/// <summary>The occupant's perimeter at its pose, circumscribed so it never loses material.</summary>
private static (PathD Outline, double Tolerance) Outline(JobPartGeometry geometry, NestJobPlacement pose)
{
var shape = (Shape)geometry.Perimeter.Clone();
shape.Rotate(pose.Rotation);
var path = ClipperBridge.ToPath(ClipperBridge.Flatten(shape, CutoutLatticeFill.FlattenTolerance, circumscribe: true),
new Vector(pose.X, pose.Y));
return (CutoutLatticeFill.Positive(path), CutoutLatticeFill.FlattenTolerance);
}
private static bool WithinRange(Box box) =>
double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
}
@@ -57,6 +57,12 @@ internal sealed class CutoutPipelinePrepass
// An insert's outline cannot fit a hole of smaller area; bolt holes skip the searches.
var outline = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
? System.Math.Abs(g.Perimeter.Area()) : double.PositiveInfinity);
// Lattice and NFP placement read a part's holes as free space, so they take as inserts
// only parts whose holes no part of the job fits by area: no later insert can land in
// one. Proxies are never solid; their frames' holes hold reserved inserts.
var smallestOutline = outline.Values.DefaultIfEmpty(double.PositiveInfinity).Min();
var solid = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g
&& g.Cutouts.All(c => System.Math.Abs(c.Area()) < smallestOutline));
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
// A proposal depends on the frame, the hole and the offered inserts with their local
@@ -104,10 +110,10 @@ internal sealed class CutoutPipelinePrepass
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
p.Priority, LocalRotation(p.Rotation, angle)))
.ToArray();
var flat = eligible.Where(p => geometry[p.Id] is { Cutouts.Count: 0 }).ToArray();
var flat = eligible.Where(p => solid.GetValueOrDefault(p.Id)).ToArray();
var nested = CutoutCirclePacker.RoundHole(profile.Cutouts[hole]) == null
? Array.Empty<NestJobPart>()
: eligible.Where(p => round[p.Id] && geometry[p.Id]!.Cutouts.Count > 0).ToArray();
: eligible.Where(p => round[p.Id] && !solid.GetValueOrDefault(p.Id)).ToArray();
if (flat.Length == 0 && nested.Length == 0)
continue;
foreach (var pose in Propose(frame, hole, flat, nested))
+17 -4
View File
@@ -10,7 +10,8 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
/// the large-insert path tries NFP first. A round hole whose inserts are all round gets only the
/// circle-in-circle proposal for the whole list.
/// circle-in-circle proposal for the whole list. Every other hole is also packed by
/// <see cref="CutoutPackerProposal"/>, which wins with more copies or an equal count packed tighter.
/// All accepted poses are clearance-checked.
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
internal static class CutoutRouter
@@ -32,7 +33,8 @@ internal static class CutoutRouter
Fill(frame, cutoutIndex, inserts, spacing, null, memo, token);
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
/// after indexing; true marks a retained lattice seed, false a residual NFP or circle-packer pose.</summary>
/// after indexing; true marks a retained lattice seed, false a residual NFP, circle-packer or
/// frontier-packer pose.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing,
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default) =>
@@ -66,9 +68,10 @@ internal static class CutoutRouter
// NFP searches never placed more there, and they are slow on round inserts, most of all
// when demand exceeds what fits (every surplus copy is searched for and not found).
if (CutoutCirclePacker.Applies(hole, inserts))
return Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
return Observe(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
var accepted = new List<NestJobPlacement>();
var geometries = new Dictionary<string, JobPartGeometry>(StringComparer.Ordinal);
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
var holeArea = System.Math.Abs(hole.Area());
@@ -77,6 +80,7 @@ internal static class CutoutRouter
{
token.ThrowIfCancellationRequested();
var geometry = JobPartGeometry.Read(part.Geometry);
geometries[part.Id] = geometry;
var ratio = geometry.MaterialArea / holeArea;
// Unsupported or degenerate material cannot establish a useful routing ratio;
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
@@ -148,11 +152,20 @@ internal static class CutoutRouter
}
}
token.ThrowIfCancellationRequested();
// The frontier packer sees every requirement at once and settles parts against the
// walls and each other; it replaces the sampled proposal when it seats more copies, or
// the same number more compactly.
var packed = CutoutPackerProposal.Fill(frame, cutoutIndex, inserts, spacing,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), memo, token);
if (packed.Count > accepted.Count
|| (packed.Count == accepted.Count
&& CutoutPackerProposal.Tighter(packed, accepted, id => geometries[id])))
return Observe(packed);
foreach (var (pose, lattice) in observed)
observeAccepted?.Invoke(pose, lattice);
return accepted.ToArray();
IReadOnlyList<NestJobPlacement> Circles(IReadOnlyList<NestJobPlacement> poses)
IReadOnlyList<NestJobPlacement> Observe(IReadOnlyList<NestJobPlacement> poses)
{
foreach (var pose in poses)
observeAccepted?.Invoke(pose, false);
+5 -3
View File
@@ -15,7 +15,7 @@ public static class NestJobCost
/// </summary>
public static double NetSheetArea(NestJob job, NestJobPlateResult sheet)
{
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
var area = sheet.Stock.Area;
var minimum = job.Options.MinimumSalvageDimension;
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
return area;
@@ -84,16 +84,18 @@ public static class NestJobCost
private static double NetSheetArea(
NestJobOptions options, NestPlateStock stock, double left, double bottom, double right, double top)
{
var area = stock.Size.Width * stock.Size.Length;
var area = stock.Area;
var minimum = options.MinimumSalvageDimension;
if (options.SalvageRate == 0 || minimum <= 0)
return area;
return area - options.SalvageRate * LargestOffcut(stock, left, bottom, right, top, minimum);
}
// No rectangular edge strip is guaranteed inside an outlined sheet, so it earns no credit.
private static double LargestOffcut(
NestPlateStock stock, double left, double bottom, double right, double top, double minimum) =>
PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
stock.Outline == null
&& PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
is { } offcut ? offcut.Length * offcut.Width : 0;
public static bool UsesExplicitCosts(NestJob job) =>
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
@@ -120,7 +121,12 @@ internal static class NestJobPlacementValidator
foreach (var cutout in shape.Cutouts)
if (!FitsWorkArea(cutout, workArea))
return false;
return true;
if (stock.Outline == null)
return true;
// The circumscribed perimeter must lie in the outline's usable area; holes are inside it.
var perimeter = ClipperBridge.ToPath(
ClipperBridge.Flatten(shape.Perimeter, NestTolerances.ValidationOutline, circumscribe: true), positive: true);
return stock.OutlineHolds(new PathsD { perimeter }, Epsilon);
}
private static bool FitsWorkArea(Shape contour, Box workArea)
+2
View File
@@ -63,6 +63,8 @@ public sealed class NestJobRunner : INestingEngine
var stock = job.Plates[index];
if (stock.Quantity is int quantity && used[stock.Id] >= quantity)
continue;
if (stock.Outline != null)
continue; // Single-plate fill strategies fill rectangles only.
hasAvailableStock = true;
var request = new PlatePlacementRequest(
stock,
+19 -1
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Converters;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
@@ -394,6 +395,7 @@ public static class NestLayoutCheck
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
&& expected.Quadrant == actual.Quadrant
&& Equals(expected.Outline, actual.Outline)
&& SameZones(expected.ClampZones, actual.ClampZones)
);
@@ -465,6 +467,7 @@ public static class NestLayoutCheck
)
{
var workArea = plate.WorkArea();
var stock = plate.Outline == null ? null : DrawingJobMapper.FromPlate("plate", plate, null);
foreach (var part in parts)
{
@@ -481,6 +484,19 @@ public static class NestLayoutCheck
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area "
+ $"of a {plate.Size} plate"
);
continue;
}
// An outlined sheet: the circumscribed material must lie in the usable area, which
// keeps the largest edge spacing from every edge and obstacle, within the same slack.
if (stock != null && Outline(part, 0) is { } material
&& !stock.OutlineHolds(new PathsD { ClipperBridge.ToPath(material.Perimeter, positive: true) },
NestTolerances.WorkAreaSlack))
{
result.Add(
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the outline "
+ $"of a {plate.Size} plate or over one of its obstacles"
);
}
}
}
@@ -501,7 +517,9 @@ public static class NestLayoutCheck
)
{
var workArea = plate.WorkArea();
var budget = workArea.Width * workArea.Length;
var budget = plate.Outline == null
? workArea.Width * workArea.Length
: DrawingJobMapper.FromPlate("plate", plate, null).UsableOutline().Sum(Clipper.Area);
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
if (placedArea > budget + Tolerance.Epsilon)
@@ -89,6 +89,7 @@ public static class NestPipelineCommit
continue;
var plate = nextPlate();
plate.Size = proposed.Stock.Size;
plate.Outline = proposed.Stock.Outline;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
+51 -3
View File
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
@@ -22,16 +24,19 @@ public sealed class NestPlateStock
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
Spacing edgeSpacing, int quadrant, double? cost,
IReadOnlyList<ClampZone> clampZones = null)
IReadOnlyList<ClampZone> clampZones = null, SheetOutline outline = null)
{
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
if (outline != null && (outline.Size.Width != size.Width || outline.Size.Length != size.Length))
throw new ArgumentException($"Stock {id}: the outline's bounds ({outline.Size}) must be the sheet size ({size}).", nameof(outline));
Cost = cost;
Id = id;
Size = size;
Outline = outline;
Quantity = quantity;
PartSpacing = partSpacing;
EdgeSpacing = edgeSpacing;
@@ -92,8 +97,50 @@ public sealed class NestPlateStock
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; }
/// <summary>The sheet's bounding box; the whole sheet when <see cref="Outline"/> is null.</summary>
public Size Size { get; }
/// <summary>
/// The sheet's material when it is not a rectangle, in plate coordinates (the sheet's
/// lower-left frame, as <see cref="SheetClampZones"/>); null for a rectangular sheet. Its
/// bounds are <see cref="Size"/>. Only engines that fill an outline nest on such stock;
/// the others place nothing on it. Parts keep <see cref="OutlineInset"/> from its edges
/// and obstacles. Omitted from serialized results when null, so rectangular stock
/// serializes exactly as before.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SheetOutline Outline { get; }
/// <summary>
/// The clearance a part keeps from an outlined sheet's edges and obstacles: the largest of
/// the four edge spacings, so no side of the outline is closer than the rectangle's would be.
/// </summary>
[JsonIgnore]
public double OutlineInset =>
System.Math.Max(0, System.Math.Max(System.Math.Max(EdgeSpacing.Left, EdgeSpacing.Right),
System.Math.Max(EdgeSpacing.Top, EdgeSpacing.Bottom)));
/// <summary>The sheet's lower-left corner in the placement frame: the translation
/// <see cref="WorkArea"/> and <see cref="ClampZones"/> were given.</summary>
[JsonIgnore]
public Vector PlacementOrigin => new(workArea.X - EdgeSpacing.Left, workArea.Y - EdgeSpacing.Bottom);
/// <summary>
/// Where a part may lie on an outlined sheet, in the placement frame: the outline less its
/// obstacles, eroded by <see cref="OutlineInset"/>. Null for a rectangular sheet, whose
/// <see cref="WorkArea"/> is the answer. A fresh copy on every call.
/// </summary>
public PathsD UsableOutline() => Outline?.Usable(OutlineInset, PlacementOrigin);
/// <summary>
/// Whether <paramref name="region"/> (outer paths positive, holes negative, in the placement
/// frame) lies within the outlined sheet's usable area, allowing <paramref name="slack"/>
/// outside it. True for a rectangular sheet: its box is checked by the callers.
/// </summary>
public bool OutlineHolds(PathsD region, double slack) =>
Outline == null || Outline.Holds(region, OutlineInset, PlacementOrigin, slack);
/// <summary>Available physical sheets: null is unlimited, zero is legal but unavailable.</summary>
public int? Quantity { get; }
public double PartSpacing { get; }
@@ -106,8 +153,9 @@ public sealed class NestPlateStock
/// </summary>
public Box WorkArea => new(workArea.X, workArea.Y, workArea.Length, workArea.Width);
/// <summary>Full sheet area before edge spacing is removed.</summary>
public double Area => Size.Width * Size.Length;
/// <summary>Full sheet area before edge spacing is removed: the material inside the
/// <see cref="Outline"/> when there is one.</summary>
public double Area => Outline?.Area ?? Size.Width * Size.Length;
/// <summary>True when a width (X) by height (Y) envelope fits the work area within epsilon.</summary>
public bool Fits(double width, double height, double epsilon = 1e-9) =>
@@ -29,6 +29,16 @@ public static class PlateFillService
"Horizontal Remnant",
];
/// <summary>Why no strategy fills a plate with a sheet outline (see <see cref="Refusal"/>).</summary>
public const string OutlinedSheetMessage =
"Fill strategies need a rectangular sheet. Nest an outlined sheet with Auto Nest and the Irregular engine.";
/// <summary>
/// Why the fill strategies cannot fill <paramref name="plate"/>, or null when they can. Callers
/// show it to the user instead of calling an operation that would throw it.
/// </summary>
public static string Refusal(Plate plate) => plate?.Outline == null ? null : OutlinedSheetMessage;
public static List<Part> FillItem(
string strategy,
Plate plate,
@@ -431,6 +441,10 @@ public static class PlateFillService
private static PlateFiller RequireFiller(string strategy, Plate plate)
{
ArgumentNullException.ThrowIfNull(strategy);
ArgumentNullException.ThrowIfNull(plate);
// Every filler lays parts out in a rectangle; only Auto Nest's Irregular engine honors an outline.
if (Refusal(plate) is { } refusal)
throw new InvalidOperationException(refusal);
// An explicit empty string is an unknown strategy; only ResolveStrategy(string),
// used for caller selections, maps null/empty to Fill.
ResolveStrategy(strategy, allowEmpty: false);
@@ -40,6 +40,7 @@ internal static class RepeatedPlateGrouping
&& Bits(a.EdgeSpacing.Top) == Bits(b.EdgeSpacing.Top)
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
&& a.Quantity == b.Quantity
&& Equals(a.Outline, b.Outline)
&& a.Cost.HasValue == b.Cost.HasValue
&& (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value))
&& SameZones(a.ClampZones, b.ClampZones);
@@ -90,6 +90,8 @@ internal static class DiscPacker
/// <summary>True when the disc fits the work area in both directions.</summary>
public static bool Fits(NestPlateStock stock, DiscType type)
{
if (stock.Outline != null)
return false; // The lattice fills rectangles only.
var work = stock.WorkArea;
return 2 * type.Radius <= work.Right - work.Left + Epsilon
&& 2 * type.Radius <= work.Top - work.Bottom + Epsilon;
@@ -131,9 +131,12 @@ internal sealed class BarStock
var lengths = new double[types.Count];
for (var t = 0; t < types.Count; t++)
{
var fitting = types[t].Orientations
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
.ToList();
// A bar is a rectangle; an outlined sheet is left to engines that fill outlines.
var fitting = stock.Outline != null
? new List<BarOrientation>()
: types[t].Orientations
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
.ToList();
if (fitting.Count == 0)
{
fits[t] = Array.Empty<BarOrientation>();
@@ -44,6 +44,10 @@ internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> Par
/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest,
/// i.e. the one that buys the most material coverage for the sheet length it consumes.
/// Parts are never placed in a sequence given up front; the sheet state decides what comes next.
///
/// Given a <see cref="PackBoundary"/>, the packer fills that outline instead of the stock's
/// rectangular work area: each region starts from the orientation's inner-fit polygon of the
/// boundary less the boundary's obstacles, and everything else is unchanged.
/// </summary>
internal sealed class FrontierPacker
{
@@ -63,7 +67,10 @@ internal sealed class FrontierPacker
private readonly WorkCounter counter;
private readonly BlockCatalog? blocks;
private readonly ClampObstacles? clamps;
private readonly PackBoundary? boundary;
/// <param name="boundary">Outline to fill instead of the stock's work area; it must be
/// fitted to <paramref name="nfps"/>.</param>
public FrontierPacker(
IReadOnlyList<PartType> types,
NoFitCache nfps,
@@ -73,9 +80,12 @@ internal sealed class FrontierPacker
double beta,
WorkCounter counter,
BlockCatalog? blocks = null,
ClampObstacles? clamps = null
ClampObstacles? clamps = null,
PackBoundary? boundary = null
)
{
if (boundary != null && !ReferenceEquals(boundary.Nfps, nfps))
throw new ArgumentException("The boundary is fitted to another no-fit cache.", nameof(boundary));
this.blocks = blocks;
this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock);
this.counter = counter;
@@ -85,9 +95,13 @@ internal sealed class FrontierPacker
this.stock = stock;
this.axis = axis;
this.beta = beta;
work = stock.WorkArea;
this.boundary = boundary;
work = boundary?.Bounds ?? stock.WorkArea;
}
private bool Fits(double width, double height) =>
boundary?.Fits(width, height) ?? stock.Fits(width, height);
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
{
var left = remaining.ToArray();
@@ -97,9 +111,11 @@ internal sealed class FrontierPacker
{
if (byOrientation.ContainsKey(o))
return true;
if (!stock.Fits(o.Width, o.Height))
if (!Fits(o.Width, o.Height))
return false;
var region = boundary == null ? new Region(o, work, single) : new Region(o, boundary, single);
if (region.IsEmpty)
return false;
var region = new Region(o, work, single);
if (clamps != null)
for (var i = 0; i < clamps.Count; i++)
{
@@ -110,6 +126,13 @@ internal sealed class FrontierPacker
counter.Add(1);
token.ThrowIfCancellationRequested();
}
if (boundary != null)
for (var i = 0; i < boundary.ObstacleCount; i++)
{
token.ThrowIfCancellationRequested();
region.Subtract(nfps.Get(boundary.Obstacle(i), o), 0, 0);
counter.Add(1);
}
states.Add(region);
byOrientation[o] = region;
return true;
@@ -126,7 +149,7 @@ internal sealed class FrontierPacker
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))
if (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]));
}
}
@@ -148,7 +171,7 @@ internal sealed class FrontierPacker
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))
if (!Fits(width, height))
continue;
foreach (var member in members)
{
@@ -341,6 +364,15 @@ internal sealed class FrontierPacker
private RectD bounds;
public Region(Orientation orientation, Box work, bool single)
: this(orientation, work, single, null) { }
/// <summary>Starts from the orientation's inner fit of the boundary. No fit slack: the
/// inner fit already lies within the bounds box, and an exact fit against a cutout wall
/// would fail the clearance check anyway.</summary>
public Region(Orientation orientation, PackBoundary boundary, bool single)
: this(orientation, boundary.Bounds, single, boundary.InnerFit(orientation)) { }
private Region(Orientation orientation, Box work, bool single, PathsD? seed)
{
Orientation = orientation;
Single = single;
@@ -353,7 +385,7 @@ internal sealed class FrontierPacker
maxX = minX;
if (maxY < minY)
maxY = minY;
free = new PathsD
free = seed ?? new PathsD
{
new PathD
{
@@ -363,7 +395,8 @@ internal sealed class FrontierPacker
new(minX - FitSlack, maxY + FitSlack),
},
};
bounds = Clipper.GetBounds(free);
free.RemoveAll(p => p.Count < 3);
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
}
public Orientation Orientation { get; }
@@ -24,8 +24,9 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
/// </summary>
public sealed class IrregularNestingEngine : INestingEngine
{
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
private static readonly (PackAxis Axis, double Beta)[] Variants =
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).
/// The cutout packer proposal tries the same variants on a cutout.</summary>
internal static readonly (PackAxis Axis, double Beta)[] Variants =
{
(PackAxis.X, 1.0),
(PackAxis.Y, 1.0),
@@ -148,6 +149,7 @@ public sealed class IrregularNestingEngine : INestingEngine
private const int MaxTail = 3;
private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<NestPlateStock, ClampObstacles> clamps = new();
private readonly Dictionary<NestPlateStock, PackBoundary?> boundaries = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private readonly IReadOnlyDictionary<string, NestJobPart> parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
@@ -229,6 +231,18 @@ public sealed class IrregularNestingEngine : INestingEngine
return cache;
}
/// <summary>An outlined sheet's usable material as the packer's boundary, built once per
/// stock per solve; null for a rectangular sheet, which keeps the work-area path.</summary>
private PackBoundary? BoundaryFor(NestPlateStock stock)
{
if (stock.Outline == null)
return null;
if (!boundaries.TryGetValue(stock, out var found))
boundaries[stock] = found = new PackBoundary(stock.Outline.Material(stock.PlacementOrigin),
stock.OutlineInset, stock.WorkArea, CacheFor(stock));
return found;
}
private ClampObstacles? ClampsFor(NestPlateStock stock)
{
if (stock.ClampZones.Count == 0)
@@ -306,7 +320,7 @@ public sealed class IrregularNestingEngine : INestingEngine
continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work,
BlocksFor(stock), ClampsFor(stock));
BlocksFor(stock), ClampsFor(stock), BoundaryFor(stock));
var fill = packer.Fill(remaining, token);
sheetPacked?.Invoke();
if (fill.Parts.Count > 0)
@@ -30,9 +30,13 @@ internal sealed class NoFitCache
public NoFitCache(double clearance)
{
Clearance = clearance;
halfClearance = clearance / 2;
}
/// <summary>The clearance two footprints together keep.</summary>
public double Clearance { get; }
public PathD Footprint(Orientation o) =>
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o));
@@ -0,0 +1,143 @@
#nullable enable
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// A closed outline the packer fills in place of a rectangular work area: a frame's cutout in
/// frame coordinates, with the parts already seated in it as obstacles. Each orientation's
/// starting free region is its inner-fit polygon: the reference points at which its clearance
/// footprint lies inside the outline eroded by the other half of the clearance, so a part
/// keeps the full clearance from the frame exactly as it does from another part. The inner
/// fit is computed once per orientation and shared by every packing of this boundary.
/// </summary>
/// <remarks>Bound to one <see cref="NoFitCache"/>: its footprints define the inner fits and the
/// obstacle no-fit polygons, so a packer filling this boundary must use that cache.</remarks>
internal sealed class PackBoundary
{
/// <summary>Extra erosion beyond the half clearance, covering the outline's flattening and
/// the round-offset chords at its reflex corners.</summary>
private const double Margin = 0.002;
private const double ArcTolerance = 0.001;
private readonly PathsD eroded;
private readonly Orientation[] obstacles;
private readonly ConcurrentDictionary<(int, int), PathsD> innerFits = new();
/// <param name="outline">Closed polygon inscribed in the true outline, so flattening never
/// adds room.</param>
/// <param name="obstacles">Polygons circumscribing material already inside the outline, with
/// the chord deviation each was flattened with; every obstacle gets the full clearance.</param>
/// <param name="nfps">The cache whose footprints this boundary is fitted to.</param>
public PackBoundary(PathD outline, IReadOnlyList<(PathD Outline, double Tolerance)> obstacles, NoFitCache nfps)
{
Nfps = nfps;
var box = Clipper.GetBounds(outline);
Bounds = new Box(box.left, box.top, box.Width, box.Height); // Clipper RectD: top is the minimum Y.
eroded = Clipper.InflatePaths(new PathsD { Positive(outline) }, -(nfps.Clearance / 2 + Margin),
JoinType.Round, EndType.Polygon, 2.0, NoFitCache.Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
this.obstacles = new Orientation[obstacles.Count];
for (var i = 0; i < obstacles.Count; i++)
{
var path = Positive(obstacles[i].Outline);
var tolerance = obstacles[i].Tolerance;
var bounds = Clipper.GetBounds(path);
this.obstacles[i] = new Orientation
{
// Real part types are nonnegative, so obstacle footprints never alias them.
TypeIndex = -1,
Index = i,
Rotation = 0,
Outline = path,
Tolerance = tolerance,
MinX = bounds.left - tolerance,
MinY = bounds.top - tolerance,
MaxX = bounds.right + tolerance,
MaxY = bounds.bottom + tolerance,
};
}
}
/// <summary>
/// A sheet's material as the boundary: <paramref name="material"/> is its outline less its
/// obstacles (outer paths positive, holes negative) in the placement frame, and parts keep
/// <paramref name="edgeClearance"/> from every edge and obstacle, as they keep the edge
/// spacing from a rectangular sheet's edges. The outline is eroded by the edge clearance
/// less the half clearance the footprints already carry (grown when that is negative), plus
/// the margin; <paramref name="bounds"/> is the packer's work area and front origin.
/// </summary>
public PackBoundary(PathsD material, double edgeClearance, Box bounds, NoFitCache nfps)
{
Nfps = nfps;
Bounds = bounds;
var inset = edgeClearance - nfps.Clearance / 2 + Margin;
eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon, 2.0,
NoFitCache.Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
obstacles = System.Array.Empty<Orientation>();
}
public NoFitCache Nfps { get; }
/// <summary>Bounds of the outline: the packer's work area and front origin.</summary>
public Box Bounds { get; }
public int ObstacleCount => obstacles.Length;
public Orientation Obstacle(int index) => obstacles[index];
/// <summary>Coarse filter against the outline's bounds; the inner fit decides.</summary>
public bool Fits(double width, double height) =>
width <= Bounds.Length + 1e-9 && height <= Bounds.Width + 1e-9;
/// <summary>A fresh copy of the orientation's starting free region.</summary>
public PathsD InnerFit(Orientation orientation) =>
new(innerFits.GetOrAdd((orientation.TypeIndex, orientation.Index),
_ => InnerFit(Nfps.Footprint(orientation), eroded)));
/// <summary>
/// Reference points t at which <paramref name="grown"/> lies inside one component of
/// <paramref name="hole"/>: its anchor vertex is inside the component (t in component - anchor)
/// and its outline never meets the component's boundary (t outside boundary + reflected
/// outline, including the pose that contains the whole component).
/// </summary>
internal static PathsD InnerFit(PathD grown, PathsD hole)
{
if (grown.Count < 3)
return new PathsD();
var reflected = new PathD(grown.Select(p => new PointD(-p.x, -p.y)));
var anchor = grown[0];
var inside = new PathsD();
PathsD? forbidden = null;
foreach (var component in hole)
{
if (component.Count < 3)
continue;
inside.Add(Clipper.TranslatePath(component, -anchor.x, -anchor.y));
// Clipper.MinkowskiSum rounds to two decimals; pass the job precision explicitly.
var sweep = Clipper.Union(Minkowski.Sum(reflected, component, true, NoFitCache.Precision),
new PathsD { Clipper.TranslatePath(reflected, component[0].x, component[0].y) },
FillRule.NonZero, NoFitCache.Precision);
forbidden = forbidden == null ? sweep : Clipper.Union(forbidden, sweep, FillRule.NonZero, NoFitCache.Precision);
}
return forbidden == null
? new PathsD()
: Clipper.Difference(inside, forbidden, FillRule.NonZero, NoFitCache.Precision);
}
private static PathD Positive(PathD path)
{
if (path.Count >= 3 && !Clipper.IsPositive(path))
{
path = new PathD(path);
path.Reverse();
}
return path;
}
}
@@ -76,6 +76,8 @@ public sealed class RectanglesNestingEngine : INestingEngine
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
{
if (stock.Outline != null)
return false; // Boxes pack rectangles only.
var work = stock.WorkArea;
return o.Width <= work.Right - work.Left + SheetPacker.OverhangAllowance
&& o.Height <= work.Top - work.Bottom + SheetPacker.OverhangAllowance;
@@ -69,11 +69,12 @@ internal static class SheetByStock
}
var trials = new List<(SheetLayout Layout, double Net, double Offcut)>();
if (preferred != null)
// Box and disc packers fill rectangles only; an outlined sheet is left to engines that fill outlines.
if (preferred != null && preferred.Outline == null)
Try(preferred, trials);
if (trials.Count == 0)
foreach (var stock in job.Plates)
if (!ReferenceEquals(stock, preferred))
if (!ReferenceEquals(stock, preferred) && stock.Outline == null)
Try(stock, trials);
if (trials.Count == 0)
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Gpu</RootNamespace>
<AssemblyName>OpenNest.Gpu</AssemblyName>
</PropertyGroup>
@@ -0,0 +1,118 @@
using System.Text.Json.Nodes;
using OpenNest.Geometry;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
/// <summary>
/// A plate's <see cref="SheetOutline"/> survives the .nest round trip under an optional
/// <c>outline</c> key; files without it load rectangles unchanged, an empty outlined plate is
/// kept, and a malformed value loads as a rectangle with a reader warning.
/// </summary>
public class SheetOutlinePersistenceTests
{
/// <summary>A 96 x 48 sheet missing its upper-right 48 x 24 corner, with a burned 4 x 4 patch.</summary>
private static SheetOutline Ell() => new(
new[] { new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48) },
new[] { new[] { new Vector(10, 10), new Vector(14, 10), new Vector(14, 14), new Vector(10, 14) } });
private static byte[] Write(Nest nest)
{
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
return stream.ToArray();
}
private static Nest OutlinedNest()
{
var nest = new Nest("outlined");
nest.Plates.Add(new Plate { Outline = Ell(), Quadrant = 3, EdgeSpacing = new Spacing(1, 2, 3, 4) });
return nest;
}
[Fact]
public void OutlineRoundTripsWithObstaclesAndKeepsAnEmptyPlate()
{
var (reopened, warnings) = RoundTrip(Write(OutlinedNest()));
Assert.Empty(warnings);
var plate = Assert.Single(reopened.Plates);
Assert.Equal(Ell(), plate.Outline);
Assert.Equal(48, plate.Size.Width);
Assert.Equal(96, plate.Size.Length);
Assert.Equal(3, plate.Quadrant);
Assert.Equal(96 * 48 - 48 * 24 - 16, plate.Area(), 6);
}
[Fact]
public void FileShapeIsPointsAndObstaclesInPlateCoordinates()
{
var plates = NestJson(Write(OutlinedNest()))["plates"]!.AsArray();
var outline = Assert.Single(plates)!["outline"]!.AsObject();
var points = outline["points"]!.AsArray();
Assert.Equal(6, points.Count);
Assert.Equal(0, (double)points[0]!["x"]!);
Assert.Equal(0, (double)points[0]!["y"]!);
Assert.Equal(96, (double)points[1]!["x"]!);
var obstacle = Assert.Single(outline["obstacles"]!.AsArray())!.AsArray();
Assert.Equal(4, obstacle.Count);
Assert.Equal(10, (double)obstacle[0]!["x"]!);
}
[Fact]
public void RectangularPlatesWriteNoOutlineKeyAndLoadWithout()
{
var nest = new Nest("plain");
nest.Plates.Add(new Plate(48, 96));
nest.Plates[0].CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical));
var archive = Write(nest);
var json = NestJson(archive);
Assert.Null(Assert.Single(json["plates"]!.AsArray())!["outline"]);
var (reopened, warnings) = RoundTrip(archive);
Assert.Empty(warnings);
Assert.Null(Assert.Single(reopened.Plates).Outline);
}
[Theory]
[InlineData("\"not an object\"", "not an object")]
[InlineData("{}", "'points' is missing")]
[InlineData("{\"points\": 5}", "not an array")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": \"ten\", \"y\": 0}, {\"x\": 0, \"y\": 10}]}", "vertex 2")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}]}", "three distinct vertices")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 10}, {\"x\": 10, \"y\": 0}, {\"x\": 0, \"y\": 10}]}", "no area")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}, {\"x\": 10, \"y\": 10}, {\"x\": 2, \"y\": -2}]}", "simple polygon")]
[InlineData("{\"points\": [{\"x\": 0, \"y\": 0}, {\"x\": 10, \"y\": 0}, {\"x\": 10, \"y\": 10}], \"obstacles\": 3}", "'obstacles' is not an array")]
public void MalformedOutlineLoadsARectangleWithAWarning(string value, string problem)
{
var archive = Write(OutlinedNest());
var json = NestJson(archive);
var plate = Assert.Single(json["plates"]!.AsArray())!.AsObject();
plate["outline"] = JsonNode.Parse(value);
var (reopened, warnings) = RoundTrip(ReplaceNestJson(archive, json));
var loaded = Assert.Single(reopened.Plates);
Assert.Null(loaded.Outline);
Assert.Equal(48, loaded.Size.Width);
Assert.Equal(96, loaded.Size.Length);
var warning = Assert.Single(warnings);
Assert.Contains("Plate 1", warning);
Assert.Contains(problem, warning);
}
[Fact]
public void SnapshotRestoresTheOutline()
{
var nest = OutlinedNest();
var snapshot = NestSnapshot.Capture(nest);
nest.Plates[0].Outline = null;
nest.Plates[0].Size = new Size(60, 120);
var restored = snapshot.Restore(nest);
Assert.Equal(Ell(), Assert.Single(restored.Plates).Outline);
Assert.Equal(96, restored.Plates[0].Size.Length);
}
}
+139
View File
@@ -696,6 +696,145 @@ namespace OpenNest.IO
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(WriteOnlyConverter<ExpectedRemnantDto>))]
public ExpectedRemnantDto ExpectedRemnant { get; init; }
/// <summary>
/// The sheet's material when it is not a rectangle (<see cref="SheetOutline"/>), in
/// plate coordinates; omitted for a rectangular sheet, which is every sheet in older
/// files. Read leniently: a wrongly shaped value loads as a rectangle with a reader
/// warning (see <see cref="NestReader.Warnings"/>).
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonConverter(typeof(LenientSheetOutlineConverter))]
public SheetOutlineDto Outline { get; init; }
}
/// <summary>A non-rectangular sheet: its vertices and the vertices of each obstacle.</summary>
public record SheetOutlineDto
{
public List<PointDto> Points { get; init; } = new();
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<List<PointDto>> Obstacles { get; init; }
/// <summary>Why the value could not be read, when it was malformed; never written.</summary>
[JsonIgnore]
public string Problem { get; init; }
}
public record PointDto
{
public double X { get; init; }
public double Y { get; init; }
}
/// <summary>
/// Reads a sheet outline leniently: a value that is not an object, a missing or non-array
/// <c>points</c>, or a vertex that is not an object with numeric <c>x</c> and <c>y</c>
/// yields a DTO carrying the <see cref="SheetOutlineDto.Problem"/> instead of failing the
/// file; a non-array obstacle list or entry is reported the same way. Geometry is not
/// checked here: <see cref="SheetOutline"/> does that when the plate is built.
/// </summary>
public sealed class LenientSheetOutlineConverter : JsonConverter<SheetOutlineDto>
{
public override SheetOutlineDto Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Null)
return null;
if (root.ValueKind != JsonValueKind.Object)
return new SheetOutlineDto { Problem = "it is not an object" };
if (!root.TryGetProperty("points", out var list))
return new SheetOutlineDto { Problem = "'points' is missing" };
if (!TryPointList(list, out var points, out var problem))
return new SheetOutlineDto { Problem = problem };
List<List<PointDto>> obstacles = null;
if (root.TryGetProperty("obstacles", out var entries) && entries.ValueKind != JsonValueKind.Null)
{
if (entries.ValueKind != JsonValueKind.Array)
return new SheetOutlineDto { Problem = "'obstacles' is not an array" };
obstacles = new List<List<PointDto>>();
var index = 0;
foreach (var entry in entries.EnumerateArray())
{
index++;
if (!TryPointList(entry, out var obstacle, out problem))
return new SheetOutlineDto { Problem = $"obstacle {index}: {problem}" };
obstacles.Add(obstacle);
}
}
return new SheetOutlineDto { Points = points, Obstacles = obstacles };
}
private static bool TryPointList(JsonElement list, out List<PointDto> points, out string problem)
{
points = null;
problem = null;
if (list.ValueKind != JsonValueKind.Array)
{
problem = "the vertex list is not an array";
return false;
}
points = new List<PointDto>();
foreach (var element in list.EnumerateArray())
{
if (element.ValueKind != JsonValueKind.Object
|| !TryNumber(element, "x", out var x)
|| !TryNumber(element, "y", out var y))
{
problem = $"vertex {points.Count + 1} is not an object with numeric x and y";
return false;
}
points.Add(new PointDto { X = x, Y = y });
}
return true;
}
public override void Write(
Utf8JsonWriter writer,
SheetOutlineDto value,
JsonSerializerOptions options
)
{
writer.WriteStartObject();
writer.WriteStartArray("points");
foreach (var point in value.Points)
WritePoint(writer, point);
writer.WriteEndArray();
if (value.Obstacles is { Count: > 0 })
{
writer.WriteStartArray("obstacles");
foreach (var obstacle in value.Obstacles)
{
writer.WriteStartArray();
foreach (var point in obstacle)
WritePoint(writer, point);
writer.WriteEndArray();
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
private static bool TryNumber(JsonElement point, string name, out double value)
{
value = 0;
return point.TryGetProperty(name, out var element)
&& element.ValueKind == JsonValueKind.Number
&& element.TryGetDouble(out value);
}
private static void WritePoint(Utf8JsonWriter writer, PointDto point)
{
writer.WriteStartObject();
writer.WriteNumber("x", point.X);
writer.WriteNumber("y", point.Y);
writer.WriteEndObject();
}
}
/// <summary>A plate's expected rectangular drop, with <see cref="Width"/> the shorter side.</summary>
+33 -4
View File
@@ -530,6 +530,33 @@ namespace OpenNest.IO
/// missing or invalid removal-minutes value falls back to <see cref="ClampRail.DefaultRemovalMinutes"/>.
/// </summary>
/// <summary>The saved support bed, or null when absent or invalid (a bad bed never fails the nest).</summary>
/// <summary>
/// The plate's sheet outline, or null (a rectangle) when the file has none or the value
/// is malformed or not a simple polygon; the latter adds a warning naming the plate.
/// </summary>
private SheetOutline ReadOutline(SheetOutlineDto dto, int plateId)
{
if (dto == null)
return null;
string problem = dto.Problem;
if (problem == null)
{
try
{
return new SheetOutline(
dto.Points.Select(p => new Vector(p.X, p.Y)),
dto.Obstacles?.Select(o => o.Select(p => new Vector(p.X, p.Y))));
}
catch (ArgumentException exception)
{
problem = exception.Message;
}
}
warnings.Add($"Plate {plateId}: its sheet outline could not be read ({problem}); "
+ "the plate loads as a rectangle of its saved size.");
return null;
}
private static SupportBed ReadSupportBed(SupportBedDto dto)
{
if (dto == null)
@@ -666,6 +693,7 @@ namespace OpenNest.IO
);
plate.GrainAngle = p.GrainAngle;
plate.CuttingParameters = CuttingParametersSerializer.FromDto(p.CuttingParameters);
plate.Outline = ReadOutline(p.Outline, p.Id);
// Clamp zones: older files have no key at all; an individually invalid
// entry is dropped rather than poisoning the whole nest.
@@ -719,7 +747,8 @@ namespace OpenNest.IO
// Cut-offs
if (p.CutOffs != null)
{
var sequence = new Dictionary<CutOff, IReadOnlyList<int>>();
var places = new Dictionary<CutOff, IReadOnlyList<int>>();
var firstPlaces = new Dictionary<CutOff, int>();
foreach (var cutoffDto in p.CutOffs)
{
@@ -739,12 +768,12 @@ namespace OpenNest.IO
plate.CutOffs.Add(cutoff);
if (cutoffDto.Segments is { Length: > 0 } segments)
sequence[cutoff] = segments;
places[cutoff] = segments;
else if (cutoffDto.Sequence is int index)
sequence[cutoff] = [index];
firstPlaces[cutoff] = index;
}
plate.RegenerateCutOffs(new CutOffSettings(), sequence);
plate.RegenerateCutOffs(new CutOffSettings(), places, firstPlaces);
}
nest.Plates.Add(plate);
+18 -3
View File
@@ -353,6 +353,7 @@ namespace OpenNest.IO
!KeepEmptyPlates
&& plate.Parts.Count(p => !p.BaseDrawing.IsCutOff) == 0
&& plate.CutOffs.Count == 0
&& plate.Outline == null
&& (
(plate.ClampZones.Count == 0 && plate.RemovedClamps.Count == 0)
|| DefaultsRecreateClamps(plate)
@@ -395,12 +396,12 @@ namespace OpenNest.IO
foreach (var cutoff in plate.CutOffs)
{
// Each trimmed segment is its own part with its own place in the sequence.
// Every place is written, so a reader regenerating a different number of
// segments still knows how many places the saved ones held.
var segments = CutOffSegments.Of(plate, cutoff)
.Select(segment => plate.Parts.IndexOf(segment.Part))
.Where(index => index >= 0)
.ToArray();
var consecutive = segments.Length == 0
|| segments.SequenceEqual(Enumerable.Range(segments.Min(), segments.Length));
cutoffs.Add(
new CutOffDto
@@ -411,7 +412,7 @@ namespace OpenNest.IO
StartLimit = cutoff.StartLimit,
EndLimit = cutoff.EndLimit,
Sequence = segments.Length > 0 ? segments.Min() : null,
Segments = consecutive ? null : segments,
Segments = segments.Length > 0 ? segments : null,
HeadLockOvercut = cutoff.HeadLockOvercut,
FarEdgeFirst = cutoff.FarEdgeFirst,
}
@@ -461,12 +462,26 @@ namespace OpenNest.IO
: ToDto(plate.ClampRail),
StockItemCodes = plate.StockItemCodes.Count == 0 ? null : plate.StockItemCodes.ToList(),
ExpectedRemnant = ToDto(PlateRemnant.Expected(plate, nest.MinimumRemnantDimension)),
Outline = ToDto(plate.Outline),
}
);
}
return list;
}
private static SheetOutlineDto ToDto(SheetOutline outline) =>
outline == null
? null
: new SheetOutlineDto
{
Points = outline.Points.Select(p => new PointDto { X = p.X, Y = p.Y }).ToList(),
Obstacles = outline.Obstacles.Count == 0
? null
: outline.Obstacles
.Select(o => o.Select(p => new PointDto { X = p.X, Y = p.Y }).ToList())
.ToList(),
};
private static ExpectedRemnantDto ToDto(PlateRemnant remnant) =>
remnant == null
? null
+8
View File
@@ -66,6 +66,8 @@ namespace OpenNest.Mcp.Tools
var strategy = ResolveStrategy(engine);
if (strategy == null)
return EngineError(engine);
if (PlateFillService.Refusal(plate) is { } refusal)
return $"Error: {refusal} Nothing committed.";
var countBefore = plate.Parts.Count;
var item = new NestItem { Drawing = drawing, Quantity = quantity };
@@ -121,6 +123,8 @@ namespace OpenNest.Mcp.Tools
var strategy = ResolveStrategy(engine);
if (strategy == null)
return EngineError(engine);
if (PlateFillService.Refusal(plate) is { } refusal)
return $"Error: {refusal} Nothing committed.";
var countBefore = plate.Parts.Count;
var item = new NestItem { Drawing = drawing, Quantity = quantity };
@@ -173,6 +177,8 @@ namespace OpenNest.Mcp.Tools
var strategy = ResolveStrategy(engine);
if (strategy == null)
return EngineError(engine);
if (PlateFillService.Refusal(plate) is { } refusal)
return $"Error: {refusal} Nothing committed.";
var finder = RemnantFinder.FromPlate(plate);
var remnants = finder.FindRemnants();
@@ -240,6 +246,8 @@ namespace OpenNest.Mcp.Tools
var strategy = ResolveStrategy(engine);
if (strategy == null)
return EngineError(engine);
if (PlateFillService.Refusal(plate) is { } refusal)
return $"Error: {refusal} Nothing committed.";
var parsed = ParseItems(drawingNames, quantities);
if (parsed.error != null)
@@ -22,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
Execute(factory.DatabasePath, "PRAGMA user_version=14");
Execute(factory.DatabasePath, "PRAGMA user_version=15");
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -70,6 +70,36 @@ public sealed class DrawingLibraryQueryTests
Assert.Equal(page.Items.Count, page.Total);
}
[Theory]
[InlineData("materialName=Carbon%20Steel", "Any|Any thickness|Steel")]
[InlineData("materialName=Carbon%20Steel&materialGrade=A36&thickness=0.25", "Any|Any thickness|Steel")]
[InlineData("materialName=Stainless&thickness=0.25", "Any")]
[InlineData("materialName=Carbon%20Steel&thickness=0.1875", "Any|Any thickness")]
[InlineData("thickness=0", "Any|Any thickness")]
public async Task UnsetMaterialGradeOrThickness_MatchesAnyFilter(string query, string expected)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
foreach (var (name, material, grade, thickness) in new[]
{
("Any", "", "", 0.0),
("Any thickness", "Carbon Steel", "", 0.0),
("Steel", "Carbon Steel", "A36", 0.25),
})
{
var record = Record(name);
record.MaterialName = material;
record.MaterialGrade = grade;
record.Thickness = thickness;
await Upload(client, record, Package(name));
}
var page = await DrawingLibraryApiTests.Query(client, query + "&sort=name&order=asc");
Assert.Equal(expected, string.Join("|", page.Items.Select(r => r.Name)));
Assert.Equal(page.Items.Count, page.Total);
}
[Theory]
[InlineData("name", false)]
[InlineData("name", true)]
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
Assert.Equal(before, Digest(factory.DatabasePath));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
Assert.Equal(before, Digest(factory.DatabasePath));
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class MaterialListTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListMaterials());
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
@@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests
Assert.Equal("Stainless", record.Material);
Assert.Equal("", record.MaterialGrade);
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
@@ -0,0 +1,200 @@
using System.Net;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Server.Tests;
/// <summary>
/// <c>GET /api/nests/stock-item-codes</c> lists the ledger item codes of open nests from codes
/// stored at each archive write, so Auto Nest can leave them out before ML holds exist.
/// </summary>
public sealed class NestStockCodesTests
{
[Fact]
public async Task OpenNests_ListTheirCodesOncePerNest()
{
using var session = new Session();
var first = await session.SaveAsync("Nest A", NestStatus.Quote, ["SHEET-1", "SHEET-1"], ["DROP-7"]);
var second = await session.SaveAsync("Nest B", NestStatus.ToBeCut, ["SHEET-1"]);
await session.SaveAsync("Uncoded", NestStatus.Quote);
var uses = await session.Repository.GetStockCodesInUseAsync(Guid.Empty);
Assert.Equal(
[("DROP-7", first.Id, "Nest A"), ("SHEET-1", first.Id, "Nest A"), ("SHEET-1", second.Id, "Nest B")],
uses.Select(u => (u.ItemCode, u.NestId, u.NestName)).ToArray());
Assert.Equal(NestStatus.ToBeCut, uses[2].Status);
}
[Fact]
public async Task Exclude_LeavesOutTheNestBeingEdited()
{
using var session = new Session();
var editing = await session.SaveAsync("Editing", NestStatus.Quote, ["SHEET-1"]);
await session.SaveAsync("Other", NestStatus.Quote, ["SHEET-2"]);
var uses = await session.Repository.GetStockCodesInUseAsync(editing.Id);
Assert.Equal(["SHEET-2"], uses.Select(u => u.ItemCode));
}
[Fact]
public async Task CutAndDeletedNests_AreNotOpen()
{
using var session = new Session();
var draft = await session.SaveAsync("Draft", NestStatus.Draft, ["D-1"]);
var cut = await session.SaveAsync("Cut", NestStatus.Quote, ["C-1"]);
var deleted = await session.SaveAsync("Deleted", NestStatus.Quote, ["X-1"]);
cut.Status = NestStatus.HasBeenCut;
await session.Repository.UpdateMetadataAsync(cut.Id, cut, cut.Revision);
await session.Repository.DeleteAsync(deleted.Id, deleted.Revision);
Assert.Equal(["D-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
Assert.Equal(draft.Id, Assert.Single(await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).NestId);
// Recovering a nest brings its codes back without rereading the archive.
var gone = (await session.Repository.GetDeletedMetadataAsync(deleted.Id))!;
await session.Repository.RecoverAsync(deleted.Id, gone.Revision);
Assert.Equal(["D-1", "X-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
}
[Fact]
public async Task SavingAndRestoring_ReplaceTheStoredCodes()
{
using var session = new Session();
var record = await session.SaveAsync("Nest", NestStatus.Quote, ["OLD-1"]);
var nest = Nest("Nest", NestStatus.Quote, ["NEW-1"]);
var updated = await session.Repository.UpdateFileAsync(record.Id, NestApiTests.Archive(nest),
Record(record.Id, nest), record.Revision);
Assert.Equal(["NEW-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
await session.Repository.RestoreAsync(record.Id, 1, updated.Revision);
Assert.Equal(["OLD-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
}
[Fact]
public async Task InvalidExclude_IsABadRequest()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/nests/stock-item-codes?exclude=nope");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task OlderServer_IsNotSupported()
{
using var http = new HttpClient(new StatusHandler(HttpStatusCode.NotFound)) { BaseAddress = new Uri("http://old/") };
using var repository = new RemoteNestRepository(http, "http://old/");
await Assert.ThrowsAsync<NotSupportedException>(() => repository.GetStockCodesInUseAsync(Guid.Empty));
}
[Fact]
public void V13Upgrade_ReadsTheCodesOfEveryStoredNest()
{
using var factory = new ServerFactory();
var open = Guid.NewGuid();
var deleted = Guid.NewGuid();
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
var nest = Nest("Open", NestStatus.Quote, ["SHEET-1"]);
database.Insert(open, Record(open, nest), NestApiTests.Archive(nest));
var gone = Nest("Gone", NestStatus.Quote, ["SHEET-2"]);
var record = database.Insert(deleted, Record(deleted, gone), NestApiTests.Archive(gone));
database.SetDeleted(deleted, true, record.Revision);
}
Execute(factory.DatabasePath, "DROP TABLE nest_stock_codes; PRAGMA user_version=13;");
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(["SHEET-1"], database.StockCodesInUse(null).Select(u => u.ItemCode));
var record = database.Get(deleted, deleted: true)!;
database.SetDeleted(deleted, false, record.Revision);
Assert.Equal(["SHEET-1", "SHEET-2"], database.StockCodesInUse(null).Select(u => u.ItemCode));
}
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static Nest Nest(string name, NestStatus status, params string[][] plateCodes)
{
var nest = NestApiTests.SyntheticNest();
nest.Name = name;
nest.Status = status;
var drawing = nest.Drawings.Single();
nest.Plates.Clear();
foreach (var codes in plateCodes.DefaultIfEmpty(Array.Empty<string>()))
{
var plate = new Plate(new Size(48, 96)) { Quantity = System.Math.Max(codes.Length, 1) };
plate.Parts.Add(new Part(drawing, new Vector(2, 3)));
plate.StockItemCodes = codes;
nest.Plates.Add(plate);
}
return nest;
}
private static NestRecord Record(Guid id, Nest nest) => new()
{
Id = id,
Name = nest.Name,
Status = nest.Status,
DateCreated = nest.DateCreated,
DateModified = nest.DateCreated,
};
private static void Execute(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
private static object? Scalar(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
return command.ExecuteScalar();
}
private sealed class Session : IDisposable
{
private readonly ServerFactory factory = new();
private readonly HttpClient client;
public Session()
{
client = factory.CreateClient();
Repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
}
public RemoteNestRepository Repository { get; }
public Task<NestRecord> SaveAsync(string name, NestStatus status, params string[][] plateCodes)
{
var nest = Nest(name, status, plateCodes);
return Repository.UploadAsync(NestApiTests.Archive(nest), Record(Guid.NewGuid(), nest));
}
public void Dispose()
{
Repository.Dispose();
client.Dispose();
factory.Dispose();
}
}
private sealed class StatusHandler(HttpStatusCode status) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(status));
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ public sealed class NestUnitsTests
Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units));
Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units);
Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, Units units) => new()
+19 -1
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
namespace OpenNest.Server.Tests;
@@ -10,15 +11,19 @@ namespace OpenNest.Server.Tests;
/// 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.
/// The update mirror never refreshes on its own and reaches GitHub only through
/// <c>updateHandler</c> (by default a handler that fails every request); tests refresh it explicitly.
/// Disposing the factory disposes the host (and its database) before deleting the directory.
/// </summary>
public sealed class ServerFactory : WebApplicationFactory<global::Program>
{
private bool _cleanedUp;
private NestDatabase? _database;
private readonly HttpMessageHandler? _updateHandler;
public ServerFactory(string? databasePath = null)
public ServerFactory(string? databasePath = null, HttpMessageHandler? updateHandler = null)
{
_updateHandler = updateHandler;
DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DirectoryPath);
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
@@ -36,6 +41,13 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
services.RemoveAll<NestDatabase>();
// Register an externally owned instance; this factory closes it before deleting its file.
services.AddSingleton(_database = new NestDatabase(DatabasePath, pooling: false));
services.RemoveAll<UpdateMirror>();
var hosted = services.Where(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(UpdateMirrorService)).ToList();
foreach (var descriptor in hosted)
services.Remove(descriptor);
services.AddSingleton(_ => new UpdateMirror(
Path.Combine(DirectoryPath, "updates"), new HttpClient(_updateHandler ?? new OfflineHandler())));
});
}
@@ -50,4 +62,10 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
if (Directory.Exists(DirectoryPath))
Directory.Delete(DirectoryPath, recursive: true);
}
private sealed class OfflineHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
throw new HttpRequestException("Tests never reach GitHub.");
}
}
@@ -151,7 +151,7 @@ public sealed class SetReleaseDrawingTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
var kept = upgraded.GetRelease(releaseId)!;
Assert.Empty(kept.Drawings);
Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity));
+1 -1
View File
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'"));
var release = upgraded.GetRelease(releaseId)!;
Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone));
+1 -1
View File
@@ -67,7 +67,7 @@ public sealed class SharedSettingsTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListSharedSettings());
}
+305
View File
@@ -0,0 +1,305 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
namespace OpenNest.Server.Tests;
/// <summary>
/// The desktop update feed the server mirrors from GitHub (<c>/updates/{channel}/</c>): which
/// release each channel serves, verified downloads, cleanup, restarts and the HTTP contract.
/// GitHub is a synthetic handler; nothing here reaches the network.
/// </summary>
public sealed class UpdateMirrorTests
{
private const string ReleasesUrl = "https://api.github.com/repos/ajisaacs/OpenNest/releases?per_page=10";
[Fact]
public async Task Refresh_MirrorsNothingUntilAClientAsksForAChannel()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
await mirror.RefreshAsync();
Assert.Empty(github.Requests);
Assert.NotNull(mirror.GetStatus().LastChecked);
}
[Fact]
public async Task Api_ServesTheStableFeedAndPackageAfterTheFirstRequestWakesARefresh()
{
var github = new FakeGitHub()
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true)
.WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var factory = new ServerFactory(updateHandler: github);
using var client = factory.CreateClient();
var mirror = factory.Services.GetRequiredService<UpdateMirror>();
var woken = 0;
mirror.RefreshRequested += () => woken++;
// Velopack appends its own query; the route ignores it.
var before = await client.GetAsync("/updates/stable/releases.win.json?arch=x64&localVersion=0.6.0");
Assert.Equal(HttpStatusCode.ServiceUnavailable, before.StatusCode);
Assert.Equal(1, woken);
await mirror.RefreshAsync();
var feed = await client.GetFromJsonAsync<JsonElement>("/updates/stable/releases.win.json");
var asset = Assert.Single(feed.GetProperty("Assets").EnumerateArray().ToList());
Assert.Equal("0.6.1", asset.GetProperty("Version").GetString());
Assert.Equal("OpenNest-0.6.1-full.nupkg", asset.GetProperty("FileName").GetString());
Assert.Equal(FakeGitHub.Hash("0.6.1"), asset.GetProperty("SHA256").GetString());
Assert.Equal(FakeGitHub.Package("0.6.1"), await client.GetByteArrayAsync("/updates/stable/OpenNest-0.6.1-full.nupkg"));
// The development build is never fetched for a stable-only shop.
Assert.DoesNotContain(github.Requests, r => r.Contains("dev-build"));
Assert.Equal(1, github.Requests.Count(r => r.StartsWith("https://api.github.com/", StringComparison.Ordinal)));
var status = await client.GetFromJsonAsync<JsonElement>("/api/updates");
Assert.Equal("0.6.1", status.GetProperty("stable").GetString());
Assert.Equal(JsonValueKind.Null, status.GetProperty("dev").ValueKind);
Assert.Equal(JsonValueKind.Null, status.GetProperty("lastError").ValueKind);
}
[Fact]
public async Task Api_RefusesUnknownChannelsAndFilesTheFeedDoesNotName()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var factory = new ServerFactory(updateHandler: github);
using var client = factory.CreateClient();
var mirror = factory.Services.GetRequiredService<UpdateMirror>();
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/beta/releases.win.json")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/dev/OpenNest-0.6.1-full.nupkg")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/stable/OpenNest-0.6.0-full.nupkg")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/stable/..%2F..%2Fnests.db")).StatusCode);
}
[Fact]
public async Task Dev_ServesTheHigherOfTheDevelopmentBuildAndTheNewestRelease()
{
var github = new FakeGitHub()
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true)
.WithRelease("v0.6.1", "0.6.1", prerelease: false)
.WithRelease("v0.5.1", null, prerelease: true); // Older pre-release with no feed.
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Dev);
await mirror.RefreshAsync();
Assert.Equal("0.7.0-dev.2020", mirror.GetStatus().Dev);
// A full release published before the next development build outranks it.
github.Clear()
.WithRelease("v0.7.0", "0.7.0", prerelease: false)
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true);
await mirror.RefreshAsync();
Assert.Equal("0.7.0", mirror.GetStatus().Dev);
Assert.Null(mirror.GetStatus().Stable);
Assert.Equal(new[] { "OpenNest-0.7.0-full.nupkg" }, PackageFiles(directory.Path));
}
[Fact]
public async Task Refresh_DownloadsEachPackageOnceAndDeletesSupersededOnes()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
await mirror.RefreshAsync();
Assert.Equal(1, github.Requests.Count(r => r.EndsWith(".nupkg", StringComparison.Ordinal)));
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false).WithRelease("v0.6.1", "0.6.1", prerelease: false);
await mirror.RefreshAsync();
Assert.Equal("0.6.2", mirror.GetStatus().Stable);
Assert.Equal(new[] { "OpenNest-0.6.2-full.nupkg" }, PackageFiles(directory.Path));
Assert.Null(mirror.GetPackagePath(UpdateMirror.Stable, "OpenNest-0.6.1-full.nupkg"));
}
[Fact]
public async Task Refresh_RejectsAPackageThatDoesNotMatchItsFeedAndKeepsTheCurrentOne()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false, corrupt: true);
await mirror.RefreshAsync();
var status = mirror.GetStatus();
Assert.Equal("0.6.1", status.Stable);
Assert.Contains("SHA-256", status.LastError);
Assert.Equal(new[] { "OpenNest-0.6.1-full.nupkg" }, PackageFiles(directory.Path));
}
[Fact]
public async Task Refresh_KeepsServingWhenGitHubIsUnreachable()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
github.Clear();
await mirror.RefreshAsync();
Assert.NotNull(mirror.GetStatus().LastError);
Assert.NotNull(mirror.GetFeed(UpdateMirror.Stable));
}
[Fact]
public async Task Restart_ServesTheSavedFeedAndKeepsRefreshingIt()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var first = new UpdateMirror(directory.Path, new HttpClient(github));
first.GetFeed(UpdateMirror.Stable);
await first.RefreshAsync();
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false);
var restarted = new UpdateMirror(directory.Path, new HttpClient(github));
Assert.Equal("0.6.1", restarted.GetStatus().Stable);
Assert.NotNull(restarted.GetPackagePath(UpdateMirror.Stable, "OpenNest-0.6.1-full.nupkg"));
await restarted.RefreshAsync();
Assert.Equal("0.6.2", restarted.GetStatus().Stable);
}
[Fact]
public async Task Refresh_StopsMirroringAChannelNobodyHasAskedForInAWeek()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var now = DateTimeOffset.Parse("2026-10-28T12:00:00Z");
var mirror = new UpdateMirror(directory.Path, new HttpClient(github), clock: () => now);
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
var requests = github.Requests.Count;
now += UpdateMirror.WantedFor + TimeSpan.FromMinutes(1);
await mirror.RefreshAsync();
Assert.Equal(requests, github.Requests.Count);
}
[Theory]
[InlineData("0.7.0", "0.6.1", 1)]
[InlineData("0.7.0-dev.2020", "0.6.1", 1)]
[InlineData("0.7.0", "0.7.0-dev.2020", 1)]
[InlineData("0.7.0-dev.10", "0.7.0-dev.9", 1)]
[InlineData("0.7.0-dev.9", "0.7.0-dev", 1)]
[InlineData("0.7.0-beta", "0.7.0-alpha", 1)]
[InlineData("0.7.0-alpha", "0.7.0-1", 1)]
[InlineData("0.7.0+abc", "0.7.0", 0)]
[InlineData("0.10.0", "0.9.0", 1)]
[InlineData("0.6.1", "not-a-version", 1)]
public void SemanticVersion_FollowsSemVerPrecedence(string higher, string lower, int expected)
{
Assert.Equal(expected, SemanticVersion.Compare(higher, lower));
Assert.Equal(-expected, SemanticVersion.Compare(lower, higher));
}
[Theory]
[InlineData(null, true)]
[InlineData("", true)]
[InlineData("on", true)]
[InlineData("off", false)]
[InlineData(" OFF ", false)]
public void Options_UpdatesAreOnUnlessTurnedOff(string? value, bool expected) =>
Assert.Equal(expected, ServerOptions.UpdatesEnabled(value));
private static string[] PackageFiles(string directory) =>
Directory.GetFiles(Path.Combine(directory, "packages")).Select(Path.GetFileName).OfType<string>().ToArray();
private sealed class TempDirectory : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "opennest-updates-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(Path))
Directory.Delete(Path, recursive: true);
}
}
/// <summary>The GitHub release list, feeds and packages for synthetic versions, newest release first.</summary>
private sealed class FakeGitHub : HttpMessageHandler
{
private readonly List<object> _releases = new();
private readonly Dictionary<string, byte[]> _files = new(StringComparer.Ordinal);
public List<string> Requests { get; } = new();
public static byte[] Package(string version) => Encoding.UTF8.GetBytes($"package {version}");
public static string Hash(string version) => Convert.ToHexString(SHA256.HashData(Package(version)));
public FakeGitHub Clear()
{
_releases.Clear();
_files.Clear();
return this;
}
/// <param name="version">The feed's version, or null for a release without a Velopack feed.</param>
public FakeGitHub WithRelease(string tag, string? version, bool prerelease, bool corrupt = false)
{
var assets = new List<object>();
if (version is not null)
{
var package = $"OpenNest-{version}-full.nupkg";
var feed = JsonSerializer.Serialize(new
{
Assets = new[]
{
new { PackageId = "OpenNest", Version = version, Type = "Full", FileName = package, SHA1 = "00", SHA256 = Hash(version), Size = (long)Package(version).Length },
},
});
assets.Add(Asset(tag, "releases.win.json", Encoding.UTF8.GetBytes(feed)));
var bytes = Package(version);
assets.Add(Asset(tag, package, corrupt ? bytes.Select(b => (byte)(b ^ 1)).ToArray() : bytes));
}
assets.Add(Asset(tag, $"OpenNest.{tag}.win-x64.zip", new byte[] { 1 }));
_releases.Add(new { tag_name = tag, draft = false, prerelease, assets });
return this;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
if (url == ReleasesUrl)
{
Assert.Contains("OpenNest-Server", request.Headers.UserAgent.ToString());
if (_releases.Count == 0)
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden));
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(_releases) });
}
return Task.FromResult(_files.TryGetValue(url, out var bytes)
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }
: new HttpResponseMessage(HttpStatusCode.NotFound));
}
private object Asset(string tag, string name, byte[] contents)
{
var url = $"https://github.com/ajisaacs/OpenNest/releases/download/{tag}/{name}";
_files[url] = contents;
return new { name, browser_download_url = url };
}
}
}
+22 -5
View File
@@ -87,7 +87,6 @@ internal static class NestArchive
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToList();
// NestReader: codes end at the first blank entry and at the plate's quantity.
var platesOut = plates
.Select((p, index) =>
{
@@ -98,10 +97,7 @@ internal static class NestArchive
Width = System.Math.Min(size.Width, size.Length),
Length = System.Math.Max(size.Width, size.Length),
Quantity = p.Quantity,
StockItemCodes = (p.StockItemCodes ?? new())
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
.Take(System.Math.Max(p.Quantity, 0))
.ToList(),
StockItemCodes = PlateCodes(p).ToList(),
ExpectedRemnant = ReadRemnant(p.ExpectedRemnant),
};
})
@@ -121,6 +117,27 @@ internal static class NestArchive
return null;
}
/// <summary>
/// The distinct ledger item codes the archive's plates are coded with, in plate order, by
/// the same rules as <see cref="ReadSheets"/>; empty when the bytes are unreadable.
/// </summary>
public static IReadOnlyList<string> ReadStockItemCodes(byte[] file)
{
if (Read(file, out var nest) is not null)
return Array.Empty<string>();
var drawingIds = (nest.Drawings ?? new()).Select(d => d.Id).ToHashSet();
return NestPlates(nest, drawingIds)
.SelectMany(p => PlateCodes(p))
.Distinct(StringComparer.Ordinal)
.ToList();
}
// NestReader: codes end at the first blank entry and at the plate's quantity.
private static IEnumerable<string> PlateCodes(PlateJson plate) =>
(plate.StockItemCodes ?? new())
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
.Take(System.Math.Max(plate.Quantity, 0));
/// <summary>The archive's units, for backfilling records stored before they were kept; inches when unreadable.</summary>
public static Units ReadUnits(byte[] file) => Read(file, out var nest) is null ? UnitsOf(nest) : Units.Inches;
+4 -3
View File
@@ -138,9 +138,10 @@ public sealed partial class NestDatabase
if (!query.IncludeArchived) conditions.Add("archived=0");
if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")");
if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer");
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("materialKey=$material");
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade");
if (query.Thickness is not null) conditions.Add("thickness=$thickness");
// A drawing saved without a material, grade or thickness (0) suits any, so it matches every filter value.
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("(materialKey=$material OR materialKey='')");
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("(gradeKey=$grade OR gradeKey='')");
if (query.Thickness is not null) conditions.Add("(thickness=$thickness OR thickness=0)");
if (query.CutType is not null) conditions.Add("cutType=$cutType");
if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst");
var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
@@ -0,0 +1,94 @@
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
// The ledger item codes each nest's current archive is coded with, kept beside the record at
// every archive write so listing the codes in use never reopens an archive.
public sealed partial class NestDatabase
{
// v14: one row per distinct code of a nest's current archive.
private const string StockCodeSchema = """
CREATE TABLE IF NOT EXISTS nest_stock_codes (
id TEXT NOT NULL,
code TEXT NOT NULL,
PRIMARY KEY (id, code)
);
CREATE INDEX IF NOT EXISTS ix_nest_stock_codes_code ON nest_stock_codes(code);
""";
/// <summary>
/// The item codes on the current archives of every non-deleted Draft, Quote or To Be Cut
/// nest except <paramref name="excludeId"/>, ordered by code then nest name.
/// </summary>
public IReadOnlyList<NestStockCodeUse> StockCodesInUse(Guid? excludeId)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = """
SELECT c.code, n.id, n.name, n.status FROM nest_stock_codes c
JOIN nests n ON n.id = c.id
WHERE n.deleted = 0 AND n.status IN ('Draft', 'Quote', 'ToBeCut') AND n.id <> $exclude
ORDER BY c.code, n.name COLLATE NOCASE, n.id
""";
command.Parameters.AddWithValue("$exclude", (excludeId ?? Guid.Empty).ToString());
var uses = new List<NestStockCodeUse>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
uses.Add(new NestStockCodeUse
{
ItemCode = reader.GetString(0),
NestId = Guid.Parse(reader.GetString(1)),
NestName = reader.GetString(2),
Status = Enum.TryParse<NestStatus>(reader.GetString(3), true, out var status) ? status : NestStatus.Quote,
});
}
return uses;
}
}
// Called only under _sync with the caller's transaction, whenever a nest's current archive changes.
private void WriteStockCodes(Guid id, byte[] file, SqliteTransaction transaction)
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "DELETE FROM nest_stock_codes WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString());
command.ExecuteNonQuery();
InsertStockCodes(command, id, NestArchive.ReadStockItemCodes(file));
}
private static void InsertStockCodes(SqliteCommand command, Guid id, IEnumerable<string> codes)
{
command.CommandText = "INSERT OR IGNORE INTO nest_stock_codes (id, code) VALUES ($id, $code)";
command.Parameters.Clear();
command.Parameters.AddWithValue("$id", id.ToString());
var code = command.Parameters.Add("$code", SqliteType.Text);
foreach (var value in codes)
{
code.Value = value;
command.ExecuteNonQuery();
}
command.Parameters.Clear();
}
// v14: reads each current archive once, deleted ones too so a recovered nest keeps its codes.
private static void AddStockCodes(SqliteCommand command)
{
command.CommandText = StockCodeSchema;
command.ExecuteNonQuery();
var codes = new List<(Guid Id, IReadOnlyList<string> Codes)>();
command.CommandText = "SELECT id, file FROM nests";
using (var reader = command.ExecuteReader())
{
while (reader.Read())
codes.Add((Guid.Parse(reader.GetString(0)), NestArchive.ReadStockItemCodes((byte[])reader.GetValue(1))));
}
foreach (var (id, nestCodes) in codes.Where(c => c.Codes.Count > 0))
InsertStockCodes(command, id, nestCodes);
command.CommandText = "PRAGMA user_version=14";
command.ExecuteNonQuery();
}
}
+5 -1
View File
@@ -162,6 +162,7 @@ public sealed partial class NestDatabase : IDisposable
""";
AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery();
WriteStockCodes(id, file, transaction);
Snapshot(id, transaction);
transaction.Commit();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
@@ -220,6 +221,7 @@ public sealed partial class NestDatabase : IDisposable
command.Parameters.AddWithValue("$revision", checked(currentRevision + 1));
command.Parameters.AddWithValue("$source", (object?)sourceRevision ?? DBNull.Value);
command.ExecuteNonQuery();
WriteStockCodes(id, file, transaction);
Snapshot(id, transaction);
}
@@ -342,7 +344,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 13) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 14) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -435,6 +437,8 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = SharedSettingSchema;
command.ExecuteNonQuery();
}
if (version < 14)
AddStockCodes(command);
transaction.Commit();
}
}
+15
View File
@@ -24,6 +24,14 @@ builder.Services.ConfigureHttpJsonOptions(options =>
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
// Desktop update feed mirrored from GitHub beside the database; OPENNEST_UPDATES=off turns it off.
var updatesEnabled = ServerOptions.UpdatesEnabled(Environment.GetEnvironmentVariable(ServerOptions.UpdatesVariable));
if (updatesEnabled)
{
builder.Services.AddSingleton(_ => new UpdateMirror(ServerOptions.UpdatesPath(databasePath), new HttpClient()));
builder.Services.AddHostedService<UpdateMirrorService>();
}
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
var app = builder.Build();
@@ -59,6 +67,11 @@ app.MapGet("/api/nests/{id:guid}/sheets", (Guid id, NestDatabase db) =>
: Results.Ok(sheets);
});
// Ledger item codes on open nests' plates, so Auto Nest leaves them out until ML holds them.
// The nest being edited passes its own id as exclude.
app.MapGet("/api/nests/stock-item-codes", (Guid? exclude, NestDatabase db) =>
Results.Ok(db.StockCodesInUse(exclude)));
// Next generated nest name for a year; one counter per year shared by every client.
app.MapPost("/api/nest-names/next", (int year, NestDatabase db) =>
{
@@ -154,6 +167,8 @@ app.MapDrawingSets();
app.MapReleases();
app.MapMaterials();
app.MapSettings();
if (updatesEnabled)
app.MapUpdates();
app.Run();
+12
View File
@@ -4,6 +4,18 @@ namespace OpenNest.Server;
public static class ServerOptions
{
public const string DatabaseVariable = "OPENNEST_DB";
public const string UpdatesVariable = "OPENNEST_UPDATES";
/// <summary>
/// True unless <c>OPENNEST_UPDATES</c> is <c>off</c>: the server mirrors the desktop
/// app's update feed from GitHub (see <see cref="UpdateMirror"/>).
/// </summary>
public static bool UpdatesEnabled(string? value) =>
!string.Equals(value?.Trim(), "off", StringComparison.OrdinalIgnoreCase);
/// <summary>The mirrored update feed lives in an <c>updates</c> directory beside the database.</summary>
public static string UpdatesPath(string databasePath) =>
Path.Combine(Path.GetDirectoryName(Path.GetFullPath(databasePath))!, "updates");
/// <summary>
/// The database path: <c>--database=&lt;path&gt;</c> or <c>--database &lt;path&gt;</c>, then
+421
View File
@@ -0,0 +1,421 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Server;
/// <summary>
/// A caching mirror of the desktop app's Velopack feed, so the workstations of one shop check
/// for updates against their nest server instead of each calling GitHub. One refresh makes a
/// single GitHub API request (the release list), reads the feeds attached to the chosen releases
/// and downloads each channel's newest full package once, verified against the feed's size and
/// SHA-256, into <see cref="DirectoryPath"/>.
/// </summary>
/// <remarks>
/// Channels: <c>stable</c> serves the newest full release; <c>dev</c> serves whichever is higher
/// of that and the newest pre-release with a feed (the rolling development build). A channel is
/// mirrored only while clients ask for it (within <see cref="WantedFor"/>), so a server whose
/// workstations never take development builds never downloads one. Feeds are served with the
/// package file names Velopack wrote, unchanged: clients download <c>{feed URL}/{FileName}</c>.
/// </remarks>
public sealed class UpdateMirror
{
public const string Stable = "stable";
public const string Dev = "dev";
public const string FeedFileName = "releases.win.json";
public const string DefaultRepository = "ajisaacs/OpenNest";
/// <summary>How long a channel stays mirrored after the last client asked for it.</summary>
public static readonly TimeSpan WantedFor = TimeSpan.FromDays(7);
private static readonly string[] Channels = { Stable, Dev };
private static readonly JsonSerializerOptions FeedOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
private readonly string _repository;
private readonly Func<DateTimeOffset> _clock;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private readonly object _stateLock = new();
private readonly Dictionary<string, ChannelFeed> _feeds = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _wanted = new(StringComparer.Ordinal);
private DateTimeOffset? _lastChecked;
private string? _lastError;
public UpdateMirror(string directoryPath, HttpClient http, string repository = DefaultRepository, Func<DateTimeOffset>? clock = null)
{
DirectoryPath = directoryPath;
_http = http;
_repository = repository;
_clock = clock ?? (() => DateTimeOffset.UtcNow);
Directory.CreateDirectory(PackagesPath);
LoadSavedFeeds();
}
/// <summary>Raised when a client asks for a channel that holds no feed yet, so the next refresh need not wait.</summary>
public event Action? RefreshRequested;
public string DirectoryPath { get; }
private string PackagesPath => Path.Combine(DirectoryPath, "packages");
public static bool IsChannel(string channel) => Array.IndexOf(Channels, channel) >= 0;
/// <summary>
/// The channel's feed for a client, or null while nothing is mirrored for it (the route answers
/// 503 and the client falls back to GitHub). Every call marks the channel as wanted.
/// </summary>
public byte[]? GetFeed(string channel)
{
bool missing;
byte[]? feed;
lock (_stateLock)
{
_wanted[channel] = _clock();
feed = _feeds.TryGetValue(channel, out var current) ? current.Json : null;
missing = feed is null;
}
if (missing)
RefreshRequested?.Invoke();
return feed;
}
/// <summary>The path of a package a channel's feed currently names, or null for any other file name.</summary>
public string? GetPackagePath(string channel, string fileName)
{
lock (_stateLock)
{
if (!_feeds.TryGetValue(channel, out var feed) || feed.FileName != fileName)
return null;
}
var path = Path.Combine(PackagesPath, fileName);
return File.Exists(path) ? path : null;
}
public UpdateMirrorStatus GetStatus()
{
lock (_stateLock)
{
return new UpdateMirrorStatus
{
Stable = _feeds.TryGetValue(Stable, out var stable) ? stable.Version : null,
Dev = _feeds.TryGetValue(Dev, out var dev) ? dev.Version : null,
LastChecked = _lastChecked,
LastError = _lastError,
};
}
}
/// <summary>
/// Brings every wanted channel up to date with GitHub. Failures are recorded in
/// <see cref="GetStatus"/> and leave the feeds already mirrored in place.
/// </summary>
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
await _refreshLock.WaitAsync(cancellationToken);
try
{
var wanted = WantedChannels();
if (wanted.Count > 0)
await RefreshChannelsAsync(wanted, cancellationToken);
lock (_stateLock)
{
_lastChecked = _clock();
_lastError = null;
}
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
lock (_stateLock)
{
_lastChecked = _clock();
_lastError = ex.Message;
}
}
finally
{
_refreshLock.Release();
}
}
private List<string> WantedChannels()
{
var now = _clock();
lock (_stateLock)
return Channels.Where(c => _wanted.TryGetValue(c, out var at) && now - at <= WantedFor).ToList();
}
private async Task RefreshChannelsAsync(List<string> channels, CancellationToken cancellationToken)
{
var releases = await GetJsonAsync<List<GitHubRelease>>(
$"https://api.github.com/repos/{_repository}/releases?per_page=10", cancellationToken) ?? new();
var published = releases.Where(r => !r.Draft && FeedAsset(r) is not null).ToList();
// The API lists the newest releases first.
var stable = published.FirstOrDefault(r => !r.Prerelease);
var preRelease = published.FirstOrDefault(r => r.Prerelease);
var feeds = new Dictionary<GitHubRelease, Candidate?>();
foreach (var channel in channels)
{
var sources = channel == Stable ? new[] { stable } : new[] { stable, preRelease };
Candidate? best = null;
foreach (var release in sources.OfType<GitHubRelease>())
{
if (!feeds.TryGetValue(release, out var candidate))
feeds[release] = candidate = await ReadCandidateAsync(release, cancellationToken);
if (candidate is not null && (best is null || SemanticVersion.Compare(candidate.Asset.Version, best.Asset.Version) > 0))
best = candidate;
}
if (best is not null)
await PublishAsync(channel, best, cancellationToken);
}
DeleteUnreferencedPackages();
}
private async Task<Candidate?> ReadCandidateAsync(GitHubRelease release, CancellationToken cancellationToken)
{
var feed = await GetJsonAsync<VelopackFeed>(FeedAsset(release)!.DownloadUrl, cancellationToken);
var full = feed?.Assets?
.Where(a => a.Type == "Full" && IsPlainFileName(a.FileName) && SemanticVersion.IsValid(a.Version))
.OrderByDescending(a => a.Version, SemanticVersion.Comparer)
.FirstOrDefault();
var package = full is null ? null : release.Assets.FirstOrDefault(a => a.Name == full.FileName);
return package is null ? null : new Candidate(full!, package.DownloadUrl);
}
private async Task PublishAsync(string channel, Candidate candidate, CancellationToken cancellationToken)
{
var asset = candidate.Asset;
var packagePath = Path.Combine(PackagesPath, asset.FileName);
if (!File.Exists(packagePath))
await DownloadPackageAsync(candidate, packagePath, cancellationToken);
// Serve only the chosen package, as the feed Velopack wrote describes it.
var json = JsonSerializer.SerializeToUtf8Bytes(new { Assets = new[] { asset } });
var feedPath = Path.Combine(DirectoryPath, channel, FeedFileName);
Directory.CreateDirectory(Path.GetDirectoryName(feedPath)!);
WriteAtomically(feedPath, json);
lock (_stateLock)
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
}
private async Task DownloadPackageAsync(Candidate candidate, string packagePath, CancellationToken cancellationToken)
{
var partial = packagePath + ".partial";
try
{
using (var response = await _http.GetAsync(candidate.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var target = File.Create(partial);
await source.CopyToAsync(target, cancellationToken);
}
var asset = candidate.Asset;
var length = new FileInfo(partial).Length;
if (asset.Size > 0 && length != asset.Size)
throw new InvalidDataException($"{asset.FileName} is {length} bytes; its feed says {asset.Size}.");
if (!string.IsNullOrEmpty(asset.SHA256))
{
await using var stream = File.OpenRead(partial);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken));
if (!hash.Equals(asset.SHA256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"{asset.FileName} does not match its feed's SHA-256.");
}
File.Move(partial, packagePath, overwrite: true);
}
finally
{
if (File.Exists(partial))
File.Delete(partial);
}
}
private void DeleteUnreferencedPackages()
{
HashSet<string> keep;
lock (_stateLock)
keep = _feeds.Values.Select(f => f.FileName).ToHashSet(StringComparer.Ordinal);
foreach (var path in Directory.EnumerateFiles(PackagesPath))
{
if (keep.Contains(Path.GetFileName(path)))
continue;
try { File.Delete(path); }
catch (IOException) { } // Still being served; the next refresh retries.
catch (UnauthorizedAccessException) { }
}
}
private void LoadSavedFeeds()
{
foreach (var channel in Channels)
{
var path = Path.Combine(DirectoryPath, channel, FeedFileName);
if (!File.Exists(path))
continue;
try
{
var json = File.ReadAllBytes(path);
var asset = JsonSerializer.Deserialize<VelopackFeed>(json, FeedOptions)?.Assets?.SingleOrDefault();
if (asset is null || !IsPlainFileName(asset.FileName) || !File.Exists(Path.Combine(PackagesPath, asset.FileName)))
continue;
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
// A restart keeps serving what clients were already taking.
_wanted[channel] = _clock();
}
catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException)
{
// An unreadable feed is rebuilt by the next refresh.
}
}
}
private async Task<T?> GetJsonAsync<T>(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("OpenNest-Server");
if (url.StartsWith("https://api.github.com/", StringComparison.Ordinal))
request.Headers.Accept.ParseAdd("application/vnd.github+json");
using var response = await _http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonSerializer.DeserializeAsync<T>(stream, FeedOptions, cancellationToken);
}
private static GitHubAsset? FeedAsset(GitHubRelease release) =>
release.Assets.FirstOrDefault(a => a.Name == FeedFileName);
// Feed file names become paths under the packages directory and URL segments.
private static bool IsPlainFileName(string? name) =>
!string.IsNullOrEmpty(name) && name == Path.GetFileName(name) && name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
&& name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase);
private static void WriteAtomically(string path, byte[] contents)
{
var temporary = path + ".tmp";
File.WriteAllBytes(temporary, contents);
File.Move(temporary, path, overwrite: true);
}
private sealed record ChannelFeed(byte[] Json, string FileName, string Version);
private sealed record Candidate(VelopackFeedAsset Asset, string DownloadUrl);
private sealed class GitHubRelease
{
[JsonPropertyName("draft")] public bool Draft { get; set; }
[JsonPropertyName("prerelease")] public bool Prerelease { get; set; }
[JsonPropertyName("assets")] public List<GitHubAsset> Assets { get; set; } = new();
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")] public string DownloadUrl { get; set; } = "";
}
private sealed class VelopackFeed
{
public List<VelopackFeedAsset>? Assets { get; set; }
}
}
/// <summary>One entry of a Velopack <c>releases.{channel}.json</c>; properties keep Velopack's names.</summary>
public sealed class VelopackFeedAsset
{
public string PackageId { get; set; } = "";
public string Version { get; set; } = "";
public string Type { get; set; } = "";
public string FileName { get; set; } = "";
public string? SHA1 { get; set; }
public string? SHA256 { get; set; }
public long Size { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesMarkdown { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesHTML { get; set; }
}
/// <summary><c>GET /api/updates</c>: the versions each channel serves and the last refresh's outcome.</summary>
public sealed class UpdateMirrorStatus
{
public string? Stable { get; init; }
public string? Dev { get; init; }
public DateTimeOffset? LastChecked { get; init; }
public string? LastError { get; init; }
}
/// <summary>SemVer 2.0 precedence for feed versions such as <c>0.7.0-dev.2020</c>; build metadata is ignored.</summary>
public static class SemanticVersion
{
public static readonly IComparer<string> Comparer = Comparer<string>.Create(Compare);
public static bool IsValid(string? version) => version is not null && TryParse(version, out _, out _);
public static int Compare(string? left, string? right)
{
if (!TryParse(left, out var leftCore, out var leftPre))
return TryParse(right, out _, out _) ? -1 : 0;
if (!TryParse(right, out var rightCore, out var rightPre))
return 1;
for (var i = 0; i < 3; i++)
{
var core = leftCore[i].CompareTo(rightCore[i]);
if (core != 0)
return core;
}
// A release outranks its pre-releases.
if (leftPre.Length == 0 || rightPre.Length == 0)
return (leftPre.Length == 0).CompareTo(rightPre.Length == 0);
for (var i = 0; i < System.Math.Min(leftPre.Length, rightPre.Length); i++)
{
var leftNumeric = long.TryParse(leftPre[i], out var leftNumber);
var rightNumeric = long.TryParse(rightPre[i], out var rightNumber);
var part = (leftNumeric, rightNumeric) switch
{
(true, true) => leftNumber.CompareTo(rightNumber),
(true, false) => -1,
(false, true) => 1,
_ => string.CompareOrdinal(leftPre[i], rightPre[i]),
};
if (part != 0)
return part;
}
return leftPre.Length.CompareTo(rightPre.Length);
}
private static bool TryParse(string? version, out long[] core, out string[] preRelease)
{
core = Array.Empty<long>();
preRelease = Array.Empty<string>();
if (string.IsNullOrEmpty(version))
return false;
var withoutMetadata = version.Split('+')[0];
var dash = withoutMetadata.IndexOf('-');
var numbers = (dash < 0 ? withoutMetadata : withoutMetadata[..dash]).Split('.');
if (numbers.Length != 3)
return false;
core = new long[3];
for (var i = 0; i < 3; i++)
{
if (!long.TryParse(numbers[i], out core[i]) || core[i] < 0)
return false;
}
preRelease = dash < 0 ? Array.Empty<string>() : withoutMetadata[(dash + 1)..].Split('.');
return preRelease.All(p => p.Length > 0);
}
}
+41
View File
@@ -0,0 +1,41 @@
namespace OpenNest.Server;
/// <summary>
/// Refreshes the <see cref="UpdateMirror"/> every <see cref="Interval"/>, and at once when a
/// client asks for a channel the mirror does not hold yet.
/// </summary>
public sealed class UpdateMirrorService : BackgroundService
{
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
private readonly UpdateMirror _mirror;
private readonly SemaphoreSlim _wake = new(0, 1);
public UpdateMirrorService(UpdateMirror mirror)
{
_mirror = mirror;
_mirror.RefreshRequested += Wake;
}
public override void Dispose()
{
_mirror.RefreshRequested -= Wake;
base.Dispose();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _mirror.RefreshAsync(stoppingToken);
try { await _wake.WaitAsync(Interval, stoppingToken); }
catch (OperationCanceledException) { return; }
}
}
private void Wake()
{
try { _wake.Release(); }
catch (SemaphoreFullException) { } // A wake is already pending.
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace OpenNest.Server;
/// <summary>
/// <c>/updates/{channel}/</c>: the desktop app's Velopack feed, mirrored from GitHub by
/// <see cref="UpdateMirror"/>. Velopack's <c>SimpleWebSource</c> reads
/// <c>releases.win.json</c> (ignoring the query it appends) and then the package it names. A
/// channel with nothing mirrored yet answers 503 so clients fall back to GitHub for that check.
/// </summary>
internal static class UpdateRoutes
{
public static void MapUpdates(this IEndpointRouteBuilder app)
{
app.MapGet("/api/updates", (UpdateMirror mirror) => Results.Ok(mirror.GetStatus()));
app.MapGet("/updates/{channel}/{file}", (string channel, string file, UpdateMirror mirror) =>
{
if (!UpdateMirror.IsChannel(channel))
return Results.NotFound();
if (file == UpdateMirror.FeedFileName)
{
return mirror.GetFeed(channel) is { } feed
? Results.Bytes(feed, "application/json")
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
return mirror.GetPackagePath(channel, file) is { } path
? Results.File(path, "application/octet-stream", enableRangeProcessing: true)
: Results.NotFound();
});
}
}
+4
View File
@@ -11,3 +11,7 @@ OPENNEST_HOST_PORT=8090
# Docker volume holding nests.db; change only to switch to a verified restore.
OPENNEST_DATA_VOLUME=opennest-data
# Desktop update mirror: the server checks GitHub Releases (outbound HTTPS to api.github.com
# and github.com) and serves updates to Database-mode workstations. "off" turns it off.
OPENNEST_UPDATES=on
@@ -0,0 +1,108 @@
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
using OpenNest.Posts.CincinnatiCIFiber;
namespace OpenNest.Tests.Cincinnati;
/// <summary>
/// Both Cincinnati posts raise the head before a rapid Plan Cutting marked to pass over parts
/// already cut, whatever the operator's head-raise settings; the CI Fiber post refuses the nest
/// while its head-raise block is not set.
/// </summary>
public class HeadRaisePostTests
{
private const string RaiseCode = "M210 (RAISE HEAD)";
[Theory]
[InlineData(M47Mode.None)]
[InlineData(M47Mode.BlockDelete)]
[InlineData(M47Mode.Auto)]
public void ClPost_RaisesBeforeAMarkedRapid_WhateverTheM47Mode(M47Mode mode)
{
var lines = ClLines(Nest(markSecond: true), mode);
var second = lines.FindIndex(l => l.Contains("G0 X10.75 Y1"));
Assert.True(second > 0, string.Join("\n", lines));
Assert.Equal("M47", lines[second - 1]);
}
[Fact]
public void ClPost_WithoutAMarker_FollowsTheM47Mode()
{
var lines = ClLines(Nest(markSecond: false), M47Mode.None);
var second = lines.FindIndex(l => l.Contains("G0 X10.75 Y1"));
Assert.True(second > 0, string.Join("\n", lines));
Assert.DoesNotContain("M47", lines[second - 1]);
}
[Fact]
public void CiFiberPost_WritesTheRaiseBeforeOnlyTheMarkedRapid()
{
var post = new CIFiberPostProcessor(new CIFiberPostConfig { HeadRaiseCode = RaiseCode });
using var stream = new MemoryStream();
post.Post(Nest(markSecond: true), stream);
var lines = Lines(stream);
var raise = Assert.Single(lines, l => l == RaiseCode);
Assert.Equal("G0X10.75Y1", lines[lines.IndexOf(raise) + 1]);
}
[Fact]
public void CiFiberPost_WithoutARaiseCode_RefusesAMarkedNest()
{
var post = new CIFiberPostProcessor(new CIFiberPostConfig());
var refusal = Assert.Throws<InvalidOperationException>(() => post.Post(Nest(markSecond: true), new MemoryStream()));
Assert.Contains("head-raise code", refusal.Message);
post.Post(Nest(markSecond: false), new MemoryStream()); // Nothing marked: nothing to refuse.
}
// Two squares side by side, each with a line lead-in on its left; the second one's rapid marked.
private static Nest Nest(bool markSecond)
{
var nest = new Nest("RAISE");
var plate = new Plate(60, 120);
nest.Plates.Add(plate);
foreach (var x in new[] { 1.0, 11.0 })
{
var part = new Part(new Drawing("square", Square()), new Vector(x, 0));
var program = LeadIn();
if (markSecond && x > 1)
program = HeadRaise.Mark(program, [0]);
Assert.True(part.RestoreLeadInProgram(program, true));
plate.Parts.Add(part);
}
return nest;
}
private static Program Square()
{
var p = new Program();
p.MoveTo(0, 0); p.LineTo(0, 2); p.LineTo(2, 2); p.LineTo(2, 0); p.LineTo(0, 0);
return p;
}
private static Program LeadIn()
{
var p = new Program();
p.MoveTo(-0.25, 1);
p.Codes.Add(new LinearMove(0, 1) { Layer = LayerType.Leadin });
p.LineTo(0, 2); p.LineTo(2, 2); p.LineTo(2, 0); p.LineTo(0, 0); p.LineTo(0, 1);
return p;
}
private static List<string> ClLines(Nest nest, M47Mode mode)
{
var post = new CincinnatiPostProcessor(new CincinnatiPostConfig { ExteriorM47 = mode, InteriorM47 = mode });
using var stream = new MemoryStream();
post.Post(nest, stream);
return Lines(stream);
}
private static List<string> Lines(MemoryStream stream) =>
Encoding.UTF8.GetString(stream.ToArray()).Replace("\r\n", "\n").Split('\n').Select(l => l.Trim()).ToList();
}
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more