176 Commits
Author SHA1 Message Date
ajandClaude Opus 5.5 3e767ca3b6 feat(avalonia): prototype a cross-platform plate viewer
Adds OpenNest.Avalonia, a throwaway Avalonia 11 app kept out of
OpenNest.sln, to gauge a Linux port. It reads nests through
OpenNest.IO and draws plates with Skia: a port of the desktop's
display paths (cuts, etches, manual lead-ins, filled material),
pan, cursor zoom, fit, hover and selection, beside a plate list and
a drawings DataGrid. --snapshot renders the window to a PNG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:12:18 -04:00
ajandClaude Opus 5.5 e18304066d feat(ui): draw library previews in one shared style from the color scheme
The Shape Library, drawing library and drawing set preview each drew
parts their own way (hardcoded green fill, steel-blue fallback, black
outlines with gray etch). They now share a library style on ColorScheme
(background, fill, outline, etch and bend colors) that ignores drawing
colors, follows the active scheme (Dark gets a dark variant) and
round-trips through saved schemes, defaulting when absent. Nest part
colors are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:23:42 -04:00
ajandClaude Opus 5.5 dcb0a5c519 feat(shapes): pick shapes from a thumbnail grid in the Shape Library
Replace the name list with a ListView of 96px thumbnails drawn by
Program.GetImage at each shape's preview defaults (or its first
configuration), labelled with the name and configuration count. The
left pane opens three tiles wide instead of half the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:11:22 -04:00
ajandClaude Opus 5.5 1592d4bfd9 fix(ci): expect inches units on migrated legacy nests
Server schema v12 (2d8d877) adds a units column, so migrated legacy
records now report units: "inches" and the Server image smoke's
legacy-vs-migrated manifest comparison failed (cmp differs at line 27)
on every master push since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:47 -04:00
ajandClaude Opus 5.5 857a560dce test(engine): repin the clamp-zone Fill-stack layouts to what the engine produces
FillItem_WithClampZones_PinnedLayout (Fill, Strip, Vertical Remnant) has
failed in the Engine.Tests CI job on every push since it was added in
ccd361d: the pinned digest 9BF5FC7FC47364A9 is not reproducible at that
commit or any later one, locally or on GitHub runners. The engine
deterministically yields 9B3EFF9B72243576 (Fill, Strip) and
039C0D607166D805 (Vertical Remnant) at ccd361d, 36c8de8 and master, so the
later filler consolidation is still layout-neutral for these cases.

Before repinning, all four strategies' layouts were checked: 40 parts, all
inside the work area, bounding boxes clear of both clamps, and no
overlapping parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:46 -04:00
ajandClaude Opus 5.5 69a83b3ad9 feat(support): let a tab alone hold short parts the bed can't
Small parts such as 1.5" test squares on a 2" laser grid rest on at most
one grid point, so no single support tab can bring their center of
gravity inside the contacts and Plan Cutting warned "no single tab can
steady it" on every one. In practice a part that short stays in the
sheet on its tab, so the bed's contacts don't matter.

Cutting Settings > Tabs gets "Tab alone holds parts up to N long"
(CuttingParameters.TabHeldMaxLength, 0 = off, the default). On a plate
with a bed, a part the bed can't hold level whose length (long side of
its minimum-area bounding rectangle) is within the limit is still
tabbed, but any tab clear of inside corners steadies it, so Plan
Cutting, hand-placed lead-ins and the pre-post review warn only when it
has no tab at all. The setting persists with the cutting parameters and
is part of the plan state fingerprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:20:03 -04:00
ajandClaude Opus 5.5 a3a75d589d fix(converter): accept a required quantity of 0 when loading drawings
Nest drawings can require no parts, but the CAD Converter's quantity
spinner had a minimum of 1, so Edit Drawings in Converter threw
ArgumentOutOfRangeException as soon as such a drawing was selected.
Lowering the minimum to 0 lets the converter load it and hand the
drawing back with its quantity still 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:26:29 -04:00
ajandClaude Opus 5.5 a3b7c715d5 docs: add the Undo/Redo contract and point AGENTS.md at it
Undo/Redo landed in phases 1-3 without a written contract, so the next
person adding a desktop edit has no rule telling them to record it, and
nothing explains why a restore must close Cutting Inspection or why
snapshots keep empty plates and skip best fits. docs/undo-redo.md states
what is and is not undoable, the BeginChange/RecordChange rule, the
"Change" safety net, how states are captured and restored, the guards
and key routing, and the measured limits. AGENTS.md links it from the
OpenNest bullet and the contract list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:59 -04:00
ajandClaude Opus 5.5 16afb41c26 feat(desktop): undo machine changes and Nest Info edits
The nest's machine and its Nest Info fields were the last edits only the
fingerprint safety net could catch (and it misses most of them). Machine
is now one step, together with the support grid and cut-off settings the
machine brings. Nest Info is one step for what Undo can put back (units,
material, thickness, customer, notes and plate defaults); the name,
status, author and creation date stay as they are through Undo, so an
edit of only those records nothing.

The sheet strip only shows Auto Nest proposals; what it shows is
committed through Auto Nest, which is already recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:57 -04:00
ajandClaude Opus 5.5 2e72469d6c feat(desktop): undo drawing imports, edits and deletes
Drawing changes were unrecorded, so Undo skipped them or lumped them
into an anonymous "Change". Each is now one labelled step: Import
Drawings, Shape Library (menu and panel button), Drawing Library (what
the dialog added or re-quantified), Save to Drawing Library (the nest
keeps the drawing's new library link), Edit Drawings in the converter,
Drawing Properties, Delete Drawing and Clean Unused Drawings. Dropping a
drawing on a plate was already recorded as Add Part and is now tested.

The converter can change live programs while it loads, even when it is
cancelled; a cancel that did so now rolls the nest back to before the
converter opened instead of leaving those edits unrecorded.

Deleting a drawing threw when removing its parts emptied the trailing
plates, because the plate manager drops trailing empty plates while the
loop walked the live plate list; it walks a copy now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:54 -04:00
ajandClaude Opus 5.5 3dd1acd3cf feat(desktop): undo cut-offs, sequencing, lead-ins and cutting plans
Cutting edits were unrecorded, so Undo skipped them or folded them into
an anonymous "Change". Each is now one labelled step: Add Cut-Off, Move
Cut-Off (one step per drag), Automatic Cut-Offs, Auto Sequence (current
plate or all plates, only when the order changed), Set Sequence (per
click), Sequence by Path (the whole tool session; Backspace and Ctrl+Z
inside it still take back one line), Place Lead-In, Remove Lead-Ins (the
tool's menu, the plate and the nest commands), Cutting Parameters (an
edited lead-in panel when the tool closes), Plan Cutting and Clear
Lead-ins and Cut-offs.

Plan Cutting stages scrap cut-offs on the live plates before it plans,
so its scope opens with the staging and records only when the plan is
applied; closing without applying records nothing. Clearing applied work
is its own step and no longer warns that it cannot be undone.

Opening the lead-in tool used to give a plate without parameters the
defaults straight away; the plate now takes the tool's parameters with
its first lead-in, so opening and closing the tool changes nothing.
Undo no longer waits while the tool has a part picked: it ends the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:52 -04:00
ajandClaude Opus 5.5 9a62a5102d feat(desktop): undo plate edits, clamps and the support grid
Plate and clamp changes went unrecorded, so Undo skipped them or lumped
them into an anonymous "Change". Each is now one labelled step: Edit
Plate (only on OK), Rotate Plate, Resize Plate, Set Nest Default Plate,
Clamps and Support Grid (applying to the nest; saving machine defaults
stays outside the history), Remove Clamp / Put Clamp Back from the
plate's clamp menu, and Add/Move/Delete Clamp in Edit Clamps.

Edit Plate used to copy an empty plate's settings into the nest defaults
even when the dialog was cancelled; that now happens only on OK, so a
cancelled dialog changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:49 -04:00
ajandClaude Opus 5.5 223bfea47c feat(desktop): undo fills, pattern tiles, auto nest and clear nest
Placement edits were only caught by the fingerprint safety net, so they
undid as an anonymous "Change" or folded into the next edit. Each now
records one labelled step: Fill (at the commit, so a stopped fill that
keeps nothing records nothing), Fill Area, Pattern Tile, Auto Nest (from
the kept proposal only; Make Nests tabs too), Clear Nest, Remove Empty
Plates and Center Parts. Clear Nest no longer warns that it cannot be
undone.

Pattern Tile onto a new plate created a plate beside the empty sentinel,
which the plate manager dropped at once, so the pattern landed on a
detached plate; it now fills the sentinel.

The undo tests share one harness that compares a text description of the
nest before an edit and after Undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:47 -04:00
ajandClaude Opus 5.5 d1270f5208 feat(desktop): undo and redo the most-used nest edits
Whole-nest snapshots make undo safe without a hand-written inverse per
operation: each recorded edit stores one NestSnapshot in the document's
history, and Undo/Redo read one back and swap it into the open window.

- Document.History is reset with the opened nest; Save and database
  saves mark the save point instead of clearing it.
- EditNestForm.BeginChange opens a scope: nested scopes merge into the
  outermost, Complete commits one entry, and leaving it incomplete rolls
  a changed nest back. No-ops are detected by the scope's changed flag,
  not by comparing bytes (copies are not byte-stable).
- RestoreNest shares the per-nest binding with the constructor and keeps
  the Document, the nest's identity, the plate index, the viewport and
  the selection. It ends the action, closes Cutting Inspection, side
  panels and the remnant viewer, rebuilds the PlateManager, reapplies
  the view's cut-off settings and recolours the drawings, because each
  of those holds or derives from the old nest's objects.
- An edit no scope recorded (Phase 3 sites) is detected by a cheap
  fingerprint and committed as its own "Change" entry before a scope,
  Undo or Redo, so it is never silently discarded; DEBUG builds also
  warn about it on idle.
- Edit > Undo/Redo name the next entry. Ctrl+Z/Ctrl+Y/Ctrl+Shift+Z go
  to focused text editors first, then to an action's own step-back
  (Sequence by Path), then to the nest. They wait for jobs, fills,
  background saves and busy actions, but not for a plain selection.
- Recorded: delete, drag, arrow nudges (coalesced), rotate, align,
  push, expand spacing, add part (drop and clone), cut, add and remove
  plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:45 -04:00
ajandClaude Opus 5.5 fe31a3f8ec feat(io): add in-memory nest snapshots for undo
Undo will keep whole-nest states and restore by reading one back.
NestSnapshot.Capture writes the same archive a save writes, with three
differences Phase 0 found necessary: it does not stamp DateLastModified
on the live nest, it leaves best fits out (they are a cache), and it
keeps empty plates, which files drop, so an undo does not shift plate
indices or lose a plate the user added. File output is unchanged.

Restore reuses each live drawing whose saved content is unchanged,
matched by a fingerprint over its nest.json entry, program, hole
sub-programs and source entities. BestFitCache and FillResultCache are
keyed by the Drawing object, so a plain read would make the next fill
recompute every best-fit pair; reuse keeps those caches, colours and
Drawing.Id, and skips parsing the reused programs. Nested counts are
recomputed for the restored nest, and given back to the live one if the
read fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:43 -04:00
ajandClaude Opus 5.5 a88c056f4b test(io): expect saved part cutting parameters after reload
These round-trip tests pinned the old behaviour, where a part with a
saved lead-in program read back with null cutting parameters. Since
7abf0701 the part keeps the settings its leads were built with, as an
owned copy that stays separate from the plate's settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:42 -04:00
ajandClaude Opus 5.5 81ec6cc738 feat(data): add a state-based undo history
The desktop app has no undo. Phase 0 showed whole-nest snapshots are
cheap enough to record after every edit, so the history keeps whole
states with a cursor instead of per-command inverses that each need
hand-written reversal code.

It drops the redo tail on commit, evicts the oldest states past 50 steps
or 128 MB without ever evicting the current one, tracks a save point for
a later dirty indicator, and merges matching commits within a second so
repeated nudges undo as one. The clock is a TimeProvider so tests control
the coalescing window. It is generic and free of IO and WinForms so it is
unit tested on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:40 -04:00
ajandClaude Opus 5.5 11b04cc272 fix(io): save each part's cutting parameters with its lead-in program
PartDto had no field for Part.CuttingParameters, and restoring a saved
program cleared it, so after save and reopen the lead and pierce-zone
reviews judged manual and planned leads against the plate's settings
instead of the ones they were built with. Undo/redo will restore nests
through the same reader, so it would have carried the same loss.

The settings are written only beside a saved part program and read
back after the program installs. Older files and clean parts read null,
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:39 -04:00
ajandClaude Opus 5.5 6a9b81326e feat(planning): cut text-like cutouts in reading order
Serial-number tags with 27 stencil-text cutouts never planned: contour
recovery stopped at 16 holes, a lone part never got recovery, and the
remaining search kept leaving letters on the wrong side so rapids ran
back across letters already cut. Recovery now handles up to 30 holes,
runs for lone holed parts, and first sweeps the holes in reading order:
by their far side along the way they spread, each left from its far side
(stencil pieces face the next piece), ending at an outside entry beyond
every hole, with a few expansions per hole. Parts within the old 16-hole
reach keep the search first, so their plans are unchanged.

The eroded pierce now steps down through shallower levels so each entry
gets a pierce on its own stroke's centre line, and leads to the entry the
planner aimed at (falling back to the nearest wall), which the sweep needs
to leave a letter from the right place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:36:54 -04:00
ajandClaude Opus 5.5 37cdc52f0d feat(cutting): pierce cutouts too small for their lead-in by erosion
Small text and narrow slots often have no entry where the configured
lead-in fits, so Plan Cutting refused them. When every entry of a cutout
fails, the planner now erodes the cutout inward (to the pierce clearance,
or nine tenths of its inscribed radius when smaller), pierces at the
eroded point nearest each ranked entry and leads straight to the closest
wall, with no lead-out. Letter strokes pierce on their centre lines; a
tapering feature pierces at its widest spot. The lead validator still
checks every candidate, and cutouts whose lead fits are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:36:03 -04:00
ajandClaude Opus 5.5 d3d7bc0299 fix(planning): cast containment rays clear of every vertex height
Text cutouts on an ID tag sat a micro-inch below another letter's
baseline. The parity ray, offset a fixed 1e-6 above the test point, ran
exactly through that letter's seam vertex, whose two copies differ in the
last bit, so one edge counted and the tag captured as "Nested cutouts are
not supported material" at some plate locations but not others. The ray
height now steps until no vertex (or arc top/bottom) lies within a small
band of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:36:00 -04:00
ajandClaude Opus 5.5 3884d058ec feat(cutting): redesign the cutting parameters form
The old layout clipped lead fields inside fixed-height group boxes, scrolled the
whole dialog, hid tabs in a collapsed section and filed angle rounding under
Pierce. Leads now sit on Outside/Holes tabs sized for the type with the most
fields, labels share one aligned column with degree suffixes and hints, angle
rounding moved under Leads, the tab options read as plain sentences and enable
only when they apply, and the dialog sizes itself to fit without scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:44 -04:00
ajandClaude Opus 5.5 27610c3b1d fix(library): lay out the set history's drawings grid on first show
The members group resumed layout only after the outer table had already
sized it, so its docked grid kept the default 240x150 until the form was
resized. Inner containers now resume before the table lays them out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:43 -04:00
ajandClaude Opus 5.5 1b99a7c866 feat(display): show plate ids only when the zoom makes room for them
Zoomed out, small parts and short cut-off segments piled their ids on each
other. As the CAD converter numbers entities, an id now shows only once its
part is big enough on screen to hold it: a part must be as wide and tall as
the id's text, a cut-off segment as long as the id's disc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:43 -04:00
ajandClaude Opus 5.5 6e40c2e6ed feat(library): redesign Edit Drawing with drop-downs, color modes and a rotation dial
Typing customer, material, grade and thickness by hand let near-duplicate
spellings into the library, which then split drawings that should match.
The fields are now drop-downs of the values the library and the machine
material catalog already use, while still accepting new values; thicknesses
already used for the material are listed ahead of the standard gauges.

A library color could be set but never cleared back to the color scheme's,
so Automatic and Reset are added. The rotation start/end/step boxes gave no
sense of the range, so a dial shades it, ticks the angles the engines try
(via RotationPolicy) and lets either end be dragged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:43 -04:00
ajandClaude Opus 5.5 0ba5539fab fix(display): keep cut-off lines from striking through segment ids
Cut-off lines are painted after the parts, so the dashed line ran straight
through each segment's id, which sits on the cut. Cut-off segment ids are now
held back from the parts pass and drawn after the lines, over a disc of the
plate's fill color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:43 -04:00
ajandClaude Opus 5.5 2e17dd7bc4 fix(desktop): size grid columns before they are first painted
Grids opened at their designer widths and jumped a moment later, because
GridStyle waited for the first paint with rows before measuring them.
Columns now measure their header and first 50 rows directly: at handle
creation when the rows are already there, otherwise in a call posted when
rows arrive, which runs after the filling code and before the repaint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:42 -04:00
ajandClaude Opus 5.5 86b0370a8f fix(names): drop S from generated nest names
S reads too much like 5 on labels and screens, so the sequence alphabet is
now base-29. Already-issued names keep their numbers: the server's stored
counter stays authoritative and a base-29 name can never equal an earlier
base-30 one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:42 -04:00
ajandClaude Opus 5.5 e5e2c268b8 feat(ui): redesign the Nest Defaults dialog
The flat ten-row list squeezed the quadrant picker to 120x28 and gave no
hint of units or where the defaults are saved. Group the fields (General,
Plate Size, Quadrant, Spacing), lay the edge spacings out around a plate,
show in/mm suffixes, and convert the values when the units switch so a
48 in plate does not silently become 48 mm.

The footer says whether Save writes to this PC or the shared nest server,
and Save is disabled while the shared copy is read-only instead of
failing after the click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:42 -04:00
ajandClaude Opus 5.5 7f1c5a3854 fix(display): label each cut-off segment at its own cut
Every segment of a cut-off is its own part of the one cut-off drawing, but
its sequence label was placed from the drawing's whole program, so all of a
cut-off's segments stacked their numbers on one point. The view culls parts
outside the visible area, so zooming changed which segment drew last and the
shown number appeared to change. Each segment is now labelled at the middle
of its own cutting moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:41 -04:00
ajandClaude Opus 5.5 96d8a6874f fix(autonest): show part rotation limits in degrees
The Rot Start, Rot End and Step columns showed the drawing's radian
constraints as-is, so users had to type 1.5708 for 90 degrees. The grid now
shows and accepts degrees; rows keep the radian values, so an unedited row
passes the drawing's limits to the engine without a conversion round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:10:59 -04:00
ajandClaude Opus 5.5 fecfcc337e build(help): ship help v0.4.0 with the shared nest server, Drawing Library, drawing sets and releases guides
The server and Database-mode pages were written after v0.3.0 was tagged,
so builds pinned to v0.3.0 had no help for setting up or connecting to a
nest server, or for the library, sets and releases that depend on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:01:55 -04:00
ajandClaude Opus 5.5 b0697f92b3 feat(cutoffs): keep large horizontal remnants in automatic scrap cut-offs
Strip lines now stop at a full-length horizontal cut-off that clears the
parts instead of slicing the band beyond it. The desktop dialog and Plan
Cutting also propose their own horizontal separator when that band beats
the X tail (opt-in for API callers via KeepHorizontalRemnant); a kept X
tail beside the band becomes a corner piece whose separator stops there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:20:34 -04:00
ajandClaude Opus 5.5 98f8e98230 fix(cutting): curved leads meet the cut tangentially; arc lead-in sweep
Curved leads turned with the contour winding, which is backwards on outside
perimeters: arc lead-ins arrived against the cut, and Line + Arc / arc
lead-outs went the long way round on one winding. LeadTurn now gives the
arc's turn direction for emission, the pierce-zone check and the preview,
and the arc geometry mirrors with it.

At convex outside corners, curved lead-ins use the first-cut edge's normal
and arc lead-outs the last-cut edge's, whichever edge was picked.

ArcLeadIn gains SweepAngle (degrees, (0, 180], default 180) in the cutting
panel, settings, nest files and cut pins; older files read as 180.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:50:30 -04:00
ajandClaude Opus 5.5 5611d9a663 refactor(engine): one PlateNester and one strategy name table
DefaultPlateNester, StripPlateNester and RemnantPlateNester differed only
in the filler they built. They are replaced by one internal PlateNester
over a Func<Plate, PlateFiller>, which keeps the old Place exactly:
restricted rotation goes to OrderedPlateNester, and each trial gets a fresh
private plate and filler.

PlacementStrategies maps Fill (and its legacy "Default" alias), Strip and
both Remnants to a filler policy and layout. PlateNesterFactory reads it
case-sensitively and PlateFillService ignores case, as before; each keeps
its own error type and message. Nothing outside the Engine and its tests
used the three public nester types, so they are removed.

No layout changes: all 29 fill-stack pins pass with the pin file untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:51 -04:00
ajandClaude Opus 5.5 5761eefd37 refactor(engine): merge the plate fillers into one PlateFiller
DefaultPlateFiller becomes PlateFiller(plate, policy, layout); PlateFillerBase
and StripPlateFiller are gone. PlateLayout.FillThenPack is the old
orchestrator path (Fill and both Remnants); PlateLayout.Strips is the old
StripPlateFiller.Nest, and Strip is Fill policy plus Strips layout.

Lifetimes are unchanged: a strip-layout filler runs every Fill, group Fill
and PackArea on a fresh inner filler and gives each strip fresh remnant
fillers, so learned angles and PlateNumber never leak into them. The
comparer, preferred direction, trim axis and angle recording now read the
policy directly. The base class's unsorted two-angle BuildAngles is dropped:
only DefaultPlateFiller's pipeline called BuildAngles, and Strip never ran it.

Layout-neutral: all 29 FillStackCharacterizationTests pins pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:51 -04:00
ajandClaude Opus 5.5 9f335ea111 refactor(engine): fold remnant fill settings into one filler policy
Fill, Vertical Remnant and Horizontal Remnant are now three values of an
internal PlateFillPolicy that DefaultPlateFiller takes (Fill by default),
replacing RemnantPlateFiller and RemnantFillPolicy. The policy holds the
comparer factory, preferred direction, trim axis and angle mode; the
remnant angle mode still replaces the angle builder outright, and the
public FillPolicy that strategies read is unchanged. Layout-neutral: the
fill-stack characterization pins pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:51 -04:00
ajandClaude Opus 5.5 0c7ad5aefb refactor(engine): list the built-in fill strategies instead of scanning
FillStrategyRegistry now holds a fixed, ordered list of the seven
built-in strategies (Pairs, Row, Column, RectBestFit, Extents, Linear,
TiltedRemnant, in their Order sequence) instead of reflecting over the
Engine assembly. LoadFrom and LoadPlugins are gone; nothing outside
tests called them, so outside IFillStrategy plug-ins were never loaded
by a shipped host and none are loaded now. IFillStrategy stays public.

The disabled set stays process-wide and keeps its Disable, Enable,
SetEnabled, Strategies, AllStrategies and DisabledNames behavior for the
Options form. DefaultPlateFiller still reads Strategies, so interactive
Fill, the fixed-strategy engines, Irregular's block catalog and private
plate fill, the cutout lattice fill and the brute-force runner all keep
honoring it.

PlateViewFillLifetimeTests drives fills through PlateView, which builds
its own filler, so a filler-level seam can't reach it. The registry gets
an internal UseForTesting hook that swaps the list until disposed, and
the WinForms test project can see Engine internals for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:50 -04:00
ajandClaude Opus 5.5 73a7c8d6a2 feat(desktop): drop a set's shared name prefix from preview labels
When every drawing in a set starts with the same words, as in
"4980 A111 PT01", the set preview's labels were all cut short to the
same "4980 A111 P…". The preview now drops the leading words all names
share, cutting only at a space, '-', '_' or '.' and never emptying a
name, and notes the dropped prefix above the drawings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:39:37 -04:00
ajandClaude Opus 5.5 479883e7ac feat(cutting): tab parts by width when the plate has no support bed
With support tabs on and no support bed, nothing was tabbed and nothing
said so. A new no-bed width range (NoBedTabMinWidth..NoBedTabMaxWidth,
the short side of the part's minimum-area bounding rectangle) now picks
the parts to tab instead. SupportTabSettings.Analyze makes the choice
for Plan Cutting, hand-placed lead-ins and the pre-post review; without
a bed any tab clear of inside corners counts. With no bed and no range,
the plan notes that nothing was tabbed. Cutting Settings > Tabs gains
the range fields, and the values persist with the cutting parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:02:31 -04:00
ajandClaude Opus 5.5 07499fe1cd fix(engine): judge part spacing alike in the layout check and runner
NestLayoutCheck rejected Fill layouts whose interlocked arcs sat exactly
at the part spacing: its SpacingOutline circumscribes arcs by up to 0.001
each, more than the 0.0005 slack, so facing arcs overlapped. The runner's
placement validator measured inscribed 0.001 polygons instead and so
accepted gaps up to ~0.002 under the slack.

Both now find candidates with SpacingOutline and confirm them on
NativeContours, the native contours flattened at a twentieth of the slack
(moved out of PartSpacingReview, which keeps using them). The runner calls
NestLayoutCheck.KeepsSpacing, the same outline, tie rule and
confirmation, so the two validators accept exactly the same pairs.
Clears stays unconfirmed and stricter for engine certification. No
tolerance changed.

FixedStrategyEngine_IrregularJob pins now assert LayoutAssert.Valid with
unchanged digests; the NativeU 70-part grid's 28 reports were the same
cause and its test now requires none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:58:41 -04:00
ajandClaude Opus 5.5 1b1f86085d fix(engine): keep best-fit pair candidates in a stable order
BestFitFinder gathered each rotation strategy's candidates, and PairEvaluator
gathered evaluated results, in ConcurrentBags, so their order depended on which
worker finished first. The stable area sort then broke equal-area ties in that
arbitrary order, and fills, which try candidates in order and keep the first of
equal layouts, could nest the same parts differently between runs.

Both now fill index-addressed slots: candidates keep rotation-angle and
slide-offset order, results keep candidate order, and ties break the same way
every run. The candidates produced and their scores are unchanged.

The Strip whole-job pin in FillStackCharacterizationTests is re-pinned
(A66602F707290A36 -> FD03A4529D37E32C). That job ties between best-fit
pairs; the old pin recorded whichever tied pair the bag happened to return
first. The new layout places the same 26 parts, passes LayoutAssert.Valid
and gave the same digest in five separate test processes. The other 28
pins are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:58:34 -04:00
ajandClaude Opus 5.5 36c8de825e fix(engine): route restricted-rotation Strip plates to the ordered nester
StripPlateNester sent every request to the strip fills, which try two
classification angles and ignore rotation bounds. With a fixed or bounded
rotation at quantity above one they proposed forbidden poses, the job
validator threw "Candidate rotation is not allowed for the requirement."
and the whole Strip job failed. Strip now uses the same guard as
DefaultPlateNester and hands such plates to OrderedPlateNester.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:13:39 -04:00
ajandClaude Opus 5.5 ccd361dfa9 test(engine): pin Fill-stack layouts on irregular parts
Characterization digests for the interactive PlateFillService surface and
the fixed-strategy whole-job engines on L-shaped, notched, arc, bar and
chevron parts: single-item fills, whole-plate nests (including one where
learned angles change the layout, guarded by a full-sweep comparison),
fills beside clamp zones, and one job per Fill/Strip/remnant engine.
They let the planned filler/nester consolidation be shown layout-neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:07:20 -04:00
ajandClaude Opus 5.5 7c4276a457 refactor(engine): retire the StockLadder engine
Every whole-job engine chooses among the offered stock sizes, and on the
synthetic benchmark nests Default matched or beat StockLadder on all 18
runs (saved sizes, a six-size pool, with and without salvage credit),
costing 30-40% less in total with mixed sizes. Its sheet-swap step never
produced a win over Irregular's tail re-plan.

The registry maps "StockLadder" to Default so saved selections, scripts
and API requests keep working. OrderedPlateNester stays: Fill and the
remnant strategies use it for restricted rotations, and its golden layout
is now pinned through NestJobRunner with the parts requested largest
first, as StockLadder requested them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:24:37 -04:00
ajandClaude Opus 5.5 14ba2e653b docs: describe the settings shared through the nest server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:02:22 -04:00
ajandClaude Opus 5.5 18612e698d feat(desktop): use the server's shared settings in Database mode
Machines, post settings, clamp defaults and nest defaults come from the
nest server once it holds them, and from this PC's files until then; saves
go where the settings were read. The server's copy is re-read when older
than two minutes, before every post and when Machine Configuration opens.
While the server is unreachable the last copy read is used, the user is told
once, and nothing shared can be changed.

Posting re-reads the post's shared settings; saving them is skipped when the
dialog changed nothing, and a conflict loads the other workstation's
settings and stops the post. On first start the desktop offers to upload the
settings the server lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:02:22 -04:00
ajandClaude Opus 5.5 90e2fd2e6e feat(server): share machines, post settings, clamps and nest defaults
Schema v13 adds shared_settings and shared_setting_revisions behind
/api/settings: one revision-guarded JSON document per kind and key, with
every revision (deletions included) kept with who saved it. Recreating a
deleted document continues its revision count.

OpenNest.Data gains the client (RemoteSharedSettings), a synchronous store
that keeps the last copy read for offline use and refuses writes while the
server is unreachable (SharedSettingsStore), a machine provider over it, and
JSON parse/serialize entry points for clamp settings and nest defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:02:08 -04:00
ajandClaude Opus 5.5 9f3c22dc4f feat(posts): let posts load and save their settings as JSON
IJsonConfigPostProcessor lets a host keep a post's settings somewhere other
than the JSON file beside the post DLL. The CL-707, CI Fiber and Gravograph
posts implement it; LoadConfig keeps the current settings when the JSON is
unusable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:02:08 -04:00
ajandClaude Opus 5.5 84e6adbcf2 fix(data): give the CL-980 default a 0.02 cut-off part clearance
Plates now take cut-off settings from the nest's machine, so the
default's 0.25-0.75 clearances replaced the 0.02 operators expect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:29:59 -04:00
ajandClaude Opus 5.5 b35490addf test(planning): hold problem nests to a cut-off baseline
Nests with a "planning with cut-offs:" line are planned again after clearing
applied work and adding default scrap cut-offs. OPENNEST_PROBLEM_NESTS_CUTOFFS=1
plans every nest that way to record new lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:17:51 -04:00
ajandClaude Opus 5.5 1fbe57a841 fix(data): match machine thicknesses rounded to three decimals
PEP stores 3/16 as 0.188, which missed the machine's 0.1875 at the
0.0005 in tolerance; widen it to 0.001 in, still far below gauge steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:15:03 -04:00
ajandClaude Opus 5.5 1bb58117af fix(desktop): fit the set preview at the largest scale the pane allows
The set preview guessed row widths and settled the scale in four rounds,
which could stick at rows of minimum-width label cells and leave much of
a narrow pane empty. At a given scale, filling each tallest-first row as
far as the pane allows is the best row split, and the packed height only
grows with scale, so the layout now bisects for the largest scale that
fits. A 21-drawing set in a 400x590 pane goes from scale 1.14 with 345 px
used to 2.23 filling the height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:04:58 -04:00
ajandClaude Opus 5.5 70e8666335 feat(posts): give the Cincinnati CL-707 settings the CI Fiber sectioned editor
The CL-707 config now declares sections, sentence-case labels and ranges,
so its dialog matches the CI Fiber one instead of the generic PropertyGrid.
The sectioned editor gains what the CL-707 config needs: optional (nullable)
numbers behind an enable box, editable tables for lists of simple records,
and a text field that suggests values from a table column (Selected library).
"Lead-in arc line 2 feedrate" is relabeled "Lead-in arc feedrate", which is
what #127 drives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:04:57 -04:00
ajandClaude Opus 5.5 2d8d877482 feat(server): keep a nest's units on its record
A record's thickness is in the nest's units, but nothing on the record said
which, so a list of nests could not tell 9.525 mm from 9.525 inches without
reading each archive. Records now carry units, read from the uploaded
archive like the counts (absent or unknown values are inches) and kept by a
metadata-only save. Schema v12 adds the column and reads every stored
archive, current and historical, so existing millimeter nests report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:40:43 -04:00
ajandClaude Opus 5.5 610b9dafcb fix(autonest): mark ledger substitutes against the best rank matched
An acceptance rule may list sheet grades first, so at plate thickness its
best match can be rank 3 (A36 for HR). Auto Nest marked every row above
rank 1 a substitute; it now ranks rows against the best rule rank ML
matched at the nest's thickness, as ML labels them, so A36 is preferred
and only A572 is a substitute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:20:11 -04:00
ajandClaude Opus 5.5 079dfaf9ea fix(desktop): count only active MaterialLedger holds and link the nest page
MaterialLedger's Release lets a hold go, so a Released hold no longer keeps
a sheet for the nest; only Proposed, Approved and AwaitingCloseout holds,
the statuses ML counts against free stock, are open. The Has Been Cut
note's link pointed at /Nests/{id}, which ML does not serve; its nest page
is /Nests/Details/{id}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:20:11 -04:00
ajandClaude Opus 5.5 c27188c722 perf(desktop): reuse cached part paths when zooming and dragging
Each zoom step rebuilt every part's display paths from its CNC program,
and each drag step rebuilt the moved parts, which dominates for parts
with hundreds of holes. LayoutPart now keeps its paths in the drawing's
program frame and only maps them to the view on a zoom or move,
rebuilding when IsDirty is set or the program, rotation or manual
lead-ins change. Also caches the label StringFormat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:11:08 -04:00
ajandClaude Opus 5.5 548522775f fix(server): report a nest's units with its sheet sizes
GET /api/nests/{id}/sheets returns sizes as stored, in the nest's units,
but did not say which. MaterialLedger compares them with inches, so a
millimeter nest's sheets never matched inventory. The response now carries
units (inches or millimeters), read the way NestReader reads them: absent
or unknown values are inches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:41:29 -04:00
ajandClaude Opus 5.5 be01124a0f feat(autonest): rank MaterialLedger stock and mark substitutes
MaterialLedger's /api/stock/opennest now resolves an OpenNest material
and grade through ranked acceptance rules. The client reads the new
spec rank, family, grade and finish and the item rank; an older ledger
that sends none of them deserializes as rank 1 with no label.

LedgerStockMapper groups drops only by identical size and spec, keeps
the ledger's order in rows and in each row's ItemCodes, and orders rows
by rank. Auto Nest's Source column shows the row's grade and finish
("A1011 P&O"); substitutes (rank above 1) read "A1011 P&O for HR" in
amber, name the substitution in their tooltip and still load ticked.
The engine still sees no rank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:45 -04:00
ajandClaude Opus 5.5 f847b216f2 feat(desktop): show a server nest's MaterialLedger holds and closeout note
Server nests in Database mode get a read-only status bar label comparing their
open MaterialLedger holds with the sheets they cut, e.g. "Ledger: 2 of 4 held,
2 short". Nothing writes to the ledger and nothing waits on it.

- MaterialLedgerClient.GetHoldsAsync reads GET /api/stock/holds?openNestNestId=
  with the closed LedgerHoldsResult set; unknown status strings are kept. The
  request plumbing is shared with GetStockAsync.
- LedgerHoldSummary (OpenNest.Data) counts Proposed/Approved/Released/
  AwaitingCloseout holds against the plate quantities, skipping the trailing
  empty plate as /sheets does.
- NestHoldStatus refreshes on server open, after save, after storage or
  Options changes and on label click; it never polls. The tooltip lists holds
  in nest units. File-mode, unsaved and ledger-less nests show nothing.
- Setting Has Been Cut in Nest Info with holds still open shows a non-modal
  note linking to {ledgerUrl}/Nests/{id}. Posting is not gated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:45 -04:00
ajandClaude Opus 5.5 23f093fcc1 feat(nest): plates report the remnant they are expected to leave
Core PlateRemnant.Expected picks a full-span cut-off drop clear of the
parts (less kerf, nominal position for head-locked overcuts), else the
largest salvage edge offcut, else none below the nest's new persisted
MinimumRemnantDimension. NestJobCost's salvage offcut now calls the Core
rule unchanged. NestWriter writes a derived plate expectedRemnant that
readers skip, and /sheets reports it per plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00
ajandClaude Opus 5.5 15750949dd feat(nest): plates name the ledger item each sheet is cut from
Auto Nest on MaterialLedger stock now records which inventory item each
physical sheet uses, so ML can hold exactly those items instead of matching
by size.

- Core: Plate.StockItemCodes (entry i = sheet i of Quantity; empty = uncoded).
  Core invalidates it: a resize clears it (turning the sheet keeps it), a lower
  Quantity truncates it, a nest material/grade/thickness change clears every
  plate's, ClearAll clears it and new plates never copy it.
- Desktop Auto Nest: after a kept proposal is committed, LedgerStockAssignment
  deals each ledger row's codes to its plates in Nest.Plates order (bundles
  repeat their code; clamp-removal variants use their base row). Rejected,
  discarded or cancelled proposals never reach it. NestPipelineCommit.PairWithStock
  pairs committed plates with their solved stock; the engine stays ledger-free.
- Plate list tooltip: "Stock: CODE (location)"; locations are display-only.
- nest.json: optional plate stockItemCodes, omitted when empty, read leniently.
- /api/nests/{id}/sheets: new plates[] { index, width, length, quantity,
  stockItemCodes }; sheets[] unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00
ajandClaude Opus 5.5 ea1decc097 feat(autonest): load sheet and drop stock from MaterialLedger
Auto Nest's Stock Options gets a Load from ledger button that reads the nest
material's free sheets and drops from MaterialLedger's API-key-protected
GET /api/stock/opennest. Ledger rows are request-local: the engine sees them as
ordinary NestPlateStock rows with ledger:{n} ids and finite quantities, and they
are never written to Nest.PlateOptions.

- OpenNest.Data/MaterialLedger: settings file, read-only client (X-Api-Key,
  5 s timeout, closed LedgerStockResult set) and LedgerStockMapper (bundles and
  sheets one row each, same-size drops grouped with ordered item codes, unknown
  shapes and too-small items skipped, inch/mm conversion).
- Options: MaterialLedger URL and API key (DPAPI, CurrentUser) with Test.
- Auto Nest grid: Use, Qty and Source columns for every row; PlateOption gains
  request-local Quantity and Id, which NestStockBuilder passes to the engine.
  Ledger rows score by area; hand-typed costs stay saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00
ajandClaude Opus 5.5 42f1762c56 fix(desktop): keep the cut simulator moving while the plate view pans
Panning invalidates the view on every mouse move, so a paint is always
pending and WM_TIMER never arrives; the torch froze until the pan ended.
Paints now advance the playhead from the playback clock too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:46:47 -04:00
ajandClaude Opus 5.5 455c543501 fix(verify): close contours whose cut runs past its start as a seam overcut
Converted part programs keep a source CAM's overcut: a hole's arc or a slot's
last line runs about 0.03 past the pierce along the first cut. The rapid check
only counted a contour closed when it ended exactly at its start, so every
overcut contour became an open fragment, the open-cut proof refused them, and
each part reported "Multiple interrupted/open cutting fragments" and an
unverifiable lead-out instead of being checked (P260810-02: all 50 parts).

ReleasedContourState now closes such a contour at its seam (matched at the
1e-6 seam tolerance, since a hole's two arcs carry centers ~1e-7 apart) and
lets the rapid leaving the overcut end ignore contact within that tolerance of
its departure. The nest now validates fully and reports the real crossings it
was hiding: rapids that cross the hole just cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:25:22 -04:00
ajandClaude Opus 5.5 4b982e8eeb fix(desktop): paint the utilization bar when its fill is a sliver
A one-pixel fill (about 2% on the Nesting Progress bar) asked GDI+ for a
zero-size corner arc, which threw "Parameter is not valid" mid-nest. Clamp
the corner radius to the rectangle and fall back to a plain rectangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:56:51 -04:00
ajandClaude Opus 5.5 0d369c9a51 feat(desktop): draw numeric units beside the value and accept typed units
Unit suffixes are painted in a reserved right margin instead of being part
of the edit text, so editing the number no longer fails to parse. Length
boxes also convert values typed in other units (mm, cm, m, in, ft,
fractions such as 3/8").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:36:13 -04:00
ajandClaude Opus 5.5 cbf3b728aa feat(desktop): preview the highlighted set beside the Sets list
The set preview moves from its own window into a details pane beside
the list, as the Drawing Library has, with the set's notes below it.
It reads the highlighted set shortly after the selection settles and
caches drawings per revision. Parts are drawn as outlines without fill,
and the info lines sit above the fitted drawings instead of over them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:35:29 -04:00
ajandClaude Opus 5.5 fa70713bfc fix(geometry): treat near-joint contacts as the joint when classifying slides
Drawing data can fix a fillet's center slightly off the tangent normal, so
the arc's end sits ~1e-7 past its neighbouring line's end. The slide contact
classifier only joined entities within 1e-7, so a contact at that tangent
point read as two boundaries, was classified ambiguous and blocked the push.
In N26-243 this stopped a part pushed left along another once the parts
aligned. Contacts within 1e-6 of a joint now take the joint's tangents;
the 1e-7 incidence tolerance is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:52:48 -04:00
ajandClaude Opus 5.5 ba55d5bff0 revert: drop the duplicate near-vertex hole-cut fix
Master already starts such entries exactly at the vertex (45592a81), so the
cherry-picked snap was redundant and its test's off-vertex precondition no
longer holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:24:57 -04:00
ajandClaude Opus 5.5 f600c5e58b feat(desktop): nest released nests live in the main window
With Nest after release ticked, Make Nests now minimizes the Drawing
Sets window and nests each group's built nest in its own main-window
tab, before it is saved, with Auto Nest's progress window and live plan
strip, one nest at a time in plan order. Stop there (or switching tabs)
stops the whole release, as the panel's Stop does; the layout is kept
by the same rules as the headless renest, without the Keep review.

After upload each tab is bound to its saved record instead of a second
tab opening; a tab whose nest was not saved is closed. The main window
stays in front, and the Drawing Sets window flashes its taskbar button
when a group failed or was refused or the release was not recorded.

NestRenest splits into CreateRequest and Keep so the solve runs off the
UI thread and the commit on it; AutoNestCommand's solve-with-progress
step is shared rather than copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:21:28 -04:00
ajandClaude Opus 5.5 27796851f3 feat(desktop): build releases in a panel beside the Drawing Sets lists
The modal release dialog is replaced by ReleasePanel, docked beside the
Library and Sets tabs and shared by both. A double-click or Enter adds a
set at one kit or a library drawing on its own at the entered quantity;
adding one already listed adds to its quantity. Lines are edited in
place with a remove button, and the nests it will make preview live.

Each group now merges into a matching Draft nest by default: the only
one, or the most recently saved of several (chosen in the row); a draft
open in a tab blocks Make Nests instead of making a parallel nest.
Nest after release is remembered per user. Edit Set and Edit Drawing
move to their buttons and right-click menus.

The Drawing Sets window is now an unowned top-level window with its own
taskbar entry and remembered bounds, closed with the main window; a
release not yet made survives closing it, and Make Nests brings the
main window forward. ReleaseSetForm remains as a thin dialog host for
releasing from Nest > Add from Drawing Library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:21:27 -04:00
ajandClaude Opus 5.5 d4269c1f06 refactor(cutting): merge circle lead settings into internal leads
Full circles now take InternalLeadIn/InternalLeadOut like every other
cutout; the ArcCircle lead settings and the cutting panel's Arc / Circle
tab are removed. Circle-specific geometry handling (radius clamp, angle
rounding, pierce zone) is unchanged.

Older nest files and desktop settings still load: their arcCircleLeadIn/
arcCircleLeadOut keys are ignored and no longer written, so re-planned
circles take the internal leads. Saved part programs are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:20:09 -04:00
ajandClaude Opus 5.5 8cd9b2cbe3 fix(planning): close hole cuts whose entry lands a hair off a vertex
A closest-point entry within Tolerance.Epsilon of a line or arc vertex made
ReindexAt start the contour at the vertex while the lead-in still ended at
the off-vertex point, so the cut closed short of its start and replay refused
the whole plate. Snap such entries onto the vertex before emitting the lead-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:20:09 -04:00
ajandClaude Opus 5.5 b341848e07 feat(desktop): preview a drawing set's drawings together at one scale
Preview on the Sets tab (button and right-click menu) downloads each
member's current revision and packs them into one view, labeled with
name and quantity, so part sizes compare at a glance. PlateView gains
DrawPartNumbers (default on) so the preview can hide sequence numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:12:58 -04:00
ajandClaude Opus 5.5 33f60d3dcb feat(engine): break equal-cost ties toward the larger free offcut
Without salvage credit (desktop Auto Nest on a single plate size) every
complete one-sheet layout costs the same, so engines kept whichever layout
came first. N26-23S nested 27.86" long when turning two parts fits it in
21.3".

NestJobCost.CompactnessOffcut measures the largest full-width/full-length
edge offcut as salvage does, whatever the salvage rate. Default and Circles
(CandidateScore), Rectangles and Circles sheet choice (SheetByStock) and
Irregular sheet and plan ranking use it only after cost, so no cost changes.
Sheet-level keys round to 1e-6 to keep ties platform-independent.

Rectangles layout digests are re-captured: each changed fixture keeps its
sheets, parts and cost and leaves a larger offcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:18:57 -04:00
ajandClaude Opus 5.5 ad3443f60d fix(benchmark): carry each nest's clamp rail into benchmark jobs
The benchmark built stock from spacing and quadrant only, so engines were
scored on clamp-free sheets and baselines were never checked against the
clamps they were made with. Stock now comes from NestStockBuilder.FromTemplate,
as in desktop Auto Nest: the source plate's rail is resolved for every
candidate size (explicit zones are offered unchanged), and baseline plates
keep their rail and removed clamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:18:56 -04:00
ajandClaude Opus 5.5 ebecd42433 feat(desktop): size grid columns to their first content, then leave them to the user
Grid columns no longer stretch to fill the control. GridStyle sizes each
column once to its header and the first rows shown (capped at 400 px),
then the widths are the user's to resize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:27 -04:00
ajandClaude Opus 5.5 3f5d402489 feat(desktop): open the Drawing Sets window as a maximized MDI child
File > Drawing Library, Drawing Sets, Releases and database-mode Import
BOM now open the window inside the main form, maximized, instead of as a
modal dialog. A second menu click activates the open window on the tab
asked for. Close now closes the window, since a DialogResult button no
longer does outside a modal dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:09:46 -04:00
ajandClaude Sonnet 5.5 a6056a6065 feat(desktop): Drawing Library is a tab of the Drawing Sets window
The drawing library browser becomes DrawingLibraryView, a control. The Drawing
Sets window hosts it as its Library tab beside Sets and Releases, and File >
Drawing Library opens the window on it. A library drawing's Releases item
switches to the Releases tab filtered to it. Nest > Add from Drawing Library
stays a dialog, now a thin wrapper around the same view.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:54:54 -04:00
ajandClaude Opus 5.5 dfa6ccb954 fix(desktop): give each UI thread its own plate-drawing pens and brushes
ColorScheme.Default and LayoutPart kept single static pens, brushes and a
font that every plate view and drawing preview painted with. GDI+ objects
are not thread-safe, so two views painting at once on different UI threads
(parallel WinForms test collections) failed in PlateRenderer.DrawPlate with
"Object is currently in use elsewhere" and crashed the test host.

The scheme now builds its pens and brushes per thread and rebuilds them
after a color change; LayoutPart's selection, lead-in and label objects are
thread-static. A regression test paints previews on four threads at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:14 -04:00
ajandClaude Opus 5.5 87672543cd feat(desktop): import a BOM into a drawing set
Drawing Sets > More > Import BOM reads a BOM and its drawing folder,
matches each drawing to the library by its D4 identity and geometry
(reuse, new revision, or new drawing) and opens the set editor with a
new set prefilled with the BOM quantities. Reading only queries the
library; Create Set lists the uploads and revisions and asks before
storing them, and the set is stored only by the editor's Save.

Parts with no material or thickness still import and are flagged for
Edit Drawing. DrawingPackage.GeometryKey compares geometry independent
of the fresh entity ids each DXF import assigns. In Database mode File >
Import BOM opens this flow instead of creating nests directly; File mode
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 a9198e5c25 feat(desktop): New, Edit and Make Nests lead the Sets tab; the rest goes in More
The set browser's six equal buttons become New Set..., Edit..., More and a
bold Make Nests.... Archive/Restore, History... and Releases... move into a
menu that the More button drops down and that is also the set list's
right-click menu (menu key and Shift+F10 open it; right-click selects the
row first). Access keys: &New Set, &Edit, M&ore, &Make Nests; the menu has
&Archive/&Restore, &History, Re&leases. Enabled rules are unchanged; More is
enabled while any of its items is. The picker hides More and has no menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 6a556b75ab feat(desktop): slim the set editor to drawing, material, quantity and note
The member grid shows one Material column (name, grade, thickness and
units, such as "Steel A36 0.25 in"), Qty and Note; Cut type appears only
when the set mixes cut types. Move Up/Down are gone: member order only
breaks ties (materials are grouped and sorted on release, nests list
drawings by name), and the stored order is kept as it was. The dialog
opens at 1040 px instead of 1264, with the library pane unchanged and
still resizable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 b376d217dd feat(desktop): one Drawing Sets window with Sets and Releases tabs
The drawing sets window now has two tabs. Releases hosts the release list
(paging, nests, Undo Release, New Release) as a SetReleasesView control that
replaces SetReleasesForm. File > Releases stays as a shortcut that opens the
window on that tab. A set's Releases (Sets tab) and a library drawing's
Releases (Drawing Library) open the tab filtered to it, with the filter shown
above the list and Clear Filter to list every release. Releases re-reads its
page whenever the tab is shown again, so a release made on Sets appears.

The set editor gets History... for a saved set; the Sets tab keeps its
History button until the browser's buttons are rearranged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 49bb7389f7 feat(desktop): preview the release and fold its options away
The release dialog now reads as a sentence ("Release set 'X' ×N") and
previews what Make Nests will do ("Creates 2 nests: ..."), with refused
materials counted and Fix Drawings reachable without selecting them.
An Options toggle holds the Release ticks, the Nest column and Include
archived drawings; collapsed, an option stays shown while it differs
from its default, and the Nest column shows whenever a Draft nest could
be merged into. Defaults, release, undo and retry are unchanged. The
Status column wraps so refusals are not cut off, and the retry status
reads "... Try Again records it; the nests are not made again."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 ee208b2766 feat(desktop): say "set" and "quantity" in the set and release dialogs
Wording only, no behavior change: "kit" becomes "set" or "Quantity",
the release button reads Make Nests... (Try Again after a failed
record), the releases list shows Material and Action, and the release
dialog's status lines are shorter. Help in docs/nest-storage.md follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:12 -04:00
ajandClaude Opus 5.5 0d1dade063 feat(desktop): open Edit Drawing from everywhere and warn before duplicates
Builds on the library's Edit Drawing (details as a new revision):
- warns before saving details that would duplicate another active library
  drawing (D4), naming them; a failed lookup also asks;
- File > Drawing Library, a nest-less mode of the library browser that lists
  every active drawing and edits on double-click;
- the set editor's member grid (double-click, right-click, button);
- the release dialog: Fix Drawings on a group refused for missing material or
  thickness or duplicate names, so those refusals are no longer a dead end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:12 -04:00
ajandClaude Opus 5.5 48863d51c8 feat(support): add slat support beds alongside the grid
Slats are strips of a set thickness running the full length or width of
the table, one every pitch. A part's contacts are the corners of where its
material crosses a slat, so the support check and support tabs work as on
the grid. Nest > Support Bed... picks grid or slats; nest files store
type "slats" and support-grid.json keeps both layouts' values. Draw
Support shows the slats cut off to the sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:12 -04:00
ajandClaude Opus 5.5 cd1961e26b fix(desktop): keep parts muted when Sequence by Path is restarted
Starting the tool while it was open built the new action before the old
one was disconnected, and the old one's teardown cleared the new muting.
The outgoing action now leaves the view alone once a successor owns it,
and opening the tool repaints the plate right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:12 -04:00
aj 414479fc4e feat(library): edit a library drawing's details as a new revision
Edit Settings becomes Edit Drawing: name, customer, material, grade,
thickness and cut type are editable beside the nesting settings and
notes. Units stay read-only. The save is a metadata revision with the
same package, so nests keep the revision they hold, and the release
refusal for drawings without material or thickness now has a fix.
2026-10-24 10:02:23 -04:00
aj bc21382c5d fix(io): give re-imported unchanged DXFs the same entity ids
Entity ids came from a process-random sequence, so importing the same
DXF twice produced different source-entity ids and therefore a
different drawing library package (different SHA-256), and an importer
could not tell an unchanged file from a changed one.

CadImporter.Import now derives each entity id from the file's SHA-256
and the entity's position in the import. An unchanged file gives the
same ids and the same package bytes; any change to the file gives
entirely new ids, so suppression, roles and edge selections keyed by id
never move to a different entity.
2026-10-24 09:12:48 -04:00
ajandClaude Opus 5.5 ba2102c793 build(help): ship help v0.3.0 with the CAD Converter, Auto Nest, Plan the cutting and Check a nest guides
Auto Nest and the CAD Converter open these pages from F1 instead of the
short first-nest sections, and cutting planning and nest checks get their
first help pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:40:07 -04:00
ajandClaude Opus 5.5 4b312a552d build(help): ship help v0.2.0 with the Machines page
v0.2.0 adds the Machines page (nest machine, default machine, per-machine
support grid, machine lead-ins) and the machines, nest-machine and
support-grid topic IDs. desktop-help.md now points at the help repo's
version rules so the next release is numbered the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:54:55 -04:00
ajandClaude Opus 5.5 a3caff6e40 feat(status-bar): show the nest's machine and change it from the label
The assigned machine now drives the support grid, lead-ins and cut-offs, so
it should be visible without opening a menu. The status bar shows
"Machine: <name>" beside the material; left- or right-clicking it lists the
machines (the same list as Nest > Machine) above the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:40:07 -04:00
ajandClaude Opus 5.5 6b63d41030 feat(cutting): start plates from the assigned machine's leads and cut-off settings
Machine configurations already held a lead-in, lead-out, kerf and cut-off per
material thickness, but nothing read them: Plan Cutting and Place Lead-in
started from whatever was last applied (often another machine's or another
thickness's leads), and cut-offs used fixed view defaults. Now that nests carry
a machine, those values can be the starting point.

- MachineCuttingDefaults maps a machine's thickness settings for the nest's
  material/grade/thickness onto CuttingParameters and CutOffSettings,
  converting units. Clean Hole stays on holes; Line + Line is not mapped
  because the configuration lacks the second segment.
- Plates without their own parameters start from the last-applied ones with
  the machine's leads and kerf laid over them; plates with parameters keep them.
- The plate view's cut-off clearance, overtravel, minimum segment and direction
  follow the machine on open, on Nest > Machine and after Nest > Edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:27:58 -04:00
ajandClaude Opus 5.5 9c78b4ebec feat(machines): assign each nest a machine and keep the support grid per machine
The support grid was one shared support-grid.json, off by default, and a nest
had no machine at all, so a plasma table and a fiber laser could not have
different grids, and nothing could pick machine-specific behavior later
(lead-ins differ between laser and plasma; tabbing depends on the grid).

- Nest.Machine (id + name) persists as top-level `machine` in nest.json;
  malformed values load as none.
- MachineConfig.SupportGrid holds each machine's grid; a machine without one
  still falls back to the legacy support-grid.json.
- New and BOM import assign the Tools > Nest Defaults machine (first by name
  when none is chosen) and give the nest that machine's grid.
- Nest > Machine reassigns a nest and swaps in the new machine's grid;
  Nest > Support Grid saves machine defaults to the nest's machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:17:28 -04:00
ajandClaude Opus 5.5 993555dba6 feat(cutoffs): head-locked overcut at the far edge, reversible by Plan Cutting
Stock can arrive up to 1.5 in wider than nominal, so only the origin-side
edge is certain. A cut-off with a head-locked overcut cuts past the nominal
far edge with the head height locked, covering the oversize. With that lock,
Plan Cutting may start the far-edge segment at the far edge: overcut outward,
rapid back still locked, unlock, cut toward the origin. Without it the
direction stays fixed, as decided in Project Memory a20ea455.

- CutOff.HeadLockOvercut / FarEdgeFirst, persisted in .nest; HEAD HEIGHT
  LOCK/UNLOCK comment markers; far edge is quadrant-aware.
- Planner offers the reversed far-edge segment in the joint search, the
  safest route and segment insertion; Apply records the direction.
- Cincinnati CL and CI Fiber posts gain lock/unlock code settings and refuse
  head-locked cut-offs while either is empty.
- Automatic cut-off dialog setting (off by default, 1.5 in), kept between
  sessions and used for new cut-offs.

Lock codes and machine behaviour with the head locked are unconfirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:51:59 -04:00
ajandClaude Opus 5.5 13352b8acd build(desktop): install the pinned help into dev build output
Only the packaging scripts installed Help\, so F1 and the Help menu in a
dev build always said help was not installed. The build now downloads the
help.json release once into obj\ and copies it beside OpenNest.exe; CI and
publish output are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:58:03 -04:00
ajandClaude Opus 5.5 7ce5e73a82 refactor: share duplicated paging, HTTP and helper code
Roslyn duplicate scans found the same code copied across layers:
- Data: the three browse sessions share BrowseSession<TPage, TSort>;
  the remote clients share NestServerHttp (JSON options, If-Match,
  error text).
- WinForms: five paged lists share BrowsePager for loading text,
  navigation, sort glyphs and failure messages. The Add Drawings to
  Release, Drawing Library and set browser dialogs now dispose their
  browse sessions.
- Server: NestQueryRequest reuses DrawingLibraryRequest's helpers;
  revision history queries share count and paging binds.
- Engine: RemnantPlateNester delegates to DefaultPlateNester;
  CuttingPlanService reuses JointCuttingPlanSearch.Map.
- Core: LineArc and CleanHole lead-ins share LineArcApproach with
  unchanged arithmetic order, so output is bit-identical.
- IO: Dxf.GetGeometry overloads share one reader; .nest JSON test
  helpers move to NestArchiveJson.

Also fixes the production compiler warnings (CS8632, CS8603, CS0108)
and moves a test off an obsolete forwarding API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:45:36 -04:00
ajandClaude Opus 5.5 a00e2c3233 chore(server): add a script that redeploys a development build over SSH
No verified server image has been published yet, so the shop server runs
development builds. Building on the host meant cloning there and hand-editing
compose; the script builds with the local Docker engine, ships the image,
backs up the data volume and waits for the health check, printing what a
rollback across a schema upgrade needs. docs/nest-storage.md describes it
beside the Compose deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:44:31 -04:00
aj 0785bb84c9 fix(planning): learn a door's hole order when a hop crosses its own cut holes
Rapids may not cross holes already cut. On an instrument panel, mounting
holes either side of a cutout become unreachable from each other once
the cutout is cut, so the holes must come first. The contour-by-contour
search backtracks chronologically through every entry combination of
the last holes before it changes their order, and ran out of budget.
Supplied-blank doors fell to an unverified safest route.

Three changes:
- Contour recovery, the part solver that memoizes failed states, now
  runs for display-perimeter parts too: their holes are the whole part,
  with no outside cut to finish on.
- A door alone on its sheet gets the recovery pass first. Any other
  lone part keeps the plain search, whose routes are shorter.
- Recovery learns, as whole-part ordering does: a hop onto a hole that
  crosses holes of the chain teaches "cut it before them" (skipping any
  rule that closes a cycle) and restarts the chain, with holes still
  waiting for a prerequisite ranked last. Rules only order candidates;
  no hole is skipped. Until a rule is learned the chain is unchanged.
2026-10-23 07:43:39 -04:00
aj 45592a811c fix(planning): start an entry beside a contour vertex exactly at the vertex
Emission splits a contour at its entry with Arc/Line.SplitAt, which treat
a point within Tolerance.Epsilon (1e-5) of an endpoint as that endpoint
and start the contour there. The lead-in still ended at the entry point
itself, so an entry 1e-8..1e-5 from a vertex left that span uncut and the
plan's own verification refused it ("does not preserve complete directed
contours").

Imported PEP mounting holes hit this: two half circles whose stored centre
sits 5e-8 beside the chord's midpoint, so the closest entry to an approach
from straight below lands 5e-8 along the first arc from its start. Panel
doors were refused on every plate holding such a hole.

PreparedContours now snaps an entry 1e-8..1e-5 from its entity's start or
end to the exact vertex, for automatic entries, scrap pierce entries and
drawing pins alike, so the lead-in ends where the contour starts. Entries
already within 1e-8 are left as they were, so saved pinned programs
re-emit unchanged. Entries de-duplicate on the snapped point, and
near-corner insets keep the snap distance from both edge ends so none can
snap onto a reflex vertex.
2026-10-23 06:54:23 -04:00
ajandClaude Opus 5.5 39f28c7fa9 feat(server): report a nest's sheet sizes and part counts
GET /api/nests/{id}/sheets reads the stored archive's nest.json and returns
the sheet sizes it needs (grouped, width the shorter side, quantities summed)
and each drawing's required and nested counts, skipping the trailing empty
plate as plateCount does. MaterialLedger builds material holds from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 06:01:43 -04:00
ajandClaude Opus 5.5 07e7d7edcd perf(planning): plan parts with thousands of holes in seconds
A part with about 1,700 holes (P241017-01) never planned: material
capture paired every boundary with every other one and lead checks
scanned every boundary per lead, so both hit the 1M native query limit
and the plate fell back to an unverified best-effort plan.

- Material capture sweeps boundary extents along X and queries only
  pairs the lead checks' per-pair clearance cannot skip, in the full
  scan's order.
- Material with 64+ boundaries keeps a grid of boundary extents. Lead
  checks, contour-joint matching and replay's contour accounting query
  only nearby boundaries; ray parity reads only holes on the point's row.
- Only native queries that run are charged to the budget; skipped far
  material no longer costs its full boundary count.
- The search looks up chosen holes and route ranks instead of scanning.

With that the plate plans Ready, but in 189 s: every step re-emitted
and re-checked all holes already cut. On parts with 64+ contours a
middle hole step now checks only the contour it adds, continuing the
completed-contour state its prefix left (ReleasedContourState.Continue).
It falls back to the full prefix whenever it cannot continue exactly,
and the last contour still checks the whole part from before it.
Isolated contour emission clones only the chosen contour.

P241017-01 now plans Ready in 5.4 s with an identical route, expansions
and rapid travel. Core and engine suites pass; the problem-nest corpus
matches the unchanged commit's outcomes and expansions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 06:01:43 -04:00
aj bbadd6aa75 fix(cut-offs): accept a blank's outline a rounding step past the sheet
Automatic cut-offs refused any part reaching past the physical sheet by
more than Tolerance.Epsilon. Imported supplied blanks (panel doors,
remnants) equal their sheet but carry PEP's rounding: a 0.000006 outline
skew plus placements rounded to 0.001 put the never-cut outline 0.00001
to 0.00003 in past the sheet, so Plan Cutting refused every such nest.

A display perimeter may now extend up to DisplayPerimeterSheetAllowance
(0.0001 model units) past the sheet. Nothing cuts it, and a blank that
fills its sheet gets no cut-offs. Everything else on the part keeps the
ordinary tolerance: cuts, leads, scribes and travel, except a rapid that
ends on the outline itself (the move to its first corner, or an applied
program's display tail). A larger overhang is still refused.
2026-10-23 05:55:32 -04:00
ajandClaude Opus 5.5 c188014a30 fix(ci): expect a blank material grade on migrated legacy nests
Server schema v8 adds materialGrade, so the migrated legacy records now
report it and the server-image smoke's manifest comparison failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:17:17 -04:00
ajandClaude Opus 5.5 069efc2368 build(help): pin opennest-help v0.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:08:18 -04:00
ajandClaude Opus 5.5 ed92c37845 feat(desktop): add WebView2 help window with F1 topics and pinned help packaging
Help > OpenNest Help (F1) and context F1 in Auto Nest and the CAD Converter open
the bundled opennest-help site in a single WebView2 window on its own UI thread.
Both packaging scripts install the opennest-help release pinned in
scripts/help.json, verified by SHA-256. The missing lead-in finding now says
the contour is missing lead-in geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:08:18 -04:00
aj 00cb639c8e fix(posts): refuse unpostable blanks before writing and malformed tails
With the pre-post risks acknowledged, an unplanned blank reached the
Cincinnati and engraver posts, which created the output file before
refusing: an existing program at that path was emptied and the desktop
had no handler for the refusal. Both posts now check every part before
creating the file, and Post shows the refusal like the multi-file path.

A display tail marker is now honoured only as one marker ending the
part's own program, followed by a rapid and the outline alone. A marker
inside a sub-program or followed by real cuts is refused by posts and by
executed reads, so cuts after it can never be dropped from both
verification and output.
2026-10-22 20:30:33 -04:00
aj 9e4361fd11 fix(planning): refuse cutouts that cross a display perimeter
Enclosure was tested at one point per entity, so a round cutout starting
on the blank but running past its edge, or a cutout whose corners all sit
on an L-shaped blank while an edge crosses its notch, reached an
unverified plan that warning acceptance could apply, cutting into or past
the never-cut edge. Every cut is now sampled along its arcs inside the
outline and must not cross or touch it, and the material capture names the
display perimeter when a cut crosses it.
2026-10-22 20:30:33 -04:00
aj 3c3bab0a9b test(planning): plan a blank with support tabs and a pierce zone
A support bed that cannot hold a blank level and a pierce zone both steer perimeter-led planning paths. A blank has no cut perimeter, so both settings must still plan only its cutouts, with every cut inside them.
2026-10-22 20:18:47 -04:00
aj 6201065034 fix(posts): never post a display perimeter
The posts treat every non-lead feed move as a cut, so an applied blank's
display tail, or the outline of a blank posted without a plan, would have
been cut along the blank's edge. The pre-post review counted that outline
as an ordinary cutting contour.

Cincinnati, CI Fiber and the engraver now read part programs through
DisplayPerimeterTail.ForPosting: an applied program posts exactly what the
same program posts with its tail removed by hand, and an unplanned display
perimeter is refused with a message that names the part and points to Plan
Cutting. The pre-post review reports the unplanned outline by name.
2026-10-22 20:15:58 -04:00
aj 684207039a fix(leads): keep manual lead-ins off a display perimeter
Place Lead-in built its contours from cut-layer entities only, so on a
blank the largest cutout looked like the outside and took an external
lead pierced on the blank; committing then regenerated the part with the
outline as a cut perimeter. The legacy whole-part strategy did the same.

Both strategies now refuse a program with a display perimeter and leave
the part unchanged, and Place Lead-in explains that such a part's cutouts
get their leads from Plan Cutting instead of selecting it.
2026-10-22 20:09:23 -04:00
aj 8f568482bc fix(planning): keep a blank's outline on its applied program
An applied cutting program for a display-perimeter blank held only its
cutouts, so the part shrank to them: a 30 x 20 door reported an 11.5 x 20
extent, overlap and the plate view lost the blank, and running automatic
cut-offs afterwards added lines straight through it.

A complete emission now ends with a display tail: a marker comment, a
rapid to the outline and the outline on the display perimeter layer. The
part keeps the blank's bounds and material, so later cut-offs still have
nothing to cut and the door plans again as before. Nothing executes the
tail: executed reads stop at the marker, so search, replay and the
pre-post review see only the cuts, and cut time, the rapid display and the
cut simulation leave it out. DisplayPerimeterTail.ForPosting gives posts
the program without it and refuses an unplanned display perimeter.
2026-10-22 20:06:15 -04:00
aj 73e54fbd67 fix(planning): refuse misplaced display perimeters by name
Review found three blanks that slipped past or failed silently. Enclosure
was checked against the outline's bounding box, so a cutout in the notch of
an L-shaped blank reached an unverified plan that warning acceptance could
apply; every cut point is now tested inside the outline itself, and the
material capture names the display perimeter when no ring encloses the
rest. A blank whose only cutouts are tabbed left the search with nothing
to choose and ended without a finding; it is now refused with its reason,
as is a blank with nothing to cut, which used to get only the generic
noncutting message. None of these offers even an unverified plan.

Clean-bounds containment, seam overcut trimming and incremental closure
drift now treat the display perimeter like the other material boundaries.
2026-10-22 19:56:14 -04:00
aj b45e9df593 feat(io): save and reload display perimeters
A display perimeter written without its own tag reloaded as an ordinary
cut, so a saved blank would come back with its outline to be cut. Programs
now carry it as :DISPLAYPERIMETER and the reader maps it back, and stored
source entities keep the DISPLAY PERIMETER layer so editing the drawing
rebuilds the same never-cut outline.

Builds older than this read the unknown tag as a cut. Open nests with
display perimeters only in builds that include this change.
2026-10-22 19:34:52 -04:00
aj 6e3705eb89 feat(planning): cut holes inside a display perimeter that is never cut
Some jobs cut only cutouts into a supplied blank, such as instrument holes
in a purchased door that is the whole sheet. Its outline has to be known
to the planner (the blank is material that leads, rapids and cut-offs must
respect) but must never be pierced, led or cut.

A new DisplayPerimeter layer marks that outline. It is material, so
nesting, containment, lead clearance and automatic cut-offs all see the
blank; a door that fills its sheet leaves cut-offs nothing to cut. Cutting
preparation keeps it out of the contours entirely: with no cut perimeter
every contour is a hole with internal leads, and the search, best-effort
and safest-route paths stop offering a perimeter. Replay refuses any run
along the display perimeter and completes once every other ring is cut.

A display perimeter must be one whole closed contour enclosing every other
contour; a mixed ring, one inside another contour, or a second one is
refused with that reason. Saving, drawing and posting it are still to do.
2026-10-22 19:32:22 -04:00
ajandClaude Fable 5.1 c325a1745a fix(desktop): list the first sets in Add a set before anything is typed
The release dialog's Add a set box was a pure type-ahead: it queried the
server only after typing paused, so opening it, or clicking its arrow with
nothing typed, showed an empty list. From Drawing Sets > Release that was
easy to miss, but New Release starts with nothing listed, and an empty
dropdown there reads as a missing server connection. The dialog now reads
the first active sets by name when it is shown, and again when the list is
opened while still empty, without dropping the list open on its own; typing
narrows it as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 14:50:07 -04:00
ajandClaude Fable 5.1 dc4fc1d593 feat(desktop): list every release and start new ones from File > Releases
Releases could only be reached through one set or one library drawing, so
seeing what the shop released lately meant knowing which set to open first.
File > Releases shows every release on the server, newest first, naming what
each one had ("Kit A ×2, Kit B; Bracket ×4") in place of the per-set
revision and kits, with the same nest list and Undo Release. New Release
opens the release dialog empty, so a release can start from the list rather
than from a set; the plan builder now accepts no lines as an empty plan,
and the dialog's Release button stays disabled until a group is listed.
The list re-reads from the newest page once the dialog records a release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 13:27:20 -04:00
ajandClaude Opus 5.5 d4903a648c feat(desktop): pick sheet gauges and fractions from thickness dropdowns
Operators had to know that 16 ga is 0.0598 to enter a thickness. The Nest
Info and Save to Drawing Library thickness boxes now list steel gauges
(26-3 ga) and plate fractions (1/16"-2") with their decimals, and read a
typed gauge ("16ga", "#16"), any fraction ("3/32", "1-1/4") or a decimal.
Unreadable text blocks OK/Save instead of reading as zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:04:55 -04:00
ajandClaude Opus 5.5 9d69c89b21 fix(desktop): keep placement previews selected when Delete is pressed
Delete during placement went to the plate view's delete-selected path, which
treated the unplaced previews as selected parts and deselected them, so they
drew solid like placed parts. Route Delete to the clone action instead (its
own handler, which removes the originals when cloning placed parts, never ran)
and re-flag the previews as selected whenever they are reselected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:26:16 -04:00
ajandClaude Opus 5.5 939d7bfff2 feat(desktop): adjust nest quantities from the drawing library dialog
After adding library drawings the operator had to close the dialog to see what
the nest now needed, and fixing a quantity meant leaving it for Edit Drawing
one part at a time. The new In This Nest tab lists the nest's drawings (cutoffs
excluded) with their library revision and required, nested and remaining
quantities, refreshes after each add, and edits Required in place.

An edit applies to the nest immediately, like an add, and the nest window
refreshes its drawing list when the dialog closes. Out-of-range values are
refused both when the cell is validated and when they are applied, because
DataGridView only validates on leaving the cell and EndEdit commits without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:57:30 -04:00
ajandClaude Opus 5.5 8fd1fabc54 feat(desktop): add library drawings on their own to a release
The Release dialog lists sets and drawings in one grid (with a type
column; Kits / Qty is a set's kits or a drawing's quantity, up to
100,000). Add Drawings... browses the library as the set editor's pane
does and stays open for several adds; listed drawings are greyed and
skipped, archived ones refused. The Drawings tab gains Release... (when
opened from a nest) to start a release from one drawing, and
Releases... so a release of drawings alone can still be found and
undone, since it belongs to no set's history.

docs/nest-storage.md describes the desktop flow, the `drawings` wire
field, null item setId, the drawingId query/probe and schema v11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:55:40 -04:00
ajandClaude Opus 5.5 54b15cff19 feat(releases): release library drawings on their own beside sets
Operators want to release a few loose drawings without first building a
set for them. The release record gains a `drawings` list (drawingId,
quantity, joined drawingName) and nest items with no `setId` carry those
drawings' quantities, so grouping, merging and undo stay one path.

Schema v11 adds release_drawings and rebuilds release_nest_items with a
nullable setId (SQLite cannot drop NOT NULL), keeping every row. The
query gains `drawingId=`; a server from before this refuses that unknown
parameter with 400, which the client turns into ServerTooOldException.
SetReleaseSession asks before building anything, because the release is
posted only after its nests are saved and an old server would refuse it
then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:54:30 -04:00
ajandClaude Opus 5.5 f657292801 docs(readme): point to Releases and invite source builds
The old note steered readers away from the source archives, but building
from source is a supported way to run OpenNest and should be encouraged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:49:34 -04:00
ajandClaude Fable 5.1 8f27eac2ca feat(cutting): plan each scrap cut-off segment where the route passes it
Plates with staged automatic scrap cut-offs could not be planned: P260810-01
plate 1 ran out of budget even at 10x effort, while the same plate plans
Ready in 3.6k expansions without the lines. A cut-off was one fixed program
cut whole before every part its line passes through, which dragged the tour
to the middle of the sheet and left the head at the far edge of the line;
and cutting every line first (the rejected alternative) weakens the sheet
and breaks the containment operators rely on when they fast-forward a
changed program.

A cut-off stays one definition, but Plate.RegenerateCutOffs now makes one
part per trimmed segment, each with its own place in the cut sequence.
CutOffSegments gives each segment the span of the nominal line it stands
for (end of the segment before it to start of the one after), and the
dependency rules of the planner and Part Sequencing use that span, so a
segment precedes only the parts beside it. The free-order search plans the
parts as if the segments were not there, so adding lines no longer changes
a plate's route, then CutOffInsertion slides each segment into the latest
gap before its first part where the rapid into its pierce and the rapid
from its end cross nothing cut before; only when one fits nowhere is the
plate searched with the segments among the parts. The search looks through
segments wherever they sit (entries face the next part, hole routes rank
from the pre-segment position, rapids are still checked from the segment's
end), so an applied plan replanned with the order kept replays Ready.
Segments saved out of consecutive order are recorded per segment in the
.nest file (`segments`); older readers load them consecutively.

P260810-01 plate 1 now plans Ready in 3639 expansions with every check
passing; rapid travel 1836 against 1547 without the lines. Plates without
cut-offs keep their PlanningCharacterizationTests digests, and the
problem-nest corpus has no plate below its baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 11:38:38 -04:00
ajandClaude Opus 5.5 ff46de69df feat(planning): offer the safest route when the strict search runs out
Cut planning should rank routes safest first, then fastest, but a plate whose
strict search hit its expansion budget was simply refused ("no plan found
within the search limit"), even when a route with a few reviewable rapid
crossings existed. Scrap cut-offs make this common: their trimmed segments
can wall off a column of parts, and rapids are straight.

When both the free-order and current-order searches stop at the limit, the
batch now walks a greedy safest route from each input, replays it with fresh
checks, and keeps the one with fewer violations (material first, then rapid
crossings, then the rest), then less rapid travel. A route that passes every
check is an ordinary Ready plan; otherwise it is an unverified BestEffort
that lists every violation and applies only with explicit acceptance. Routes
with any motion into material are never offered, so the strict refusal and
its lead-in advice stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:17 -04:00
ajandClaude Opus 5.5 1fc36e9e54 feat(desktop): open drawing sets from File without a nest
Releasing a set creates new nests or merges into draft ones, so tying the
set browser to the Drawing Library dialog (which needs an open nest) put it
behind a document it never uses. File > Drawing Sets... (Database mode)
now opens a standalone window with the set browser and Release, opening
released nests in their own tabs. The library dialog loses its Sets tab
but keeps Used in Sets and Add to Set on a drawing's menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:04 -04:00
ajandClaude Opus 5.5 099a020424 feat(desktop): preview the selected drawing in the drawing library
The library browser showed only a grid and a notes box, so picking the right
part meant reading names and guessing at the shape, and the add controls sat in
a crowded row with the pager. The Drawings tab now splits into the list (with
its pager underneath) and a details pane: a geometry preview, the drawing's
name, customer, material, thickness, cutting and revision, its notes, and the
quantity with a prominent Add to Nest button.

The preview downloads the listed revision's package after the selection settles
for 150 ms, shares a request already loading that revision, and caches drawings
per revision for the dialog's lifetime. It colors the drawing from the record's
settings without advancing the nest's part palette. It is best-effort: any
failure shows "Preview unavailable" and the dialog keeps working, and the UI
update is marshalled explicitly because an STA thread without a WinForms
synchronization context resumes the await on the thread pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:03 -04:00
ajandClaude Opus 5.5 c58ae6f9e1 fix(desktop): keep the plate list selection in sync with the header arrows
Navigating plates from the plate view header (or Ctrl+Left/Right) changed
the plate without updating the plate list, so its highlighted row pointed
at a different plate. Select, focus and scroll to the current plate's row
whenever the current plate changes, guarded so the list doesn't reload it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:03 -04:00
ajandClaude Opus 5.5 63771b0029 feat(desktop): open the drawing and shape libraries at the Open dialog's size
The library dialogs opened small and centered on the main window while File > Open
fills 90% of the owner's screen, so browsing them felt cramped next to it. The
screen-fit sizing moves into a shared DialogBounds helper that all three use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:02 -04:00
aj 09d58ce064 feat(posts): map Carbon Steel to the CI Fiber MSN material code
Steel is "Carbon Steel" in OpenNest, matching the Cincinnati post's cut
libraries. The CI Fiber material-code table only listed "Mild Steel", so a
Carbon Steel nest reached MSN only by falling through to the default code.
List "Carbon Steel" explicitly in the shipped JSON and the built-in
default, and keep "Mild Steel" for nests that already use it.

Tests check both names against the shipped config and the built-in
default; the Carbon Steel rows failed before this change. Posted output
is unchanged, since the default code was already MSN.
2026-10-21 18:36:35 -04:00
ajandClaude Opus 5.5 64eca9775f feat(sequencing): mute parts until a line or click sequences them in Sequence by Path
With every part drawn the same, it was hard to see which parts the drawn
lines had not reached yet. Parts no line reaches are now drawn muted, and
update live while dragging. Clicking a part adds it as a one-point line, so
a stray part can be picked without drawing a line to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:50:12 -04:00
ajandClaude Opus 5.5 d1c77b8ec7 fix(sequencing): cut parts no drawn line crosses last in Sequence by Path
Every part was claimed by its nearest line even when the line never reached
it, so a part beyond a line's start projected to distance 0 along it and was
cut near the front. Parts now belong only to lines crossing their bounding
box; uncrossed parts follow all the others, ordered by their nearest line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:50:11 -04:00
ajandClaude Opus 5.5 35359906fe feat(fill): add tilted parts beside a layout whose leftover strip is too narrow upright
Fill only tried 0 and 90 degrees for parts classified as rectangles, so a long
part filling a 60 x 120 plate four high at 90 degrees left a 55 x 59 strip empty
although two more fit tilted about 33 degrees (a reported nest: 4 placed, 6 fit).
The second tilted part only fits with its corner tucked beside the grid, in the
strip the grid's slack leaves, so a plain rectangular remnant fill finds one.

TiltedRemnant runs after the other strategies. It moves the best layout flush to
one end so leftover room becomes one remnant, and to either end of the other axis
so its slack forms one strip; then tiles the part at the most upright angles that
fit the remnant's height and keeps only copies at least the part spacing from the
layout. It is skipped when the part fits the remnant upright, for circles, for an
explicit rotation range and once the requested quantity is placed.

Fill benchmark (--parallel 1): the synthetic set is unchanged (all valid, same
cost and plates). The reported part needs 1 sheet instead of 2 for 6 parts and
2 instead of 3 for 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:49:59 -04:00
ajandClaude Opus 5.5 5ecdc98581 feat(server): add a company material list with list/add endpoints
Nests, drawings and releases store material and grade as free text, so
there was no canonical list for clients to suggest from or to fix
spelling against. Schema v10 adds materials and material_grades keyed by
SharedListNames.Key, with GET/POST /api/materials. Adding is idempotent
by key and keeps the first spelling.

The tables start empty: the materials already stored did not import
cleanly, so the list is filled through the API instead. Existing
free-text columns are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:32:41 -04:00
ajandClaude Opus 5.5 3ca7d6185f feat(desktop): nest released drawing sets with Auto Nest before saving
A release now ends in nests ready to cut rather than empty drafts, and a
merge no longer leaves the draft it cleared waiting for someone to run Auto
Nest. "Nest after release" (on by default) runs Auto Nest headless on every
released group, new or merged, before anything is uploaded.

NestRenest removes every plate and nests each drawing's required quantity
onto sheets from the nest's plate defaults and plate-size options. It keeps
a layout only when it is valid and keepable, so an invalid result never
reaches the server; the nest is saved without sheets and the operator is
told to run Auto Nest. Unplaced copies stay as remaining quantity, and the
status column lists sheets, utilization and what did not fit. A new Nesting
progress stage reports each group, and cancelling while nesting saves
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:55 -04:00
ajandClaude Opus 5.5 f6781d27c3 feat(desktop): release several sets, merge into drafts, and undo releases
The release dialog now lists the sets to release instead of one set with a
multiplier. More are added by typing a name (the matching active sets fill
the list as you type) and each set has its own kit count. Its groups span
all the sets, and each group can make a new Draft nest or merge into a Draft
nest of the same material, grade and thickness, listed with its sheet count.
The status column says that a merge discards the draft's layout and that
Undo Release restores it.

A draft open in a tab of this desktop is refused, because nest tabs do not
track unsaved edits and the merge would leave them behind. The main window
tells the dialog which server nests are open.

The release history shows what each release did to each nest (created, or
merged from one revision to another, and what undo did) and gains Undo
Release. When nests changed after the release, it names them and offers to
undo the others, leaving those as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:54 -04:00
ajandClaude Opus 5.5 74203483c5 feat(data): release several sets in one plan and merge groups into draft nests
The release plan now takes several sets, each with its own kit count. A
drawing listed by more than one set becomes one item with the quantities
summed and each set's share kept, so the release can record what every kit
put into every nest. FindDraftsAsync lists the Draft nests of a group's
material through the new nest query filters.

The session can merge a group into a chosen draft instead of making a new
nest. It downloads the draft at the revision it was chosen at, refuses it
unless it is still a Draft of the group's material, thickness, units and cut
type, adds the items, and removes every plate so the whole demand is nested
again. The save is guarded by that revision, so a draft someone saved
meanwhile fails its group instead of being overwritten. An optional renest
step, run before anything is uploaded, will back "Nest after release"; if it
throws, the nest is still released without a layout.

The release record now carries every set line, merged nests with their
revisions before and after, and each set's drawing quantities.
SetReleaseText.UndoRefusal words a refused undo for the release history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:53 -04:00
ajandClaude Opus 5.5 4b604d3874 feat(server): filter nest queries by status, material, grade and thickness
A release that merges into an existing draft needs the drafts of the same
material group. The nest query can now filter exactly on status, material,
grade and thickness. Material and grade compare like the shared material
lists (case and spacing ignored) through a SQLite function, so no key columns
or backfill are needed; a blank grade asks for nests without one, and
thickness matches at the four decimals drawings are stored with.

The client sends the filters only when set, so plain browsing keeps working
against servers that do not know them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:52 -04:00
ajandClaude Opus 5.5 28db6ae807 feat(server): release several drawing sets at once, merge into drafts, and undo
A release can now cover several sets, each with its own kit count, so parts
from different kits share sheets. It can also merge into an existing draft
nest instead of always creating one. Schema v9 replaces the single-set
release tables: releases, their set lines, their nests (created or merged,
with the nest revision before and after) and the drawing quantities each set
added to each nest, so a kit stays traceable once kits share a nest. The v8
migration turns each existing release into one set line with created nests.

Undo runs on the server in one transaction: created nests are soft-deleted
and merged nests restored to their pre-merge revision, but only while each
still holds what the release wrote. Otherwise nothing changes and the
changed nests are listed, so the operator can skip them. A nest restored to
that revision counts as unchanged, which lets releases sharing a nest be
undone newest first.

The old /api/sets/{id}/releases and /api/nests/{id}/release routes are
replaced by /api/releases; no tagged desktop used them. The desktop still
releases one set at a time until the release dialog is reworked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:52 -04:00
ajandClaude Opus 5.5 19809c61d0 feat(sets): browse the library inside the set editor
Adding drawings to a set meant opening the library dialog, then the set
editor, then a second copy of the library in pick mode, then backing out
again. The set editor now has the library as its left pane (unfiltered,
searchable, sortable, paged): select drawings and Add to Set, double-click
or press Enter. Members already in the set are greyed. The library
dialog's pick mode had no other caller and is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:41:53 -04:00
ajandClaude Opus 5.5 382abf702b test(server): upload a real archive from the drawing set nest fixture
The drawing set fixtures landed on master while uploads still accepted
any bytes; the server now rejects a file without a readable nest.json,
so the fixture's placeholder payload made every release test that
posts a nest fail with 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:18:38 -04:00
ajandClaude Opus 5.5 2963764215 feat(io): let NestWriter keep a nest's own modified date
Write always stamped DateLastModified with the save time. That is right
for an ordinary save, but a converter carrying another system's date
cannot keep it: every PEP nest imported into the server recorded the
import time as its modified date.

PreserveDateLastModified (init, default false) writes the nest's own
date instead; every existing caller keeps stamping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:14:06 -04:00
ajandClaude Opus 5.5 64e24fc6c8 feat(server): take nest record counts, material and thickness from the archive
The server stored whatever plate count, part count, material and
thickness the client claimed, so a record could disagree with its own
archive, and a bulk importer had to read each written .nest back in full
just to describe it (about a quarter of the PEP archive import's time).

Uploads (POST and PUT /file) now read only the archive's nest.json -
never programs or geometry, and without referencing OpenNest.IO - and
overwrite those fields, following NestReader's fallbacks for thickness,
material, parts with a missing drawing and the trailing new-plate slot.
A file without a readable nest.json is 400 and stores nothing, so the
server can guarantee the record describes the bytes; nest.json
decompression stops at 64 MB. A metadata-only PUT keeps the stored
counts because the archive is unchanged; material and thickness stay
editable there, since the desktop applies the record's values on open.

Server tests that uploaded placeholder bytes now upload small
deterministic archives, and the container smoke's near-limit probe is a
padded real archive. New tests pin the server's values to a full
NestReader read of the same archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:14:06 -04:00
ajandClaude Opus 5.5 0eb6d5886a perf(geometry): flatten Shape.ToPolygon by chord tolerance by default
ToPolygon() used 1000 segments for every arc and circle regardless of
size: no more accurate for large arcs and needlessly dense for small
ones, which made every Drawing area and canonical-angle computation on
an arc-heavy part handle hundreds of thousands of vertices.

The parameterless overload now delegates to ToPolygonWithTolerance with
DefaultChordTolerance = 0.0001. That is ten times tighter than the
spacing and overlap checks' 0.001 because callers use this polygon for
area, which an inscribed polygon always under-reads: at 0.001, seven
area-sensitive cut-off, splitter and circle-packing tests drifted past
their precision; at 0.0001 all pass. ToPolygon(int) keeps the fixed
segment count for any caller that wants it.

With the one-pass Cleanup, a 300-nest PEP archive import on the forge
server went from 46.1 s to 15.5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:13:37 -04:00
ajandClaude Opus 5.5 9ab6fbbeaf perf(geometry): compact Polygon.Cleanup in one pass
Cleanup removed each duplicate vertex with List.RemoveAt from the middle
of the list, so a polygon cost O(duplicates x vertices). Shape.ToPolygon
emits a duplicate at every entity junction and flattened arcs to 1000
points, so building the Drawing for a large arc-heavy part spent seconds
shifting memory: one 1 MB PEP nest took 12.7 s to convert and 11.2 s to
read back, a quarter of a 300-nest archive import's total time.

The single pass compares each vertex with its original predecessor, as
the back-to-front loop did, so tolerance-equal runs collapse identically
and the output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:13:37 -04:00
ajandClaude Opus 5.5 8c7dc41ce8 feat(planning): warn about tipping parts and retire legacy lead-in assignment
Plan Cutting is now the only automatic lead-in assignment, and parts the
support grid cannot hold level are reported wherever they lack a
steadying tab.

- Remove Assign Lead-ins (plate and nest), LeadInAssigner, the lead-in
  tool's Auto-Assign button and the Auto Tab size range; old settings
  with autoTab sizes still load
- Manual Place Lead-in tabs the clicked entry when support tabs are on
  and the grid cannot hold the part (SupportTabSettings)
- New PartSupport finding: Plan Cutting replay and best-effort plans
  warn when a drawing pin, a lead-in that does not fit or no single tab
  leaves a part unsteady; the pre-post review checks each placed
  program's outside tab gap. These warnings replace the advisory
  weak-tab notes; only the inside-corner tab note stays advisory, and
  never for a pin
- Pinned perimeters keep their pin but carry the support analysis
- Draw grid points as pixel-snapped squares without anti-aliasing
- Post fixtures build lead-ins through a test-only TestLeadIns helper
- Docs: support tabs, pre-post verification and planner notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:54:40 -04:00
ajandClaude Opus 5.5 c3881c5534 fix(support): center tied support tabs across the part's long side
A tab cannot lift a part's margin past its contacts' own hull edges, so a
whole stretch of outline often ties for the best tab point, and the first
sample found won, leaving the tab off-center. Keep the full outline search
for the margin, then among samples within Tolerance.Epsilon of the best,
pick the one nearest the line through the center of gravity across the
long side of the outline's minimum-area bounding rectangle. A part held
only near one end is still tabbed at the far end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:39:05 -04:00
ajandClaude Opus 5.5 7bf753ce17 feat(storage): record material grade on server nests and pick it in Nest Info
Nest records gain a materialGrade field (server schema v8, blank for existing
rows) that is searched, sorted and versioned like the other metadata. The
Nest Info dialog gets a grade picker listing the machine configs' grades for
the chosen material; density survives only an unchanged name and grade. A
blank recorded grade keeps the archive's grade while the name matches, so
records saved before v8 do not lose it on open. Saved Nests shows a Grade column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:37:57 -04:00
ajandClaude Opus 5.5 11b6032459 feat(desktop): redesign the pre-post verification form for quick reading
Replace the plain-text report with a colored status banner, per-check
count tiles and a findings list grouped by check type, with the selected
finding shown in full below it. A Copy Report button keeps the full text
report available. Consent, cancellation and failure blocking are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:37:57 -04:00
ajandClaude Opus 5.5 0ca4e64189 fix(bestfit): drop saved pair sets whose canonical frame changed
Saved best-fit pairs are in the drawing's canonical frame, but the canonical
angle is recomputed on load and was not saved with the set. When that
computation changes (RotatingCalipers checking every hull edge, marks no
longer counted as material), old pairs were replayed in a turned frame and
could overlap.

Each saved set now records canonicalAngle, and BestFitCacheStore.Import
skips sets whose angle no longer matches the recomputed one (1e-9 rad,
modulo a full turn). Sets from older files have no angle and are dropped;
their pairs are recomputed when next needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:04:23 -04:00
ajandClaude Opus 5.5 f901840ed0 fix(flatbar): stop the swap optimization trading identical copies
TryImprove kept a swap when the trial run looked fuller than before, but
the trial charges each type's self-nesting gain again even when the run
already holds that type. Trading a placed copy for an identical unplaced
one therefore looked fuller by the gain and repeated forever: FlatBar ran
into the benchmark's 5-minute timeout on tabbed_cog_layout once the
all-edges MBR fix gave one part new rotations (it finished in 7 s before).

The swap is now judged on the merged run, so every kept swap really
leaves the run fuller and the loop terminates. Swaps the old test kept
that do fill the run are still kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:41:27 -04:00
ajandClaude Opus 5.5 059a873d32 fix(geometry): check every hull edge for the minimum bounding rectangle
MinimumBoundingRectangle(Polygon) evaluated only the first (n + 1) / 2
edges, assuming opposite edges give the same rectangle. That holds only
for centrally symmetric hulls: for the triangle (0,0), (10,0), (12,1) it
returned area 12 instead of 10, depending on vertex order.

Every edge is now a candidate. A second-half edge replaces the best only
when smaller beyond rounding, so symmetric hulls (rectangles, best-fit
pairs, whose MBR angle PairEvaluator uses directly) keep their previous
orientation instead of flipping 180 degrees on floating-point noise.

On the Desktop problem nests, 8 of 160 drawing entries now get a smaller
MBR (0.004% to 3.2%) and a turned canonical frame; none changed without a
real area gain. The problem-nest corpus is unchanged against f51e5aef.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:41:27 -04:00
ajandClaude Opus 5.5 0922e41401 fix(desktop): mute sheet cut-offs the cut simulator has not reached
Cut-off lines are drawn after the parts in their own red dashed pen, so they
stayed red over their muted parts. Draw them gray while the torch has not
started their part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:32:44 -04:00
ajandClaude Opus 5.5 ed9c1f9df6 feat(desktop): make the toolbar sequencing button run Sequence by Path
The click-each-part Manual Sequence tool stays under Plate > Sequence Parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:46:03 -04:00
ajandClaude Opus 5.5 50f966913d feat(desktop): show planning time and replan when inputs change mid-plan
The cut planner status shows the run's elapsed time, and its inputs stay
enabled while planning: changing one cancels the run and starts a new one.
Cut-off settings open once the cancelled run releases the plates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:38:38 -04:00
ajandClaude Opus 5.5 96f02c665c feat(desktop): preview sequence numbers live while drawing a path
While a Sequence by Path line is being dragged, the plate view shows the
numbers the finished line would give, without reordering the plate, so
reorder listeners (drawing list, overlap overlay, simulator) are not hit
on every mouse move. The plate is reordered once on release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:26:55 -04:00
ajandClaude Opus 5.5 aeb90d8a86 feat(desktop): sequence parts along lines drawn over the plate
Plate > Sequence Parts > Sequence by Path: each left-drag adds a line,
cut in drawing order; the jump between lines is a rapid and claims no
parts. Each part belongs to the line passing closest to its center and
is ordered by where that nearest point falls along the line. Cut-first
parts stay ahead and cutoff dependency rules still apply. A small window
offers Undo Line, Clear (restores the starting order) and Done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:12:04 -04:00
ajandClaude Opus 5.5 f51e5aefdd feat(planning): keep support tabs out of inside corners and report weak tabs
The best tab point used to land in inside corners: equally good points
run along a straight edge, vertices were tried first and the first tie
won. Operators cannot grind a nub flush there.

- SupportAnalyzer finds inside corners (inward-turning vertex runs of at
  least 30 degrees, so chorded fillets count) and keeps tab points a
  clearance from them; of equally good points it picks the one farthest
  from an inside corner. Plan Cutting and the overlay use twice the tab
  size; AcceptsTabAt rejects closer points
- When no steadying entry has a lead-in that fits, the search falls back
  to entries clear of inside corners, then to every entry, each ordered
  by how well the tab steadies the part, keeping only the steadiest that
  fit
- Replay notes parts whose planned tab cannot hold them level or sits in
  an inside corner; the notes are advisory and leave a Ready plan Ready
- Console/MCP plate summaries print a ready plan's notes
- docs/support-tabs.md and cutting-planner notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:18:16 -04:00
ajandClaude Opus 5.5 a7c0747e11 feat(planning): tab parts the laser support grid cannot hold level
Model the machine's support bed as a grid of contact points measured
from the plate origin, and check each part's center of gravity against
the convex hull of the grid points under its material. The bed is
extensible so straight and CAD-imported curved slats can follow.

- Core: SupportBed/SupportGrid, SupportMaterial and SupportAnalyzer
  (margin with registration tolerance, best single tab point)
- Nest-wide support bed that every plate follows, saved in
  plateDefaults.supportBed; machine defaults in support-grid.json
- CuttingParameters.SupportTabs: Plan Cutting tabs only unsupported
  parts and limits their perimeter entries to points where the tab
  steadies the part, falling back to any fitting entry
- Desktop: Nest > Support Grid..., View > Draw Support overlay and a
  "Tab parts the support grid cannot hold level" tab option
- docs/support-tabs.md, nest file format and planner notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:55:24 -04:00
ajandClaude Opus 5.5 b65876c031 feat(desktop): add a nest's library drawings to a drawing set
Operators often build a nest first and only then want it kept as a
reusable kit. The drawing list's new "Add Library Drawings to Set..."
takes every drawing copied from this server's library, sums copies of
one library drawing, and opens the set editor on a chosen or new set
for review and Save. Unlinked, other-server and zero-quantity drawings
are named as skipped instead of silently dropped.

Members the set already lists keep the set's quantity: a nest released
from a set carries its kit multiplier, so copying its quantities back
would scale the kit on every round trip. Members are added at the
library's latest revision (S1), with a note when the nest holds an
older one or the drawing is archived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:24:10 -04:00
ajandClaude Opus 5.5 cc731ff3d6 feat(desktop): release drawing sets into draft nests and track them
A set is only useful on the floor once it becomes nests. Release on the
Sets tab previews one row per material group with a kit multiplier, an
archived-drawing toggle and a tick per group, then builds each ticked
group's nest as File > New would, in the group's units, uploads it as a
Draft, opens it in its own tab bound to the server record and records the
release. Every group's outcome stays on screen, and a release whose
nests exist but whose record failed can be recorded again.

Releases shows each release's nests and how far they have got ("2 of 3
cut"), and Nest Info names the release a nest came from, so a kit run can
be followed across its nests. Plate defaults convert between inches and
millimeters so a group drawn in other units still gets sensible plates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:12:11 -04:00
ajandClaude Opus 5.5 1ccba9b910 feat(desktop): browse and edit drawing sets in the drawing library
Operators need to build kits from library drawings before they can be
released. The Drawing Library dialog gains a Sets tab beside Drawings,
so sets live where their drawings are browsed: list, search, archive,
restore, history, and an editor whose Add Drawings reuses the library in
a multi-select pick mode. The Drawings tab's menu shows which sets use a
drawing and adds it to a chosen or new set.

DrawingSetEditSession keeps a new set's id across a save whose reply was
lost, so a retry adopts the stored set instead of creating it twice, and
guards existing-set saves with the revision they were read at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:53:22 -04:00
ajandClaude Opus 5.5 af6975b9f1 feat(data): add the drawing set client and release session
The desktop needs a cross-platform way to read and save sets and to turn
one into nests before any UI is built on it. RemoteDrawingSetStore covers
every /api/sets route with the library client's checks and conflict
handling. SetReleasePlan groups a set by material, grade, thickness, units
and cut type without I/O, so the release dialog can preview refusals
(duplicate names, drawings without material) before anything is created.

SetReleaseSession builds every group's nest through LibraryDrawingInsert
before uploading any, so a refusal leaves nothing on the server; uploads
then never cancel mid-flight, a lost reply is reconciled by the nest's
client-allocated id, and the release is posted only when every chosen
nest exists, with a same-id retry when that post fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:48:23 -04:00
ajandClaude Opus 5.5 44666b289d feat(server): record set releases and the nests they made
Releasing a drawing set makes one nest per material group, so a kit run
spans several nests. The server now records which set revision was
released, the multiplier and the nests it made, so a run can be tracked
across its nests and a nest can say where it came from.

The client uploads every nest first and posts the release last: a failure
part-way leaves ordinary nests and no release, never a release pointing at
missing nests. For the same reason a release is accepted for an older set
revision or an archived set, since the nests already exist by then. The
release id is client-allocated and a nest belongs to at most one release,
so a retried post gets 409 with the release that already holds it.

Releases are append-only and read joined to each nest's current metadata,
so status and deletion are always current. The tables join schema v7, as
the set tables have not shipped yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:27:27 -04:00
ajandClaude Opus 5.5 be057bf337 feat(server): store versioned drawing sets
A drawing set is a named list of library drawings with quantities (a kit,
an assembly, a recurring order) that will later be released into one nest
per material. This is the server half: sets get the same revision,
If-Match and archive contract as /api/drawings so the client and tests
follow a known shape.

Schema v7 adds drawing_sets, drawing_set_revisions and
drawing_set_members. Members reference drawings by id and mean each
drawing's current revision, so reads join the current drawing record:
the client can group members by material without one request per drawing,
and the joined record is ignored on save so a read record can be sent
back unchanged. Every change keeps its own member list, and
/api/drawings/{id}/sets answers "used in" from current revisions only.

The drawing query's enum, integer and history-paging parsing is now shared
so both APIs refuse bad input identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:51:56 -04:00
ajandClaude Opus 5.5 28ee04a480 feat(nests): add a Draft nest status
Nests released from drawing sets need a state before Quote: created but not
yet nested or checked. Draft is appended to NestStatus (value 3) because the
server still accepts integer statuses from existing callers, so existing
numbers must not shift; NestStatuses.WorkflowOrder puts it first wherever the
operator picks a status.

Older desktops read Draft as Quote (the documented unknown-status fallback),
and an older server refuses "draft", so the server must be upgraded before
desktops that can set it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:31:58 -04:00
aj 1b323e2380 docs(posts): describe CI Fiber M50 as Table In, the pallet swap
The pallet-change setting and post doc said M50 moves the cut sheet out,
borrowed from the CL-series manual. Cincinnati's CNC Codes Handbook calls
M50 "Table In" and M51 "Table Out". On the CI Fiber shuttle, Table In brings
the waiting pallet into the laser, which sends the cut one out, so M50 is
still the swap. Say that, note that it expects the waiting pallet to be
loaded, and note that newer Cincinnati samples end without a pallet code.
Default (M50) and posted output are unchanged.
2026-10-20 10:04:59 -04:00
aj 2d74d09023 chore(posts): stop naming the CI Fiber customer sample part
The Cincinnati sample used by the CI Fiber tests is a customer production
part. Its geometry and NC were never tracked, but comments named its part
number and fixture file names and quoted its date and a pierce coordinate.
Replace them with generic wording and placeholder values; the fixture stays
outside the repository, configured only in the gitignored test-config.json.
Comment-only change.
2026-10-20 08:01:18 -04:00
aj cc2b3d1b81 test(posts): post the CI Fiber sample's own lead-ins and match it line for line
The sample regression fixture now carries each part's pierce, lead-in and
cut moves from the Cincinnati machine program as its saved cutting program.
A new test posts those saved programs without reassigning lead-ins and
requires the program body to equal the sample exactly: L2/L4 macro, G41/G42
side, lead-ins and every cut move (21,911 lines).

Mutation checks: dropping the largest-contour perimeter rule from the kerf
fix fails it at the first perimeter (L4 posted as L2), and forcing G41 fails
it at the first perimeter's G42. The existing tests still replace the saved
programs with OpenNest's own lead-ins first; their comment now says so
instead of describing the sample's leads wrongly.
2026-10-20 07:36:01 -04:00
ajandClaude Opus 5.5 63233db108 fix(posts): put the CI Fiber kerf on the scrap side for either winding
The CI Fiber post selected G42 for every perimeter. Cincinnati's CNC Codes
Handbook defines G42 as "product remains on the left", and imported
perimeters run clockwise with the part on the right, so the controller
offset every outside edge into the part: parts came out a kerf undersize.
Cincinnati's LaserNST samples only use G42 because they cut perimeters
counter-clockwise.

Winding is normalized only at DXF import, so the post can't assume a
direction either. It now takes the largest contour as the perimeter (as
ShapeProfile does) and picks G41 or G42 from each contour's actual winding.
That also stops a reversed drawing from swapping the L2/L4 lead-in macros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:08:11 -04:00
ajandClaude Opus 5.5 abfc1a714a feat(posts): trigger the Cincinnati safety head raise by rapid length
The safety raise (M47 P<SafetyHeadraiseDistance>) used to fire only when
the next part had a different drawing name, which has nothing to do with
crash risk: a long rapid between copies of the same part got a plain M47,
while a short hop between neighbouring different drawings got the full
raise. Sheets are rarely flat (5/8" plate can be ~1.5" out of flat within
supplier tolerance), and longer rapids cross more of that bow.

Add SafetyHeadraiseRapidDistance (program units, default 12"): any rapid to
the next pierce longer than it takes the safety raise, regardless of
drawing. Unknown travel never does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:08:10 -04:00
aj efa4844049 test(benchmark): prove parallelism reach with a rendezvous, not overlapping sleeps
Run_NeverExceedsMaxParallelism_ButReachesIt asserted two 200 ms solves
overlapped, which depends on the thread pool adding a second worker in
time; hosted Windows release run 39400265218 saw only one. Each fake solve
now waits (up to 10 s) until two solves have entered, so a parallel runner
always reaches the limit and a serial one fails once after the timeout.
Forcing MaxDegreeOfParallelism = 1 fails it (Expected 2, Actual 1).

Project Memory b534b51c.
2026-10-20 06:02:16 -04:00
622 changed files with 60169 additions and 6591 deletions

No files matched your search

+10 -4
View File
@@ -7,7 +7,7 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. User guides and their screenshots live in the separate `ajisaacs/opennest-help` repo: update or recapture them when a UI change alters a documented screen, and never rename a `HelpTopics` ID.
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
@@ -38,17 +38,18 @@ 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`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
- `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.
## Geometry and ownership safeguards
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check, the runner's candidate validation and the pre-post part-spacing review all find spacing candidates with Core's `SpacingOutline` and confirm them on `NativeContours` (the outline circumscribes arcs by more than the slack), so they agree; `NestLayoutCheck.Clears` skips the confirmation and stays stricter for engine certification. Do not loosen tolerances to hide failures.
- `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.
- 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.
- 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.
@@ -57,13 +58,18 @@ Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [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) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [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
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md)
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf and cut-off settings for plates without their own parameters
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
- [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`.
- [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.
+9
View File
@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OpenNest.Avalonia.App"
RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
</Application.Styles>
</Application>
+30
View File
@@ -0,0 +1,30 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace OpenNest.Avalonia
{
public partial class App : Application
{
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var window = new MainWindow();
desktop.MainWindow = window;
var args = desktop.Args ?? System.Array.Empty<string>();
if (args.Length > 0)
window.OpenNest(args[0]);
// --snapshot <png> [plate]: render the window to an image and exit, for checking the drawing.
var snapshot = System.Array.IndexOf(args, "--snapshot");
if (snapshot >= 0 && snapshot + 1 < args.Length)
window.SaveSnapshotAndClose(args[snapshot + 1],
snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
}
base.OnFrameworkInitializationCompleted();
}
}
}
+388
View File
@@ -0,0 +1,388 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using OpenNest.Avalonia.Rendering;
using OpenNest.CNC;
using OpenNest.Geometry;
using SkiaSharp;
using Control = Avalonia.Controls.Control;
using Vector = OpenNest.Geometry.Vector;
namespace OpenNest.Avalonia.Controls
{
/// <summary>
/// Draws one plate with Skia: pans with a left or middle drag, zooms at the cursor with the
/// wheel, fits on a double click, and highlights the part under the cursor.
/// </summary>
public sealed class PlateCanvas : Control
{
private static readonly SKColor BackgroundColor = new(169, 169, 169);
private static readonly SKColor PlateFillColor = new(245, 245, 245);
private static readonly SKColor PlateOutlineColor = new(128, 128, 128);
private static readonly SKColor EdgeSpacingColor = new(180, 180, 180);
private static readonly SKColor LeadColor = new(255, 0, 0);
private static readonly SKColor EtchColor = new(0, 150, 60);
private static readonly SKColor SelectedColor = new(150, 200, 255, 90 * 255 / 100);
// Paths are built once per program and only read afterwards, so the render thread may draw
// them while the UI thread builds others. Keyed by reference: a rotation replaces the program.
private readonly Dictionary<CNC.Program, ProgramPaths> paths = new();
private Plate plate;
private double scale = 1;
private Point origin;
private Point? dragStart;
private bool fitPending = true;
public Plate Plate
{
get => plate;
set
{
plate = value;
HoveredPart = null;
SelectedPart = null;
fitPending = true;
InvalidateVisual();
}
}
public Part HoveredPart { get; private set; }
public Part SelectedPart { get; private set; }
/// <summary>The cursor in plate coordinates, or null when it is outside the view.</summary>
public Vector? CursorWorld { get; private set; }
public event EventHandler ViewChanged;
public PlateCanvas()
{
ClipToBounds = true;
Focusable = true;
}
public void ClearCache() => paths.Clear();
public void ZoomToFit()
{
if (!Fit())
return;
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>Fits the view to the plate and its parts without requesting a redraw.</summary>
private bool Fit()
{
if (plate == null || Bounds.Width <= 0 || Bounds.Height <= 0)
return false;
var extents = PlateRect(plate);
foreach (var part in plate.Parts)
extents = Union(extents, part.BoundingBox);
const double margin = 20;
var sx = (Bounds.Width - 2 * margin) / System.Math.Max(extents.Length, 1e-6);
var sy = (Bounds.Height - 2 * margin) / System.Math.Max(extents.Width, 1e-6);
scale = System.Math.Min(sx, sy);
origin = new Point(
Bounds.Width / 2 - (extents.Left + extents.Length / 2) * scale,
Bounds.Height / 2 + (extents.Bottom + extents.Width / 2) * scale);
fitPending = false;
return true;
}
public double Zoom => scale;
private Vector ToWorld(Point p) => new((p.X - origin.X) / scale, (origin.Y - p.Y) / scale);
private SKMatrix WorldMatrix =>
new((float)scale, 0, (float)origin.X, 0, (float)-scale, (float)origin.Y, 0, 0, 1);
private static Box PlateRect(Plate plate)
{
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
return new Box(left, bottom, plate.Size.Length, plate.Size.Width);
}
private static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
return new Box(left, bottom,
System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
}
private ProgramPaths PathsFor(Part part)
{
if (!paths.TryGetValue(part.Program, out var result))
paths[part.Program] = result = ProgramPaths.Build(part.Program);
return result;
}
public override void Render(DrawingContext context)
{
// A redraw may not be requested during a render, so tell listeners afterwards.
if (fitPending && Fit())
global::Avalonia.Threading.Dispatcher.UIThread.Post(() => ViewChanged?.Invoke(this, EventArgs.Empty));
var items = new List<PartItem>();
if (plate != null)
{
for (var i = 0; i < plate.Parts.Count; i++)
{
var part = plate.Parts[i];
items.Add(new PartItem(PathsFor(part), part.Location, part.BoundingBox,
ToSkColor(part.BaseDrawing.Color), (i + 1).ToString(),
ReferenceEquals(part, SelectedPart), ReferenceEquals(part, HoveredPart),
part.BaseDrawing.IsCutOff, part.HasManualLeadIns));
}
}
context.Custom(new PlateDrawOperation(new Rect(Bounds.Size), plate == null ? null : PlateRect(plate),
plate?.EdgeSpacing, WorldMatrix, items));
}
private static SKColor ToSkColor(System.Drawing.Color color) =>
color.IsEmpty ? new SKColor(130, 204, 130) : new SKColor(color.R, color.G, color.B, color.A);
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
var at = e.GetPosition(this);
var factor = System.Math.Pow(1.2, e.Delta.Y);
// Keep the world point under the cursor fixed.
origin = new Point(at.X - (at.X - origin.X) * factor, at.Y - (at.Y - origin.Y) * factor);
scale *= factor;
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
e.Handled = true;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
Focus();
var point = e.GetCurrentPoint(this);
if (e.ClickCount == 2)
{
ZoomToFit();
return;
}
if (point.Properties.IsLeftButtonPressed)
{
SelectedPart = HitTest(ToWorld(point.Position));
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
}
if (point.Properties.IsLeftButtonPressed || point.Properties.IsMiddleButtonPressed)
{
dragStart = point.Position;
e.Pointer.Capture(this);
}
}
protected override void OnPointerMoved(PointerEventArgs e)
{
var at = e.GetPosition(this);
if (dragStart is { } start)
{
origin += at - start;
dragStart = at;
InvalidateVisual();
}
CursorWorld = ToWorld(at);
var hovered = dragStart == null ? HitTest(CursorWorld.Value) : HoveredPart;
if (!ReferenceEquals(hovered, HoveredPart))
{
HoveredPart = hovered;
InvalidateVisual();
}
ViewChanged?.Invoke(this, EventArgs.Empty);
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
dragStart = null;
e.Pointer.Capture(null);
}
protected override void OnPointerExited(PointerEventArgs e)
{
CursorWorld = null;
if (HoveredPart != null)
{
HoveredPart = null;
InvalidateVisual();
}
ViewChanged?.Invoke(this, EventArgs.Empty);
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key == Key.F)
{
ZoomToFit();
e.Handled = true;
}
}
protected override void OnSizeChanged(global::Avalonia.Controls.SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
if (fitPending)
InvalidateVisual();
}
/// <summary>The topmost part whose material contains <paramref name="world"/>.</summary>
private Part HitTest(Vector world)
{
if (plate == null)
return null;
for (var i = plate.Parts.Count - 1; i >= 0; i--)
{
var part = plate.Parts[i];
var box = part.BoundingBox;
if (world.X < box.Left || world.X > box.Right || world.Y < box.Bottom || world.Y > box.Top)
continue;
var local = world - part.Location;
if (PathsFor(part).Fill.Contains((float)local.X, (float)local.Y))
return part;
}
return null;
}
private sealed record PartItem(ProgramPaths Paths, Vector Location, Box Bounds, SKColor Color,
string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
private sealed class PlateDrawOperation : ICustomDrawOperation
{
private readonly Box plateRect;
private readonly Spacing? edgeSpacing;
private readonly SKMatrix matrix;
private readonly List<PartItem> items;
public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
List<PartItem> items)
{
Bounds = bounds;
this.plateRect = plateRect;
this.edgeSpacing = edgeSpacing;
this.matrix = matrix;
this.items = items;
}
public Rect Bounds { get; }
public bool HitTest(Point p) => Bounds.Contains(p);
public bool Equals(ICustomDrawOperation other) => false;
public void Dispose()
{
}
public void Render(ImmediateDrawingContext context)
{
var lease = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
if (lease == null)
return;
using var api = lease.Lease();
var canvas = api.SkCanvas;
canvas.Save();
canvas.ClipRect(new SKRect(0, 0, (float)Bounds.Width, (float)Bounds.Height));
canvas.Clear(BackgroundColor);
if (plateRect != null)
Draw(canvas);
canvas.Restore();
}
private void Draw(SKCanvas canvas)
{
// Hairline strokes (width 0) stay one pixel wide at any zoom.
using var fill = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
using var stroke = new SKPaint { Style = SKPaintStyle.Stroke, StrokeWidth = 0, IsAntialias = true };
canvas.Save();
var world = matrix;
canvas.Concat(ref world);
var plate = new SKRect((float)plateRect.Left, (float)plateRect.Bottom,
(float)plateRect.Right, (float)plateRect.Top);
fill.Color = PlateFillColor;
canvas.DrawRect(plate, fill);
if (edgeSpacing is { } spacing)
{
stroke.Color = EdgeSpacingColor;
canvas.DrawRect(new SKRect(plate.Left + (float)spacing.Left,
plate.Top + (float)spacing.Bottom, plate.Right - (float)spacing.Right,
plate.Bottom - (float)spacing.Top), stroke);
}
stroke.Color = PlateOutlineColor;
canvas.DrawRect(plate, stroke);
var view = canvas.LocalClipBounds;
foreach (var item in items)
{
var b = item.Bounds;
if (b.Right < view.Left || b.Left > view.Right || b.Top < view.Top || b.Bottom > view.Bottom)
continue;
canvas.Save();
canvas.Translate((float)item.Location.X, (float)item.Location.Y);
fill.Color = item.Selected ? SelectedColor
: item.Hovered ? Lighten(item.Color) : item.Color;
canvas.DrawPath(item.Paths.Fill, fill);
stroke.Color = EtchColor;
canvas.DrawPath(item.Paths.Etch, stroke);
stroke.Color = item.Selected ? new SKColor(30, 90, 200) : Darken(item.Color);
canvas.DrawPath(item.Paths.Cut, stroke);
// As on the desktop, only manually placed lead-ins are shown.
if (item.ShowLeads)
{
stroke.Color = LeadColor;
canvas.DrawPath(item.Paths.Lead, stroke);
}
canvas.Restore();
}
canvas.Restore();
DrawLabels(canvas);
}
// Ids are drawn in screen space and only where the part is big enough on screen to hold one.
private void DrawLabels(SKCanvas canvas)
{
using var text = new SKPaint { Color = SKColors.Black, IsAntialias = true, TextSize = 12 };
foreach (var item in items)
{
if (item.IsCutOff)
continue;
var center = matrix.MapPoint((float)item.Bounds.Center.X, (float)item.Bounds.Center.Y);
var width = item.Bounds.Length * matrix.ScaleX;
var height = item.Bounds.Width * -matrix.ScaleY;
var measured = text.MeasureText(item.Label);
if (measured + 4 > width || text.TextSize + 4 > height)
continue;
canvas.DrawText(item.Label, center.X - measured / 2, center.Y + text.TextSize / 3, text);
}
}
private static SKColor Darken(SKColor c) =>
new((byte)(c.Red * 0.55), (byte)(c.Green * 0.55), (byte)(c.Blue * 0.55), 255);
private static SKColor Lighten(SKColor c) =>
new((byte)(c.Red + (255 - c.Red) / 2), (byte)(c.Green + (255 - c.Green) / 2),
(byte)(c.Blue + (255 - c.Blue) / 2), c.Alpha);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:c="using:OpenNest.Avalonia.Controls"
xmlns:local="using:OpenNest.Avalonia"
x:Class="OpenNest.Avalonia.MainWindow"
Title="OpenNest (Avalonia prototype)"
Width="1400" Height="860">
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
<Separator />
<MenuItem Header="E_xit" Click="OnExit" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
</MenuItem>
</Menu>
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="Open" Click="OnOpen" />
<Button Content="Fit" Click="OnFit" />
<Button Content="Previous" Click="OnPreviousPlate" />
<Button Content="Next" Click="OnNextPlate" />
</StackPanel>
</Border>
<Border DockPanel.Dock="Bottom" Padding="6,3" Background="#F0F0F0">
<TextBlock x:Name="Status" Text="Open a .nest file to begin." />
</Border>
<Grid ColumnDefinitions="260,4,*,4,320">
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Plates" />
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
</DockPanel>
<GridSplitter Grid.Column="1" ResizeDirection="Columns" />
<c:PlateCanvas Grid.Column="2" x:Name="Canvas" />
<GridSplitter Grid.Column="3" ResizeDirection="Columns" />
<DockPanel Grid.Column="4">
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Drawings on plate" />
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
AutoGenerateColumns="False" GridLinesVisibility="Horizontal" CanUserSortColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="Qty" Binding="{Binding Count}" />
<DataGridTextColumn Header="Size" Binding="{Binding Size}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Grid>
</DockPanel>
</Window>
+139
View File
@@ -0,0 +1,139 @@
using System;
using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using OpenNest.IO;
namespace OpenNest.Avalonia
{
public sealed record DrawingRow(string Name, int Count, string Size);
public partial class MainWindow : Window
{
private Nest nest;
private string fileName;
public MainWindow()
{
InitializeComponent();
Canvas.ViewChanged += (_, _) => UpdateStatus();
KeyBindings.Add(new KeyBinding
{
Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
Command = new Command(() => OnOpen(null, null)),
});
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
}
public void OpenNest(string path)
{
try
{
nest = new NestReader(path).Read();
}
catch (Exception ex)
{
Status.Text = $"Could not open {Path.GetFileName(path)}: {ex.Message}";
return;
}
fileName = Path.GetFileName(path);
Title = $"{fileName} - OpenNest (Avalonia prototype)";
Canvas.ClearCache();
PlateList.ItemsSource = nest.Plates
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
.ToList();
PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1;
}
public void SaveSnapshotAndClose(string path, int plateNumber)
{
Opened += async (_, _) =>
{
if (nest?.Plates.Count > 0)
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
await System.Threading.Tasks.Task.Delay(500);
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
bitmap.Render(this);
bitmap.Save(path);
Close();
};
}
private async void OnOpen(object sender, RoutedEventArgs e)
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open Nest",
AllowMultiple = false,
FileTypeFilter = new[] { new FilePickerFileType("Nest files") { Patterns = new[] { "*.nest" } } },
});
var path = files.FirstOrDefault()?.TryGetLocalPath();
if (path != null)
OpenNest(path);
}
private void OnExit(object sender, RoutedEventArgs e) => Close();
private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
private void StepPlate(int step)
{
if (nest == null || nest.Plates.Count == 0)
return;
PlateList.SelectedIndex = System.Math.Clamp(PlateList.SelectedIndex + step, 0, nest.Plates.Count - 1);
}
private void OnPlateSelected(object sender, SelectionChangedEventArgs e)
{
var index = PlateList.SelectedIndex;
var plate = nest != null && index >= 0 ? nest.Plates[index] : null;
Canvas.Plate = plate;
DrawingGrid.ItemsSource = plate?.Parts
.Where(part => !part.BaseDrawing.IsCutOff)
.GroupBy(part => part.BaseDrawing)
.Select(group => new DrawingRow(group.Key.Name, group.Count(), SizeOf(group.First())))
.OrderByDescending(row => row.Count)
.ToList();
UpdateStatus();
}
private static string SizeOf(Part part) =>
$"{part.BoundingBox.Length:0.###} x {part.BoundingBox.Width:0.###}";
private void UpdateStatus()
{
if (Canvas.Plate == null)
return;
var text = $"{fileName} Plate {PlateList.SelectedIndex + 1} of {nest.Plates.Count} Zoom {Canvas.Zoom:0.##} px/unit";
if (Canvas.CursorWorld is { } at)
text += $" X {at.X:0.000} Y {at.Y:0.000}";
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
if (part != null)
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
Status.Text = text;
}
private sealed class Command : System.Windows.Input.ICommand
{
private readonly Action action;
public Command(Action action) => this.action = action;
public event EventHandler CanExecuteChanged { add { } remove { } }
public bool CanExecute(object parameter) => true;
public void Execute(object parameter) => action();
}
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Avalonia</RootNamespace>
<AssemblyName>OpenNest.Avalonia</AssemblyName>
<Nullable>disable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.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" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
</ItemGroup>
</Project>
+18
View File
@@ -0,0 +1,18 @@
using System;
using Avalonia;
namespace OpenNest.Avalonia
{
internal static class Program
{
[STAThread]
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}
+175
View File
@@ -0,0 +1,175 @@
using System;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using SkiaSharp;
namespace OpenNest.Avalonia.Rendering
{
/// <summary>
/// A program's display geometry as Skia paths in the program frame, before placement.
/// A Skia port of the desktop's GraphicsHelper.GetDisplayPaths, without tab bridging.
/// </summary>
internal sealed class ProgramPaths : IDisposable
{
public SKPath Cut { get; } = new() { FillType = SKPathFillType.EvenOdd };
public SKPath Lead { get; } = new();
public SKPath Etch { get; } = new();
/// <summary>The material outline to fill: cuts and tabs, without open break cuts.</summary>
public SKPath Fill { get; } = new() { FillType = SKPathFillType.EvenOdd };
public SKRect Bounds { get; private set; }
public static ProgramPaths Build(CNC.Program program)
{
var paths = new ProgramPaths();
var position = Vector.Zero;
paths.AddProgram(program, ref position);
var bounds = paths.Cut.Bounds;
if (!paths.Etch.IsEmpty)
bounds = paths.Cut.IsEmpty ? paths.Etch.Bounds : SKRect.Union(bounds, paths.Etch.Bounds);
paths.Bounds = bounds;
return paths;
}
private SKPath TargetFor(LayerType layer) => layer switch
{
LayerType.Leadin or LayerType.Leadout => Lead,
LayerType.Scribe => Etch,
LayerType.Display or LayerType.Tab => null,
_ => Cut,
};
private void AddProgram(CNC.Program program, ref Vector position)
{
// Sub-program offsets are relative to the frame origin at entry, not the tool position.
var frameOrigin = position;
var mode = program.Mode;
SKPath open = null;
for (var i = 0; i < program.Length; ++i)
{
var code = program[i];
switch (code)
{
case LinearMove line:
{
var end = mode == Mode.Incremental ? line.EndPoint + position : line.EndPoint;
var target = line.Suppressed ? null : TargetFor(line.Layer);
if (target != null)
{
Continue(ref open, target, position);
target.LineTo((float)end.X, (float)end.Y);
}
if (!line.Suppressed && line.Layer != LayerType.Break)
AddFillLine(line.Layer, position, end);
position = end;
break;
}
case ArcMove arc:
{
var end = arc.EndPoint;
var center = arc.CenterPoint;
if (mode == Mode.Incremental)
{
end += position;
center += position;
}
var target = arc.Suppressed ? null : TargetFor(arc.Layer);
if (target != null)
{
Continue(ref open, target, position);
AddArc(target, position, end, center, arc.Rotation);
}
if (!arc.Suppressed && arc.Layer != LayerType.Break)
AddFillArc(arc.Layer, position, end, center, arc.Rotation);
position = end;
break;
}
case RapidMove rapid:
{
var end = mode == Mode.Incremental ? rapid.EndPoint + position : rapid.EndPoint;
if (end.DistanceTo(position) > 0.001)
open = null;
position = end;
break;
}
case SubProgramCall call when call.Program != null:
open = null;
position = new Vector(frameOrigin.X + call.Offset.X, frameOrigin.Y + call.Offset.Y);
AddProgram(call.Program, ref position);
open = null;
break;
}
}
}
// Starts a new contour unless the move continues the one already open on the same path.
private SKPath openFill;
private static void Continue(ref SKPath open, SKPath target, Vector from)
{
if (!ReferenceEquals(open, target) || target.LastPoint != new SKPoint((float)from.X, (float)from.Y))
target.MoveTo((float)from.X, (float)from.Y);
open = target;
}
private bool IsMaterial(LayerType layer) =>
layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Display);
private void AddFillLine(LayerType layer, Vector from, Vector to)
{
if (!IsMaterial(layer))
return;
Continue(ref openFill, Fill, from);
Fill.LineTo((float)to.X, (float)to.Y);
}
private void AddFillArc(LayerType layer, Vector from, Vector to, Vector center, RotationType rotation)
{
if (!IsMaterial(layer))
return;
Continue(ref openFill, Fill, from);
AddArc(Fill, from, to, center, rotation);
}
private static void AddArc(SKPath path, Vector from, Vector to, Vector center, RotationType rotation)
{
var startAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(from.Y - center.Y, from.X - center.X)));
var endAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(to.Y - center.Y, to.X - center.X)));
var radius = (float)center.DistanceTo(to);
var oval = new SKRect((float)center.X - radius, (float)center.Y - radius,
(float)center.X + radius, (float)center.Y + radius);
if (startAngle.IsEqualTo(endAngle))
{
// A full circle: two half sweeps keep the contour starting at the current point.
var sweep = rotation == RotationType.CCW ? 180f : -180f;
path.ArcTo(oval, (float)startAngle, sweep, false);
path.ArcTo(oval, (float)startAngle + sweep, sweep, false);
return;
}
if (rotation == RotationType.CCW && endAngle < startAngle)
endAngle += 360.0;
else if (rotation == RotationType.CW && startAngle < endAngle)
startAngle += 360.0;
path.ArcTo(oval, (float)startAngle, (float)(endAngle - startAngle), false);
}
public void Dispose()
{
Cut.Dispose();
Lead.Dispose();
Etch.Dispose();
Fill.Dispose();
}
}
}
+33 -9
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -37,6 +38,12 @@ namespace OpenNest.Benchmark
public double PartSpacing { get; init; }
public int Quadrant { get; init; }
/// <summary>Source plate's machine clamp rail, resolved for each candidate size as Auto Nest does; null when none.</summary>
public ClampRail ClampRail { get; init; }
/// <summary>Source plate's explicit clamp zones when it has no rail, offered unchanged on every size.</summary>
public IReadOnlyList<ClampZone> ClampZones { get; init; } = Array.Empty<ClampZone>();
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
public double SalvageRate { get; init; }
@@ -60,7 +67,8 @@ namespace OpenNest.Benchmark
/// (unlimited quantity - the engine under test decides how many of each
/// size it actually uses, and how demand splits across plates). The
/// engine owns its own multi-plate/size strategy; this harness no
/// longer picks plate sizes on the engine's behalf.
/// longer picks plate sizes on the engine's behalf. Stock comes from
/// the same template builder as desktop Auto Nest, so clamps match it.
/// </summary>
public NestJob BuildNestJob(
int maxPlates,
@@ -71,14 +79,13 @@ namespace OpenNest.Benchmark
var parts = Requests.Select(r =>
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
);
var stock = CandidateSizes.Select(size => new NestPlateStock(
size.ToString(1),
size,
null,
PartSpacing,
EdgeSpacing,
Quadrant
));
// No options would make the builder offer the template itself; an empty pool stays empty.
var stock = CandidateSizes.Count == 0
? Array.Empty<NestPlateStock>()
: NestStockBuilder.FromTemplate(
StockTemplate(),
CandidateSizes.Select(size => new PlateOption { Width = size.Width, Length = size.Length }).ToList()
);
return new NestJob(
parts,
stock,
@@ -90,5 +97,22 @@ namespace OpenNest.Benchmark
)
);
}
/// <summary>A plate carrying this job's spacing, quadrant and clamps, sized to the largest candidate.</summary>
private Plate StockTemplate()
{
var template = new Plate(CandidateSizes.MaxBy(size => size.Width * size.Length))
{
PartSpacing = PartSpacing,
EdgeSpacing = EdgeSpacing,
Quadrant = Quadrant,
};
if (ClampRail != null)
template.SetClampRail(ClampRail);
else
foreach (var zone in ClampZones)
template.ClampZones.Add(zone);
return template;
}
}
}
+1 -8
View File
@@ -126,14 +126,7 @@ namespace OpenNest.Benchmark
.BaselinePlateRuns.Select(
(run, index) =>
{
var stock = new NestPlateStock(
$"baseline-{index}",
run.Plate.Size,
1,
run.Plate.PartSpacing,
run.Plate.EdgeSpacing,
run.Plate.Quadrant
);
var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
var placements = run
.Parts.Select(part =>
{
+29 -5
View File
@@ -74,6 +74,8 @@ namespace OpenNest.Benchmark
EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant,
ClampRail = template.ClampRail,
ClampZones = template.ClampZones,
SalvageRate = nest.SalvageRate,
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
Requests = requests,
@@ -182,23 +184,45 @@ namespace OpenNest.Benchmark
plate.EdgeSpacing.Top
),
};
// Clamps are part of the sheet the layout was made on.
if (plate.ClampRail != null)
validationPlate.SetClampRail(plate.ClampRail, plate.RemovedClamps);
else
foreach (var zone in plate.ClampZones)
validationPlate.ClampZones.Add(zone);
for (var copy = 0; copy < plate.Quantity; copy++)
runs.Add((validationPlate, parts));
}
return runs.Count > 0 ? runs : null;
}
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
Nest nest
)
private static (
Spacing EdgeSpacing,
double PartSpacing,
int Quadrant,
ClampRail ClampRail,
IReadOnlyList<ClampZone> ClampZones
) ResolvePlateTemplate(Nest nest)
{
var source = nest.Plates?.FirstOrDefault();
if (source != null)
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
return (
source.EdgeSpacing,
source.PartSpacing,
source.Quadrant,
source.ClampRail,
source.ClampRail == null ? source.ClampZones.ToList() : Array.Empty<ClampZone>()
);
var defaults = nest.PlateDefaults;
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
return (
defaults.EdgeSpacing,
defaults.PartSpacing,
defaults.Quadrant,
defaults.ClampRail,
Array.Empty<ClampZone>()
);
}
private static List<Size> ResolveSheetSizes(Nest nest)
+2 -1
View File
@@ -115,7 +115,7 @@ static class NestConsole
Console.Error.WriteLine(
engine.IsJobEngine
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)"
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as Irregular require --autonest)"
);
return 1;
}
@@ -824,6 +824,7 @@ static class NestConsole
{
CuttingPlanPhase.Reordering => "finding a part order...",
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
CuttingPlanPhase.SafestRoute => "finding the safest route...",
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
_ => value.Phase + "...",
});
+201 -28
View File
@@ -27,6 +27,14 @@ public sealed class AutomaticCutOffOptions
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
/// </summary>
public double? MinimumStripLength { get; set; }
/// <summary>
/// Whether to propose a full-length horizontal separator that keeps the unused band beyond the
/// parts along Y (away from the origin) when that band is a larger piece than the X tail.
/// The band must be at least MinimumTailLength wide. False preserves vertical-only proposals.
/// An existing full-length horizontal cut-off clear of the parts is honored either way.
/// </summary>
public bool KeepHorizontalRemnant { get; set; }
}
public enum AutomaticCutOffDiagnosticCode
@@ -39,6 +47,8 @@ public enum AutomaticCutOffDiagnosticCode
NoSafeTailSeparator,
TailBelowMinimum,
ShiftedCut,
HorizontalRemnant,
NoSafeHorizontalSeparator,
}
public sealed record AutomaticCutOffDiagnostic(
@@ -77,6 +87,15 @@ public sealed class AutomaticCutOffPlan
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue;
/// <summary>
/// Signed Y coordinate of the new or existing full-length horizontal separator the vertical
/// lines stop at, keeping the band beyond it; null when the lines cross the full sheet width.
/// </summary>
public double? HorizontalSeparatorY { get; internal set; }
/// <summary>Width along Y of the band kept beyond HorizontalSeparatorY, otherwise zero.</summary>
public double HorizontalRemnantWidth { get; internal set; }
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
public double StripCutLength { get; internal set; }
@@ -90,6 +109,15 @@ public sealed class AutomaticCutOffPlan
public static class AutomaticCutOffPlanner
{
public const double GeometryTolerance = Tolerance.Epsilon;
/// <summary>
/// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
/// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
/// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
/// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
/// larger overhang that signals a real placement error, is still refused.
/// </summary>
public const double DisplayPerimeterSheetAllowance = 1e-4;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
public const int MaximumShiftCandidateCount = 20000;
@@ -115,7 +143,9 @@ public static class AutomaticCutOffPlanner
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
var ySign = plate.Quadrant is 3 or 4 ? -1 : 1;
var occupied = 0.0;
var occupiedY = 0.0;
var hasParts = false;
foreach (var part in plate.Parts)
{
@@ -123,7 +153,9 @@ public static class AutomaticCutOffPlanner
if (part.BaseDrawing.IsCutOff)
continue;
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
occupied = System.Math.Max(occupied, partX);
occupiedY = System.Math.Max(occupiedY, partY);
hasParts = true;
}
if (!hasParts)
@@ -131,16 +163,62 @@ public static class AutomaticCutOffPlanner
// Use distances from the coordinate origin, not the sheet's lower-left corner.
var length = plate.Size.Length;
var width = plate.Size.Width;
occupied = System.Math.Min(occupied, length);
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
+ GeometryTolerance;
occupiedY = System.Math.Min(occupiedY, width);
var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
var separator = occupied + margin;
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
var diagnostics = new List<AutomaticCutOffDiagnostic>();
// A band beyond the parts along Y is kept by a full-length horizontal separator; the
// vertical lines then stop at it instead of slicing the band. A user's line clear of the
// parts is honored; a new one is proposed only when its band beats the X tail.
var horizontalDistance = plate.CutOffs
.Where(c => c?.Axis == CutOffAxis.Horizontal && Finite(c.Position) && FullLengthLimits(c, bounds, settings))
.Select(c => ySign * c.Position.Y)
.Where(distance => distance > occupiedY + GeometryTolerance && distance < width - GeometryTolerance
&& width - distance >= options.MinimumTailLength)
.Select(distance => (double?)distance)
.Min();
var horizontalExists = horizontalDistance.HasValue;
if (!horizontalExists && options.KeepHorizontalRemnant)
{
var proposed = occupiedY + margin;
var tailArea = hasTailCandidate && length - separator >= options.MinimumTailLength
? (length - separator) * width : 0;
if (double.IsFinite(proposed) && proposed < width - GeometryTolerance
&& width - proposed >= options.MinimumTailLength && (width - proposed) * length > tailArea)
horizontalDistance = proposed;
}
if (horizontalDistance.HasValue && !horizontalExists)
{
var y = ySign * horizontalDistance.Value;
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
var clamp = clamps.FirstOrDefault(zone =>
y >= zone.Bounds.Bottom - plate.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
if (limited || clamp != null)
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
(clamp != null ? $"A horizontal separator at Y = {y:G8} would cross clamp '{clamp.Id}'"
: $"A same-line manual cut at Y = {y:G8} has different limits")
+ "; vertical lines cross the full sheet width instead."));
horizontalDistance = null;
}
}
// A tail too short to keep stays attached to the skeleton, so the strip grid must
// continue to the sheet end; stopping at the skipped separator would leave a final
// strip longer than the spacing.
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength;
// strip longer than the spacing. Beside a kept horizontal band the tail is a corner
// piece, which must also be wide enough to keep.
bool KeepsTail(double? band) => hasTailCandidate && length - separator >= options.MinimumTailLength
&& (band == null || band.Value >= options.MinimumTailLength);
var keepsTail = KeepsTail(horizontalDistance);
var usedSpan = keepsTail ? separator : length;
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
// Bound the count for either outcome: the horizontal separator is verified only below.
var candidateCount = System.Math.Ceiling(System.Math.Max(usedSpan, KeepsTail(null) ? separator : length)
/ options.Spacing);
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
@@ -148,7 +226,31 @@ public static class AutomaticCutOffPlanner
ValidateExisting(plate, bounds, settings);
var cache = Plate.BuildPerimeterCache(plate);
var definitions = new List<CutOff>();
var diagnostics = new List<AutomaticCutOffDiagnostic>();
CutOff horizontalSeparator = null;
if (horizontalDistance.HasValue && !horizontalExists)
{
horizontalSeparator = new CutOff(new Vector(0, ySign * horizontalDistance.Value), CutOffAxis.Horizontal);
horizontalSeparator.Regenerate(plate, settings, cache);
if (!HasUsableSegments(horizontalSeparator.Drawing.Program, false) ||
!IsFullSpanProgram(horizontalSeparator.Drawing.Program, false, bounds.Left, bounds.Right))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
"No full-length horizontal separator survives the current settings; "
+ "vertical lines cross the full sheet width instead."));
horizontalSeparator = null;
horizontalDistance = null;
keepsTail = KeepsTail(null);
usedSpan = keepsTail ? separator : length;
candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
}
}
// Vertical lines run from the origin-side edge up to a kept band's separator.
var horizontalY = horizontalDistance.HasValue ? ySign * horizontalDistance.Value : (double?)null;
var startLimit = ySign < 0 ? horizontalY : null;
var endLimit = ySign > 0 ? horizontalY : null;
var spanLow = startLimit ?? bounds.Bottom;
var spanHigh = endLimit ?? bounds.Top;
var separated = false;
var separatorX = (double?)null;
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
@@ -162,7 +264,7 @@ public static class AutomaticCutOffPlanner
// Full-span definitions fix the strip boundaries, even when their emitted
// programs are segmented. Partial cuts remain obstacles, not boundaries.
var boundaries = plate.CutOffs
.Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings))
.Where(c => c.Axis == CutOffAxis.Vertical && Spans(c))
.Select(c => sign * c.Position.X)
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
.Distinct().OrderBy(distance => distance).Append(usedSpan);
@@ -212,6 +314,15 @@ public static class AutomaticCutOffPlanner
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
X: sign * separator));
if (horizontalSeparator != null)
definitions.Add(horizontalSeparator);
if (horizontalY.HasValue)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.HorizontalRemnant,
$"Vertical lines stop at the {(horizontalExists ? "existing" : "new")} horizontal separator at "
+ $"Y = {horizontalY.Value:G8}, keeping a {width - horizontalDistance.Value:G8}-wide band "
+ "along the sheet length."));
if (diagnostics.Any(d => d.IsBlocking))
{
// A partial proposal must not accidentally be accepted around a manual conflict.
@@ -219,12 +330,24 @@ public static class AutomaticCutOffPlanner
stripCutLength = 0;
separated = false;
separatorX = null;
horizontalY = null;
}
// The legacy placement loop stays untouched. Its detached programs are already
// regenerated, so reporting their actual new strip cutting needs no new search.
if (!shifted)
fixedGridCutLength = stripCutLength;
// Lines are planned and measured without the head-locked overcut: it only extends the
// segment reaching the far edge past the sheet, where there is nothing to separate.
if (settings.NewCutOffHeadLockOvercut > 0)
{
foreach (var definition in definitions)
{
definition.HeadLockOvercut = settings.NewCutOffHeadLockOvercut;
definition.Regenerate(plate, settings, cache);
}
}
return new AutomaticCutOffPlan
{
Definitions = definitions.AsReadOnly(),
@@ -236,8 +359,22 @@ public static class AutomaticCutOffPlanner
TailSeparatorX = separatorX,
StripCutLength = stripCutLength,
FixedGridCutLength = fixedGridCutLength,
HorizontalSeparatorY = horizontalY,
HorizontalRemnantWidth = horizontalY.HasValue ? width - horizontalDistance.Value : 0,
};
CutOff NewVertical(double x) => new(new Vector(x, 0), CutOffAxis.Vertical)
{
StartLimit = startLimit,
EndLimit = endLimit,
};
// A full-span line, or one with the limits this plan gives its own vertical lines.
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
horizontalY.HasValue &&
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
void AddShiftedGap(double start, double end, int count, double step)
{
var nominal = new List<double>(count);
@@ -290,8 +427,7 @@ public static class AutomaticCutOffPlanner
RegisterCandidate(distance);
if (!measurements.TryGetValue(distance, out var measured))
{
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
.MeasureSegments(plate, settings, cache);
measured = NewVertical(sign * distance).MeasureSegments(plate, settings, cache);
measurements.Add(distance, measured);
}
return measured;
@@ -302,7 +438,7 @@ public static class AutomaticCutOffPlanner
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
Near(c.Position.X, x)).ToList();
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
if (matches.Any(c => !Spans(c)))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
@@ -335,7 +471,7 @@ public static class AutomaticCutOffPlanner
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
var existing = matches.FirstOrDefault();
var candidate = existing == null
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
? NewVertical(x)
: new CutOff(existing.Position, existing.Axis)
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
candidate.Regenerate(plate, settings, cache);
@@ -353,7 +489,7 @@ public static class AutomaticCutOffPlanner
if (isSeparator)
{
if (!usable || !IsFullSpanProgram(program, bounds))
if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
{
WarnNoSeparator(x);
return;
@@ -361,7 +497,7 @@ public static class AutomaticCutOffPlanner
separated = true;
separatorX = candidate.Position.X;
}
else if (usable && !IsFullSpanProgram(program, bounds))
else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.SegmentedCut,
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
@@ -414,12 +550,14 @@ public static class AutomaticCutOffPlanner
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
"A head-locked overcut must be finite and positive.", nameof(settings));
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
Require(plate.Parts != null && plate.CutOffs != null,
"Plate parts and cut-off collections are required.", nameof(plate));
}
private static double ValidatePart(Part part, Box sheet, int sign)
private static (double X, double Y) ValidatePart(Part part, Box sheet, int sign, int ySign)
{
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
"Part pose must be finite.", "plate");
@@ -432,19 +570,32 @@ public static class AutomaticCutOffPlanner
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
"Part geometry has stale bounds; update it before planning.", "plate");
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
// Checking converted entities catches overflowing incremental moves and curve bounds.
var hasMaterial = false;
var occupied = sign > 0 ? box.Right : -box.Left;
var occupiedY = ySign > 0 ? box.Top : -box.Bottom;
var roundoff = CutOff.GetBoundsRoundoff(part);
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
var entities = ConvertProgram.ToGeometry(part.Program);
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
foreach (var entity in entities)
{
var entityBox = entity.BoundingBox;
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
if (!SpecialLayers.IsMaterial(entity.Layer))
{
// Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
// ending on the outline (its first corner, or an applied program's display tail)
// shares the outline's allowance.
Require(Inside(entityBox.Translate(part.Location), sheet)
|| hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
"Part geometry extends outside the physical sheet.", "plate");
continue;
}
entityBox = entityBox.Translate(part.Location);
// Permit only bounded floating-point roundoff, with the same conservative
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
@@ -452,12 +603,15 @@ public static class AutomaticCutOffPlanner
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
Require(Inside(entityBox, sheet,
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
occupiedY = System.Math.Max(occupiedY, ySign > 0 ? entityBox.Top : -entityBox.Bottom);
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
}
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
return occupied;
return (occupied, occupiedY);
}
private static void ValidateProgram(Program program, HashSet<Program> path)
@@ -502,7 +656,7 @@ public static class AutomaticCutOffPlanner
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
private static bool HasUsableSegments(Program program)
private static bool HasUsableSegments(Program program, bool vertical = true)
{
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
return false;
@@ -510,30 +664,49 @@ public static class AutomaticCutOffPlanner
{
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
!Near(from.EndPoint.X, to.EndPoint.X) ||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
(vertical ? !Near(from.EndPoint.X, to.EndPoint.X) : !Near(from.EndPoint.Y, to.EndPoint.Y)) ||
(vertical ? System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y)
: System.Math.Abs(from.EndPoint.X - to.EndPoint.X)) <= GeometryTolerance)
return false;
}
return true;
}
private static bool IsFullSpanProgram(Program program, Box bounds)
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
private static bool IsFullSpanProgram(Program program, bool vertical, double low, double high)
{
// No gaps, bridges, or filtered middle segments can separate the tail.
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
program.Codes[1] is not LinearMove to)
return false;
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
var start = vertical ? from.EndPoint.Y : from.EndPoint.X;
var end = vertical ? to.EndPoint.Y : to.EndPoint.X;
return System.Math.Min(start, end) <= low + GeometryTolerance &&
System.Math.Max(start, end) >= high - GeometryTolerance;
}
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
Nonnegative(box.Length) && Nonnegative(box.Width) &&
double.IsFinite(box.Right) && double.IsFinite(box.Top);
private static bool Inside(Box box, Box sheet) =>
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
// A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
private static bool IsRapidOntoOutline(Entity entity, List<Entity> outline, Vector location, Box sheet)
{
if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
|| !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
return false;
bool Allowed(Vector point) =>
Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
|| outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
}
private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
+11 -3
View File
@@ -21,12 +21,17 @@ namespace OpenNest.BestFit
IReadOnlyList<double> HullAngles
);
/// <summary>The saved best-fit results for one drawing at one plate size and spacing.</summary>
/// <summary>
/// The saved best-fit results for one drawing at one plate size and spacing.
/// <paramref name="CanonicalAngle"/> is the drawing's canonical angle (radians) when the
/// set was saved: the frame its pair geometry is in. Null means the frame is unknown.
/// </summary>
public sealed record SavedPairSet(
double PlateWidth,
double PlateHeight,
double Spacing,
IReadOnlyList<SavedPairResult> Results
IReadOnlyList<SavedPairResult> Results,
double? CanonicalAngle = null
);
/// <summary>
@@ -45,7 +50,10 @@ namespace OpenNest.BestFit
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
);
/// <summary>Restores saved sets for <paramref name="drawing"/>.</summary>
/// <summary>
/// Restores saved sets for <paramref name="drawing"/>. Sets saved in a frame other
/// than the drawing's current canonical frame, or in an unknown one, are stale.
/// </summary>
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
}
}
+1 -1
View File
@@ -238,7 +238,7 @@ namespace OpenNest.CNC
builder.PartIndex = i;
var mode = Mode.Absolute;
var pos = part.Location;
builder.AddProgram(part.Program, part.Location, ref mode, ref pos);
builder.AddProgram(DisplayPerimeterTail.WithoutTail(part.Program), part.Location, ref mode, ref pos);
}
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
@@ -36,6 +36,35 @@ internal enum AutomaticEntryKind
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
Pinned = 7,
/// <summary>
/// The perimeter point where a tab best keeps the part level on its support bed; only a
/// perimeter tabbed for support has it (see <see cref="PreparedContours.WithSupportTab"/>).
/// </summary>
SupportTab = 8,
/// <summary>
/// A cutout entry pierced where eroding the cutout leaves room (see
/// <see cref="PreparedContours.ErodedPierceEntries"/>): only offered once no configured
/// lead-in of the cutout fits.
/// </summary>
ErodedPierce = 9,
}
/// <summary>
/// Which automatic entries a perimeter tabbed for support offers (see
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue.
/// </summary>
internal enum SupportEntries
{
/// <summary>Entries whose tab steadies the part, clear of inside corners, plus its best tab point.</summary>
Steadying,
/// <summary>Every entry clear of inside corners: the fallback when no steadying entry fits.</summary>
ClearOfInsideCorners,
/// <summary>Every entry: the last fallback, when nothing clear of inside corners fits.</summary>
Any,
}
/// <summary>
@@ -55,23 +55,24 @@ internal static class ContourProgramVerifier
var first = run.Curves[0];
var ringIndex = -1;
var entityIndex = -1;
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
// The admitted slack is far below any extent margin, so only boundaries near the run's
// start can contain it; they come in ring order, as the full scan met them.
foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y)))
{
Query(token, ref budget);
var admitted = AdmittedClosureSlack(material.Rings[r]);
for (var e = 0; e < material.Rings[r].Length; e++)
var curve = material.Rings[r][e];
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
Query(token, ref budget);
var curve = material.Rings[r][e];
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
ringIndex = r;
entityIndex = e;
break;
}
ringIndex = r;
entityIndex = e;
break;
}
}
if (ringIndex < 0) return false;
// A display perimeter is never cut: any run along it is not this plan.
if (ringIndex == 0 && material.HasDisplayPerimeter) return false;
var tabs = material.Tabs[ringIndex];
if (tabs.Any(tab => tab))
{
@@ -108,7 +109,7 @@ internal static class ContourProgramVerifier
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
true, tolerance, token, ref budget)) return false;
}
return visited.Count == material.Rings.Count;
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
}
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
@@ -22,20 +22,39 @@ internal sealed class PlannedPartProgram
public CuttingParameters Parameters { get; }
}
/// <summary>
/// An owned cutoff segment program cut the other way at the far sheet edge
/// (<see cref="CutOff.ReverseAtFarEdge"/>); Apply installs it and records the direction on the
/// segment's cutoff definition, so regeneration keeps it.
/// </summary>
internal sealed class PlannedCutOffProgram
{
public PlannedCutOffProgram(Part segment, Program program)
{
Segment = segment;
Program = program;
}
public Part Segment { get; }
public Program Program { get; }
}
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
internal sealed class PlateCuttingPlan
{
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null)
IEnumerable<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = null)
{
Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray());
CutOffs = Array.AsReadOnly((cutOffs ?? []).ToArray());
}
public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; }
public IReadOnlyList<PlannedCutOffProgram> CutOffs { get; }
}
public enum CuttingCommitStatus
@@ -161,27 +180,49 @@ internal static class CuttingPlanCommit
}
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
}
var cutOffs = new List<(Part, Program, CutOff)>();
foreach (var planned in plan.CutOffs)
{
if (planned?.Segment == null || !onPlate.Contains(planned.Segment) || !targets.Add(planned.Segment))
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
var definition = plate.CutOffs.FirstOrDefault(c => ReferenceEquals(c?.Drawing, planned.Segment.BaseDrawing));
// Only the placed far-edge segment, cut exactly the other way, may be installed.
if (definition == null || planned.Program == null || live.Contains(planned.Program)
|| !installed.Add(planned.Program)
|| CutOff.ReverseAtFarEdge(planned.Segment.Program) is not { } expected
|| !ProgramContent.Equal(expected, planned.Program, token))
return Invalid(plate, "A cutoff segment can only be installed cut the other way at the far edge.");
cutOffs.Add((planned.Segment, planned.Program, definition));
}
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
staged.Add(new(plate, order, programs, cutOffs, reordered || programs.Count != 0 || cutOffs.Count != 0));
}
// Last cancellation point. The boundary below has no await, search or geometry work.
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
try
{
foreach (var item in staged)
{
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
item.Programs.Select(p => p.Part).Concat(item.CutOffs.Select(c => c.Segment))
.Select(part => (part, part.CaptureCuttingState())).ToArray(),
item.CutOffs.Select(c => (c.CutOff, c.CutOff.FarEdgeFirst, c.CutOff.Drawing.Program)).ToArray()));
item.Plate.Parts.SetOrder(item.Order);
foreach (var (part, program, bounds, parameters) in item.Programs)
{
beforeInstall?.Invoke(item.Plate, part);
part.InstallPlannedProgram(program, bounds, parameters);
}
foreach (var (segment, program, cutOff) in item.CutOffs)
{
beforeInstall?.Invoke(item.Plate, segment);
cutOff.InstallFarEdgeSegment(segment, program);
}
}
}
catch (Exception ex)
@@ -190,6 +231,11 @@ internal static class CuttingPlanCommit
{
foreach (var (part, state) in undo[i].States)
part.RestoreCuttingState(state);
foreach (var (cutOff, farEdgeFirst, program) in undo[i].CutOffs)
{
cutOff.FarEdgeFirst = farEdgeFirst;
cutOff.Drawing.Program = program;
}
undo[i].Plate.Parts.SetOrder(undo[i].Order);
}
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
@@ -208,5 +254,6 @@ internal static class CuttingPlanCommit
new(CuttingCommitStatus.InvalidInput, message, plate);
private sealed record Staged(Plate Plate, Part[] Order,
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
}
@@ -48,6 +48,15 @@ public static class ExecutionMotionReader
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
// A display perimeter's tail is never executed: an applied program ends at its marker.
// Only a well-formed tail at the end of the part's own program may end the read.
if (code is Comment { Value: DisplayPerimeterTail.Marker })
{
if (!ReferenceEquals(current, program))
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
DisplayPerimeterTail.IndexOf(current);
break;
}
if (requireSupportedTypes)
{
var type = code.GetType();
@@ -49,8 +49,8 @@ internal static class IncrementalClosureDrift
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
private static bool IsContour(Motion motion) => !motion.Suppressed
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter };
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
{
@@ -12,8 +12,9 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class LeadMaterialSnapshot
{
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
string reason)
string reason, bool displayPerimeter = false)
{
HasDisplayPerimeter = displayPerimeter;
Rings = rings;
Tabs = tabs;
Reason = reason;
@@ -41,6 +42,12 @@ public sealed class LeadMaterialSnapshot
/// </summary>
internal IReadOnlyList<bool[]> Tabs { get; }
/// <summary>
/// True when the perimeter (<see cref="Rings"/>[0]) is a display perimeter: a supplied blank's
/// edge that bounds material but is never cut. Every other ring is a hole the program cuts.
/// </summary>
internal bool HasDisplayPerimeter { get; }
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
@@ -53,6 +60,8 @@ public sealed class LeadMaterialSnapshot
var ringTabs = new List<bool[]>();
var chain = new List<PostVerificationGeometry.Curve>();
var chainTabs = new List<bool>();
var ringDisplay = new List<bool>();
var chainDisplay = new List<bool>();
foreach (var motion in execution.Motions)
{
token.ThrowIfCancellationRequested();
@@ -63,7 +72,8 @@ public sealed class LeadMaterialSnapshot
continue;
}
// A tabbed cutout's tabs are never cut but still close its hole.
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
// A display perimeter is never cut but still bounds the blank's material.
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter))
throw new ArgumentException("Material capture contains an unsupported layer.");
var curve = motion.Curve;
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
@@ -72,6 +82,7 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve);
chainTabs.Add(motion.Layer == LayerType.Tab);
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish();
}
@@ -80,37 +91,36 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material has no closed contour.");
var budget = 1000000;
// Certify simple rings and mutually disjoint boundaries before containment.
for (var r = 0; r < rings.Count; r++)
for (var s = r; s < rings.Count; s++)
for (var i = 0; i < rings[r].Length; i++)
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Material validation exceeds the native query limit.");
var a = rings[r][i];
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
// Native arc queries pad angles by 1e-5 radians, admitting
// intersections on an extension (distance grows with radius).
// At a shared arc joint require both actual finite spans;
// overlap and genuine second intersections still refuse.
if (arcJoint)
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// Native contacts at rounded arc/line joints can drift from the
// authored endpoint. Use the shared geometry tolerance only to
// recognize that joint; overlaps and other contacts still refuse.
if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
foreach (var (r, i, s, j) in ContactCandidates(rings, token))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Material validation exceeds the native query limit.");
var a = rings[r][i];
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
// Native arc queries pad angles by 1e-5 radians, admitting
// intersections on an extension (distance grows with radius).
// At a shared arc joint require both actual finite spans;
// overlap and genuine second intersections still refuse.
if (arcJoint)
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// Native contacts at rounded arc/line joints can drift from the
// authored endpoint. Use the shared geometry tolerance only to
// recognize that joint; overlaps and other contacts still refuse.
if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
? "A display perimeter must enclose every other contour."
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
var outer = -1;
for (var i = 0; i < rings.Count; i++)
{
@@ -123,16 +133,22 @@ public sealed class LeadMaterialSnapshot
}
}
if (outer < 0)
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
throw new ArgumentException(ringDisplay.Contains(true)
? "A display perimeter must enclose every other contour."
: "Material must have exactly one enclosing perimeter.");
if (ringTabs[outer].Any(tab => tab))
throw new ArgumentException("A perimeter cannot have tabs.");
if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
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))
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);
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
void Finish()
{
@@ -145,10 +161,14 @@ public sealed class LeadMaterialSnapshot
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
}
if (chainDisplay.Contains(true) && chainDisplay.Contains(false))
throw new ArgumentException("A display perimeter must be one whole closed contour.");
rings.Add(chain.ToArray());
ringTabs.Add(chainTabs.ToArray());
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
chain.Clear();
chainTabs.Clear();
chainDisplay.Clear();
}
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
@@ -157,18 +177,182 @@ public sealed class LeadMaterialSnapshot
}
}
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
/// <summary>
/// The boundary pairs certification queries natively, in the order of the full pairwise scan
/// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks'
/// margin (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>) can report no
/// native contact, so it is left out; a sweep over X finds the rest without visiting every pair.
/// Material that is not well conditioned keeps every pair.
/// </summary>
private static List<(int R, int I, int S, int J)> ContactCandidates(
IReadOnlyList<PostVerificationGeometry.Curve[]> rings, CancellationToken token)
{
var inside = false;
foreach (var curve in ring)
var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
var whole = PostVerificationGeometry.Extent.None;
for (var r = 0; r < rings.Count; r++)
for (var i = 0; i < rings[r].Length; i++)
{
curves.Add((r, i, rings[r][i].Extent));
whole = whole.Union(rings[r][i].Extent);
}
var pairs = new List<(int R, int I, int S, int J)>();
if (!whole.IsWellConditioned)
{
token.ThrowIfCancellationRequested();
if (curve.CrossesRay(point))
inside = !inside;
for (var a = 0; a < curves.Count; a++)
for (var b = a + 1; b < curves.Count; b++)
pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index));
}
return inside;
else
{
// No pair spans more than the whole material, so this bounds every pair's margin.
var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding
* System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY);
var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray();
for (var a = 0; a < byMinX.Length; a++)
{
token.ThrowIfCancellationRequested();
var first = byMinX[a];
for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++)
{
var second = byMinX[b];
if (first.Extent.IsClearOfQueriesFrom(second.Extent))
continue;
pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0
? (first.Ring, first.Index, second.Ring, second.Index)
: (second.Ring, second.Index, first.Ring, first.Index));
}
}
}
pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J)));
return pairs;
}
/// <summary>Material with fewer boundaries is scanned whole, exactly as before the index.</summary>
private const int IndexedBoundaries = 64;
private BoundaryIndex index;
/// <summary>
/// The boundaries a native query from <paramref name="query"/> can report a contact with, in
/// ring order: every boundary but those whose extents are clear of it by the lead checks'
/// margin plus <paramref name="reach"/> (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>).
/// Small material returns every boundary.
/// </summary>
internal IEnumerable<PostVerificationGeometry.Curve> BoundariesNear(PostVerificationGeometry.Extent query,
double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]);
/// <summary>As <see cref="BoundariesNear"/>, each boundary as its ring and its index in that ring.</summary>
internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0)
{
if (BoundaryCount < IndexedBoundaries)
return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i)));
return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach);
}
internal bool ContainsMaterial(Vector point, CancellationToken token)
{
if (BoundaryCount < IndexedBoundaries)
return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y);
return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token));
}
/// <summary>
/// A uniform grid of boundary extents over the material, for material with many holes. Read
/// only once built, so concurrent searches may share it.
/// </summary>
private sealed class BoundaryIndex
{
private readonly (int Ring, int Index)[] positions;
private readonly PostVerificationGeometry.Extent[] extents;
private readonly List<int>[] cells;
private readonly List<int>[] bands;
private readonly PostVerificationGeometry.Extent[] ringExtents;
private readonly int ringCount;
private readonly PostVerificationGeometry.Extent whole;
private readonly int columns;
private readonly int rows;
private readonly double width;
private readonly double height;
internal BoundaryIndex(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, PostVerificationGeometry.Extent whole)
{
positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray();
extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray();
ringCount = rings.Count;
ringExtents = new PostVerificationGeometry.Extent[rings.Count];
this.whole = whole;
var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length)));
columns = side;
rows = side;
width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns;
height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows;
cells = new List<int>[columns * rows];
bands = new List<int>[rows];
if (!whole.IsWellConditioned)
return;
for (var ring = 1; ring < rings.Count; ring++)
{
var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent));
ringExtents[ring] = extent;
for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin);
band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++)
(bands[band] ??= new List<int>()).Add(ring);
}
for (var b = 0; b < positions.Length; b++)
{
var (x0, y0, x1, y1) = Cells(extents[b], 0);
for (var y = y0; y <= y1; y++)
for (var x = x0; x <= x1; x++)
(cells[y * columns + x] ??= new List<int>()).Add(b);
}
}
/// <summary>
/// The holes, in ring order, whose curves a ray along row <paramref name="y"/> can cross:
/// every hole but those whose extents are clear of the row (see
/// <see cref="PostVerificationGeometry.Extent.IsClearOfRow"/>).
/// </summary>
internal IEnumerable<int> HolesOnRow(double y)
{
if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit))
return Enumerable.Range(1, ringCount - 1);
return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y));
}
internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach)
{
// The margin covers every pair's span, and each skip is then decided for the pair alone.
// Material or a query that is not well conditioned (whose cells were never built, for
// the material) keeps every boundary.
var union = whole.Union(query);
var margin = PostVerificationGeometry.Extent.ClearMargin + reach
+ PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
if (!union.IsWellConditioned || !double.IsFinite(margin))
return positions;
var (x0, y0, x1, y1) = Cells(query, margin);
var found = new List<int>();
for (var y = y0; y <= y1; y++)
for (var x = x0; x <= x1; x++)
if (cells[y * columns + x] is { } cell)
found.AddRange(cell);
found.Sort();
var near = new List<(int Ring, int Index)>();
for (var i = 0; i < found.Count; i++)
if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach))
near.Add(positions[found[i]]);
return near;
}
// The cells an extent widened by margin covers, clamped to the grid.
private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => (
Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin));
private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1);
private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1);
}
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token) =>
PostVerificationGeometry.Inside(point, ring, token);
}
@@ -125,7 +125,8 @@ public static class LeadPathValidator
var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
foreach (var boundary in target.Rings.SelectMany(r => r))
// A boundary clear of the contour's first motion contains none of its points.
foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
@@ -190,10 +191,7 @@ public static class LeadPathValidator
// Every boundary of material this far away is farther than the zone.
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
{
Charge(material.BoundaryCount);
continue;
}
if (Clearance(material) is { } distance)
return (Short(distance, "another placed part"), other);
}
@@ -232,11 +230,11 @@ public static class LeadPathValidator
}
// Material too far from the lead for any native boundary query to report a contact
// (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran.
// Ray parity is still asked natively unless the lead lies beside the material's rows.
// (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
// are charged, so many far parts or holes do not exhaust the budget. Ray parity is
// still asked natively unless the lead lies beside the material's rows.
bool FarInside(LeadMaterialSnapshot material)
{
Charge(material.BoundaryCount);
var midpoint = move.Curve.Midpoint;
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
}
@@ -289,7 +287,8 @@ public static class LeadPathValidator
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
{
foreach (var boundary in material.Rings.SelectMany(r => r))
// Boundaries no native query from the curve can reach report no contact; only the others run.
foreach (var boundary in material.BoundariesNear(curve.Extent))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
@@ -54,7 +54,7 @@ internal static class NestedPartContainment
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
@@ -33,18 +33,18 @@ internal static class OwnedCuttingParameters
PierceDiameter = Dimension(source.PierceDiameter),
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
RoundLeadInAngles = source.RoundLeadInAngles,
LeadInAngleIncrement = source.LeadInAngleIncrement,
AutoTabMinSize = Dimension(source.AutoTabMinSize),
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
TabConfig = source.TabConfig == null
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
: CopyTab(source.TabConfig),
TabsEnabled = source.TabsEnabled,
SupportTabs = source.SupportTabs,
NoBedTabMinWidth = Dimension(source.NoBedTabMinWidth),
NoBedTabMaxWidth = Dimension(source.NoBedTabMaxWidth),
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
Sequencing = new SequenceParameters
{
Method = sequence.Method,
@@ -71,7 +71,8 @@ internal static class OwnedCuttingParameters
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn
{ Radius = Dimension(n.Radius), SweepAngle = Sweep(n.SweepAngle) },
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
@@ -137,4 +138,7 @@ internal static class OwnedCuttingParameters
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
private static double Angle(double value) => double.IsFinite(value)
? value : throw new ArgumentException("Angles must be finite.");
private static double Sweep(double value) => double.IsFinite(value) && value > 0 && value <= 180
? value : throw new ArgumentException("Arc sweep must be in (0, 180] degrees.");
}
@@ -82,7 +82,8 @@ public sealed class PlateCuttingState
}
var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters)
@@ -155,7 +156,8 @@ public sealed class PlateCuttingState
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
return $"Cutoff {i + 1} definition changed.";
}
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
@@ -204,5 +206,5 @@ public sealed class PlateCuttingState
List<ContourCutPin> Pins, string PinsFingerprint);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit);
double? StartLimit, double? EndLimit, double HeadLockOvercut, bool FarEdgeFirst);
}
@@ -5,6 +5,7 @@ using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Support;
namespace OpenNest.CNC.CuttingPlanning;
@@ -14,9 +15,11 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
internal PreparedContours Owner { get; init; }
/// <summary>
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice.
/// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
/// zone (see <see cref="PreparedContours.ScrapPierceEntry"/>), or a cutout's where eroding it
/// leaves room (see <see cref="PreparedContours.ErodedPierceEntries"/>). The lead-in runs
/// straight from here to <see cref="Point"/> instead of the configured lead-in; a cutout so
/// pierced has no lead-out. Null for every other choice.
/// </summary>
public Vector? Pierce { get; internal init; }
}
@@ -41,10 +44,26 @@ public sealed class PreparedContours
private readonly ContourChoice[] pinned;
private readonly CuttingParameters[] contourParameters;
private readonly int[] pinNumbers;
// Set when the support bed cannot hold the part level: the perimeter is tabbed and its
// automatic entries keep to points where the tab steadies the part. World = local + origin.
private readonly PartSupport support;
private readonly Vector supportOrigin;
// The outside when it is a display perimeter: shown and material, never cut. It is not one of
// the contours, so every contour is a hole; a complete emission only appends it as the
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
private readonly Shape displayOutline;
private bool hasCutPerimeter => displayOutline == null;
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters)
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null)
{
}
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters, Shape displayOutline)
{
this.displayOutline = displayOutline;
this.shapes = shapes;
this.scribes = scribes;
this.breakCuts = breakCuts;
@@ -55,7 +74,8 @@ public sealed class PreparedContours
}
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
unpinned.displayOutline)
{
contourParameters = new CuttingParameters[shapes.Length];
var rejected = new List<CutPinResolution>();
@@ -78,7 +98,9 @@ public sealed class PreparedContours
rejected.Add(pin);
continue;
}
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
var choice = new ContourChoice(contour, pin.EntityOrdinal,
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
{ Owner = this };
ValidateChoice(choice);
pinned[contour] = choice;
pinNumbers[contour] = pin.PinIndex + 1;
@@ -87,17 +109,83 @@ public sealed class PreparedContours
RejectedPins = rejected.AsReadOnly();
}
// A copy that tabs the perimeter for support; every pin keeps its entry, now owned here, and
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
source.displayOutline)
{
for (var contour = 0; contour < Count; contour++)
{
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
pinNumbers[contour] = source.pinNumbers[contour];
}
contourParameters = source.contourParameters == null
? new CuttingParameters[Count]
: (CuttingParameters[])source.contourParameters.Clone();
if (pinned[PerimeterOrdinal] == null)
{
var perimeter = OwnedCuttingParameters.Copy(source.ParametersFor(PerimeterOrdinal));
perimeter.TabsEnabled = true;
perimeter.TabConfig = OwnedCuttingParameters.CopyTab(tab);
contourParameters[PerimeterOrdinal] = perimeter;
}
RejectedPins = source.RejectedPins;
this.support = support;
supportOrigin = location;
}
/// <summary>
/// A copy whose perimeter is tabbed with <paramref name="tab"/> where it steadies the part
/// on its support bed: <paramref name="support"/> analyzed this part's material placed at
/// <paramref name="location"/>, and the perimeter's automatic entries keep to points it
/// accepts (<see cref="PartSupport.AcceptsTabAt"/>) plus its best tab point. A pinned
/// perimeter keeps its pin's entry, leads and tab (the drawing wins); the copy only keeps
/// the analysis, so replay can report a pin that leaves the part unsteady.
/// </summary>
public PreparedContours WithSupportTab(PartSupport support, Vector location, Tab tab)
{
ArgumentNullException.ThrowIfNull(support);
ArgumentNullException.ThrowIfNull(tab);
PostVerificationGeometry.Validate(location);
if (!support.IsComplete || support.BestTabPoint == null)
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
if (!hasCutPerimeter)
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
return new PreparedContours(this, support, location, tab);
}
/// <summary>
/// The analysis of a part its support bed cannot hold level (see <see cref="WithSupportTab"/>),
/// or null; <see cref="SupportOrigin"/> places its local contours in its world frame.
/// </summary>
public PartSupport Support => support;
/// <summary>The part's location: world = local + this, for <see cref="Support"/>.</summary>
public Vector SupportOrigin => supportOrigin;
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
internal bool PerimeterTabbed => hasCutPerimeter && ParametersFor(PerimeterOrdinal).TabsEnabled;
public int Count => shapes.Length;
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
public int PerimeterOrdinal => Count - 1;
/// <summary>
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
/// is a display perimeter, so every contour below it is a hole.
/// </summary>
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
public bool HasCutPerimeter => hasCutPerimeter;
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
@@ -122,17 +210,15 @@ public sealed class PreparedContours
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
}
// The pin's leads replace every contour type's leads, since the type the emitter detects is
// The pin's leads replace both lead sets, since the type the emitter detects is
// not the caller's concern; angle rounding is off because a pinned entry never moves.
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
{
var owned = OwnedCuttingParameters.Copy(shared);
owned.ExternalLeadIn = pin.LeadIn;
owned.InternalLeadIn = pin.LeadIn;
owned.ArcCircleLeadIn = pin.LeadIn;
owned.ExternalLeadOut = pin.LeadOut;
owned.InternalLeadOut = pin.LeadOut;
owned.ArcCircleLeadOut = pin.LeadOut;
owned.RoundLeadInAngles = false;
owned.TabsEnabled = pin.HasTab;
if (pin.HasTab)
@@ -155,6 +241,7 @@ public sealed class PreparedContours
var scribes = new List<Entity>();
var breakCuts = new List<Shape>();
var tabbedCutouts = new List<Shape>();
Shape displayPerimeter = null;
Shape breakCut = null;
var current = new Shape();
foreach (var entity in geometryProgram.ToGeometry())
@@ -185,7 +272,8 @@ public sealed class PreparedContours
scribes.Add(entity);
continue;
}
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
&& entity.Layer != SpecialLayers.DisplayPerimeter)
throw new ArgumentException("Preparation requires clean cut/display contours.");
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate contour entity.");
@@ -203,6 +291,28 @@ public sealed class PreparedContours
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour();
}
if (displayPerimeter != null)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
// Every cut must lie on the blank: a non-rectangular blank's box is not enough.
// Each cut is sampled along its arcs and must not cross or touch the outline anywhere.
var outline = displayPerimeter.ToPolygon();
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
if (shape.ToPolygon().Vertices.Any(vertex => !outline.ContainsPoint(vertex))
|| displayPerimeter.Intersects(shape))
throw new ArgumentException("A display perimeter must enclose every other contour.");
// The search plans a part through its closed contours; tabbed cutouts and break cuts
// alone leave it nothing to choose.
if (contours.Count == 0)
throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs.");
foreach (var cut in breakCuts)
cut.UpdateBounds();
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, displayPerimeter);
}
if (current.Entities.Count != 0 || contours.Count == 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
var perimeter = 0;
@@ -223,7 +333,15 @@ public sealed class PreparedContours
void FinishContour()
{
current.UpdateBounds();
if (ContourCuttingStrategy.IsTabbedCutout(current))
if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
{
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
throw new ArgumentException("A display perimeter must be one whole closed contour.");
if (displayPerimeter != null)
throw new ArgumentException("A display perimeter must enclose every other contour.");
displayPerimeter = current;
}
else if (ContourCuttingStrategy.IsTabbedCutout(current))
{
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
throw new ArgumentException("A tabbed cutout has nothing to cut.");
@@ -285,8 +403,12 @@ public sealed class PreparedContours
void Add(int entityOrdinal, Vector point)
{
token.ThrowIfCancellationRequested();
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
if (entries.Count >= maxEntries)
return;
// Compare the entry as emitted: snapping can bring two raw points together.
var entry = Entry(contourOrdinal, entityOrdinal, point);
if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
entries.Add(entry);
}
}
@@ -311,6 +433,7 @@ public sealed class PreparedContours
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
ValidateChoice(choice);
return choice;
@@ -323,12 +446,61 @@ public sealed class PreparedContours
/// emission reads only this choice and the perimeter, and the lead validator checks the
/// lead as usual. A pinned perimeter keeps its pin's entry.
/// </summary>
internal ContourChoice ScrapPierceEntry(Vector pierce)
internal ContourChoice ScrapPierceEntry(Vector pierce) => PiercedEntry(PerimeterOrdinal, pierce);
/// <summary>
/// Entries of cutout <paramref name="contourOrdinal"/> pierced where eroding it leaves room
/// (see <see cref="ErodedPierce"/>): the planner's fallback when no configured lead-in fits the
/// cutout. For each of the first <paramref name="max"/> <paramref name="targets"/> (its own
/// entries, usually ranked) the pierce is the eroded point nearest it, and the lead-in runs
/// straight from there to the target itself, then, as an alternative, to the cutout's native
/// point closest to the pierce. Neither has a lead-out. Nothing for the perimeter or a pinned
/// cutout; the lead validator still checks each one.
/// </summary>
internal IReadOnlyList<ContourChoice> ErodedPierceEntries(int contourOrdinal, IReadOnlyList<ContourChoice> targets,
int max, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
return Array.Empty<ContourChoice>();
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
targets.Select(t => t.Point).ToArray(), max);
var entries = new List<ContourChoice>();
for (var i = 0; i < found.Pierces.Count; i++)
{
token.ThrowIfCancellationRequested();
var pierce = found.Pierces[i];
Add(() => targets[i] with { Pierce = pierce });
Add(() => PiercedEntry(contourOrdinal, pierce));
}
return entries;
void Add(Func<ContourChoice> entry)
{
try
{
var choice = entry();
ValidateChoice(choice);
if (!entries.Any(e => e.Point == choice.Point && e.Pierce == choice.Pierce))
entries.Add(choice);
}
catch (ArgumentException)
{
// A pierce on the wall itself has no lead; the others still stand.
}
}
}
// A contour entry at the closest native point to a pierce off it, along a straight lead-in.
private ContourChoice PiercedEntry(int contourOrdinal, Vector pierce)
{
PostVerificationGeometry.Validate(pierce);
var shape = GetShape(PerimeterOrdinal);
var shape = GetShape(contourOrdinal);
var point = shape.ClosestPointTo(pierce, out var entity);
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
{
Owner = this,
Pierce = pierce,
@@ -355,7 +527,7 @@ public sealed class PreparedContours
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
if (IsSingleCircle(shape))
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
}
/// <summary>
@@ -372,7 +544,8 @@ public sealed class PreparedContours
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
int contourOrdinal,
Vector? lookAhead = null,
CancellationToken token = default)
CancellationToken token = default,
SupportEntries supportEntries = SupportEntries.Steadying)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
@@ -382,7 +555,45 @@ public sealed class PreparedContours
? new List<ContourEntryCandidate>()
: PreferredCandidates(shape, contourOrdinal, token);
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
return MergeByGeometry(all);
return SupportFiltered(contourOrdinal, all, supportEntries);
}
/// <summary>
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
/// its automatic catalogue then keeps to steadying points unless asked for a fallback.
/// </summary>
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
/// <summary>
/// The support margin with this perimeter's tab at <paramref name="choice"/> (see
/// <see cref="PartSupport.MarginWith"/>); negative infinity when it is not tabbed for support.
/// </summary>
internal double SupportMarginAt(ContourChoice choice) =>
support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
/// <summary>Whether a support tab at <paramref name="choice"/> holds the part level (see <see cref="PartSupport.HoldsLevelWithTabAt"/>).</summary>
internal bool HoldsLevelAt(ContourChoice choice) =>
support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
/// <summary>Whether a support tab at <paramref name="choice"/> keeps the inside-corner clearance.</summary>
internal bool IsClearOfInsideCorners(ContourChoice choice) =>
support == null || support.IsClearOfInsideCorners(choice.Point + supportOrigin);
// A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains
// the outline's best tab point. The search's fallbacks, when none of them has a lead-in that
// fits, keep every candidate clear of inside corners and then every candidate, still tabbed.
private IReadOnlyList<ContourEntryCandidate> SupportFiltered(int contourOrdinal,
List<ContourEntryCandidate> candidates, SupportEntries entries)
{
if (support == null || contourOrdinal != PerimeterOrdinal || entries == SupportEntries.Any)
return MergeByGeometry(candidates);
if (entries == SupportEntries.ClearOfInsideCorners)
return MergeByGeometry(candidates.Where(c => IsClearOfInsideCorners(c.Choice)).ToList());
var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList();
var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin);
if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point))
kept.Add(new ContourEntryCandidate(best, AutomaticEntryKind.SupportTab));
return MergeByGeometry(kept);
}
private static bool IsSingleCircle(Shape shape) =>
@@ -475,9 +686,10 @@ public sealed class PreparedContours
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
// Strictly inside the edge by projection parameter (distance alone loses the
// sign when a short edge is overshoot): never the corner, never the far
// endpoint (where a reflex vertex might sit). Short edges omit the point.
// endpoint (where a reflex vertex might sit). Short edges omit the point. The
// margin is the vertex snap distance, so Entry cannot snap it onto either end.
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
return;
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
}
@@ -609,7 +821,7 @@ public sealed class PreparedContours
public Program Emit(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, true);
return EmitPrefix(choices);
return EmitOwned(choices, complete: true);
}
/// <summary>
@@ -626,21 +838,28 @@ public sealed class PreparedContours
throw new ArgumentException("Foreign contour choice.");
ValidateChoice(choice);
// Same owned clones as a real emission; the source shapes/settings are never used
// directly, so the probe cannot drift the preparation or mutate it.
// directly, so the probe cannot drift the preparation or mutate it. Isolated emission
// reads only the chosen contour and the contour count, so only that contour is cloned.
var owned = new Shape[shapes.Length];
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
contourParameters);
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
}
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, false);
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
return EmitOwned(choices, complete: false);
}
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
}
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
complete ? (Shape)displayOutline?.Clone() : null);
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
public int BreakCutCount => breakCuts.Count;
@@ -661,6 +880,8 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
// Only the outside is tabbed, with its own pin's tab when it is pinned.
if (!hasCutPerimeter)
return new(this, choices, expected, 0);
var perimeter = ParametersFor(PerimeterOrdinal);
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
}
@@ -695,10 +916,8 @@ public sealed class PreparedContours
if (choice.Pierce is { } pierce)
{
PostVerificationGeometry.Validate(pierce);
if (choice.ContourOrdinal != PerimeterOrdinal)
throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
throw new ArgumentException("A scrap pierce must lie off its contour.");
throw new ArgumentException("A planner-placed pierce must lie off its contour.");
}
}
@@ -722,6 +941,9 @@ public sealed class PreparedContours
return centres;
}
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
internal Box ContourBounds(int contourOrdinal) => GetShape(contourOrdinal).BoundingBox;
/// <summary>
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
@@ -733,6 +955,33 @@ public sealed class PreparedContours
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
}
/// <summary>
/// Emission splits a contour at its entry with <see cref="Arc.SplitAt"/>/<see cref="Line.SplitAt"/>,
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
/// stored centre sits beside the chord's midpoint) is never cut.
/// </summary>
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
{
if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
|| shape.Entities[entityOrdinal] is not (Line or Arc))
return point;
var entity = shape.Entities[entityOrdinal];
// Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
// programs emitted before this snap (saved pinned entries) re-emit unchanged.
bool Snaps(Vector vertex)
{
var distance = point.DistanceTo(vertex);
return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
}
var start = Start(entity);
if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
return Snaps(start) ? start : point;
var end = End(entity);
return Snaps(end) ? end : point;
}
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
@@ -12,6 +12,11 @@ public sealed class ReleasedContourState
{
private readonly List<Obstacle> obstacles = new();
private readonly bool reportMissingLeadIns;
// How the last analysed part ended: the contours it numbered, and whether nothing it left
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
// motions appended to it are judged. See Continue.
private int partContours;
private bool partClosed;
public ReleasedContourState() : this(true) { }
@@ -24,7 +29,11 @@ public sealed class ReleasedContourState
public ReleasedContourState Copy()
{
var copy = new ReleasedContourState(reportMissingLeadIns);
var copy = new ReleasedContourState(reportMissingLeadIns)
{
partContours = partContours,
partClosed = partClosed,
};
copy.obstacles.AddRange(obstacles);
return copy;
}
@@ -62,10 +71,33 @@ public sealed class ReleasedContourState
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
}
/// <summary>
/// Checks <paramref name="execution"/> as the continuation of the part the last check ended
/// in, as if that part's whole execution had been checked with these motions appended: the
/// findings are those the appended motions would add, and the state advances the same way.
/// It needs that part to have ended with every cut closed and nothing deferred, and the
/// continuation to open with a rapid and end the same way; otherwise the result is null and
/// this state must be discarded, and the caller checks the whole execution instead. Fixed
/// emissions and cutoffs are not continued.
/// </summary>
internal IReadOnlyList<PostVerificationFinding> Continue(OwnedExecution execution, Vector arrival,
int partNumber, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(execution);
PostVerificationGeometry.Validate(arrival);
if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
return null;
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours);
return partClosed ? findings.AsReadOnly() : null;
}
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)
bool proveOpenCuts = false, int contoursBefore = 0)
{
var current = new CutGroup();
var unfinished = new List<CutGroup>();
@@ -78,7 +110,7 @@ public sealed class ReleasedContourState
CutGroup previous = null;
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
var hasLead = false;
var contourNumber = 0;
var contourNumber = contoursBefore;
var inBreakCut = false;
var reportedTab = false;
for (var index = 0; index < moves.Count; index++)
@@ -95,7 +127,7 @@ public sealed class ReleasedContourState
Finish();
if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
"A break cut has no nonzero placed lead-in motion.")
"A break cut is missing lead-in geometry.")
{ Location = move.Curve.Start }, owned);
inBreakCut = true;
hasLead = false;
@@ -127,7 +159,14 @@ public sealed class ReleasedContourState
// A rapid well clear of a contour's extent can neither cross nor touch it.
if (reach.IsClearOf(obstacle.Extent))
continue;
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
// A rapid leaving an overcut departs from its retraced tail, which lies on the first
// cut only within the seam tolerance its arcs were matched at. Contact within that
// much of the departure is the departure itself.
var from = start;
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}.")
@@ -135,7 +174,7 @@ public sealed class ReleasedContourState
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
}, owned);
}
}
@@ -179,7 +218,7 @@ public sealed class ReleasedContourState
contourNumber++;
if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
$"Cutting contour {contourNumber} is missing lead-in geometry.")
{ Location = move.Curve.Start }, owned);
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
@@ -201,6 +240,12 @@ public sealed class ReleasedContourState
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(current.Curves))
Finish();
else if (OvercutClosure(current.Curves) is { } closing)
{
current.OvercutEnd = current.Curves[^1].End;
current.Curves[^1] = closing;
Finish();
}
}
}
Finish();
@@ -222,6 +267,9 @@ public sealed class ReleasedContourState
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
unfinished.Any(chain => chain.Fixed));
partContours = contourNumber;
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
&& !inBreakCut && loose.Leads.Count == 0;
void Add(PostVerificationFinding finding, bool fixedEmission)
{
@@ -244,9 +292,9 @@ public sealed class ReleasedContourState
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(current.Curves))
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
{
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
closed.Add(current);
}
else if (!cutoff)
@@ -255,6 +303,31 @@ public sealed class ReleasedContourState
}
}
/// <summary>
/// The closing piece of a contour cut on past its own start along its first cut, as a source
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
/// cut passes back through the start and its tail retraces the first cut. The tail encloses
/// nothing, so the contour is closed by the last cut up to its start. Null for any other shape.
/// Arcs of one circle may carry centers derived from their own rounded endpoints, so the seam
/// is matched at <see cref="PostVerificationGeometry.RaySeamTolerance"/>, the closure seam
/// ray parity already admits; the obstacle keeps that seam, never the retraced tail.
/// </summary>
private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList<PostVerificationGeometry.Curve> curves)
{
if (curves.Count < 2)
return null;
const double tolerance = PostVerificationGeometry.RaySeamTolerance;
var first = curves[0];
var last = curves[^1];
if (!last.SameDirection(first, tolerance) || !last.Contains(first.Start, tolerance)
|| !first.Contains(last.End, tolerance))
return null;
var seam = last.ClosestPoint(first.Start);
var along = last.DistanceAlong(seam);
var tail = last.Length - along;
return along > tolerance && tail > tolerance && tail <= first.Length + tolerance ? last.Until(seam) : null;
}
/// <summary>
/// Whether one part's open cuts, with their leads, can release no material. A closed
/// contour with its own leads already encloses whatever it releases and counts as one node.
@@ -396,6 +469,9 @@ public sealed class ReleasedContourState
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
internal bool Fixed { get; set; }
// Where the cut really ended when OvercutClosure closed it: the retraced tail's end, on the
// first cut. Its last curve now ends at the seam instead.
internal Vector? OvercutEnd { get; set; }
}
private sealed class DisjointSet(int count)
@@ -412,7 +488,8 @@ public sealed class ReleasedContourState
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
}
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
Vector? OvercutEnd)
{
internal PostVerificationGeometry.Extent Extent { get; } =
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
@@ -72,8 +72,8 @@ internal static class SeamOvercut
// Incremental coordinates only need a consistent origin within one contour.
var reference = incremental ? position ?? new Vector() : new Vector();
var end = reference + motion.EndPoint;
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
or ArcMove { Layer: LayerType.Cut or LayerType.Display };
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter };
if (!cut || position == null)
{
Finish();
@@ -83,16 +83,16 @@ internal static class StateFingerprint
Out(p.ExternalLeadOut);
Lead(p.InternalLeadIn);
Out(p.InternalLeadOut);
Lead(p.ArcCircleLeadIn);
Out(p.ArcCircleLeadOut);
Number(p.PierceClearance);
Number(p.PierceDiameter);
Flag(p.RoundLeadInAngles);
Number(p.LeadInAngleIncrement);
Number(p.AutoTabMinSize);
Number(p.AutoTabMaxSize);
TabSettings(p.TabConfig);
Flag(p.TabsEnabled);
Flag(p.SupportTabs);
Number(p.NoBedTabMinWidth);
Number(p.NoBedTabMaxWidth);
Number(p.TabHeldMaxLength);
Sequence(p.Sequencing);
Assignment(p.Assignment);
text.Append('}');
@@ -141,6 +141,7 @@ internal static class StateFingerprint
break;
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
Number(l.Radius);
Number(l.SweepAngle);
break;
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
Number(l.LineLength);
@@ -20,6 +20,7 @@ namespace OpenNest.CNC.CuttingStrategy
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
{
var entities = partProgram.ToGeometry();
RefuseDisplayPerimeter(entities);
var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -107,6 +108,14 @@ namespace OpenNest.CNC.CuttingStrategy
return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
}
// These tools would lead the outline or mistake the largest cutout for it (an External lead
// pierced on the blank). Only the cutting planner knows every contour of such a part is a hole.
private static void RefuseDisplayPerimeter(List<Entity> entities)
{
if (entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
throw new InvalidOperationException(DisplayPerimeterTail.LeadInRefusal);
}
public CuttingResult ApplySingle(
Program partProgram,
Vector point,
@@ -115,6 +124,7 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
var entities = partProgram.ToGeometry();
RefuseDisplayPerimeter(entities);
var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -251,14 +261,20 @@ namespace OpenNest.CNC.CuttingStrategy
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
IReadOnlyList<Shape> tabbedCutouts = null)
IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
EmitBreakCuts(result, breakCuts, shapes);
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
EmitTabbedCutout(result, cutout);
EmitChosenContours(result, shapes, choices, contourParameters);
EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
if (displayTail != null)
{
// Display only, after every cut: executed reads and posts stop at its marker.
var start = GetShapeStartPoint(displayTail);
DisplayPerimeterTail.Append(result, ConvertShapeToMoves(displayTail, start, LayerType.DisplayPerimeter), start);
}
result.Mode = Mode.Incremental;
return result;
}
@@ -269,20 +285,23 @@ namespace OpenNest.CNC.CuttingStrategy
// perimeter never degrades to a hole because it was isolated. Output must not be
// installed on a Part or accepted as a complete plan.
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
int? perimeterOrdinal = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
result.Mode = Mode.Incremental;
return result;
}
// One contour per choice in order; the perimeter (last shape) is always External.
// One contour per choice in order; the perimeter is always External. Without a cut perimeter
// (a display perimeter outside) perimeterOrdinal is past the last shape: every contour is a hole.
// Contour emission itself reads only the contour's own geometry and settings, never
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
private void EmitChosenContours(Program result, Shape[] shapes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
int perimeterOrdinal)
{
var shared = Parameters;
try
@@ -294,7 +313,7 @@ namespace OpenNest.CNC.CuttingStrategy
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
var shape = shapes[choice.ContourOrdinal];
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
exactCirclePrograms: true, scrapPierce: choice.Pierce);
}
}
@@ -430,6 +449,9 @@ namespace OpenNest.CNC.CuttingStrategy
var leadIn = SelectLeadIn(contourType);
var leadOut = SelectLeadOut(contourType);
// A cutout the planner pierced by erosion is too small for its configured leads.
if (scrapPierce != null && contourType != ContourType.External)
leadOut = new NoLeadOut();
if (contourType == ContourType.ArcCircle && entity is Circle circle)
{
@@ -446,9 +468,14 @@ namespace OpenNest.CNC.CuttingStrategy
);
}
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance());
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius());
if (scrapPierce is { } holePierce)
leadIn = StraightLeadIn(point, holePierce, out normal);
else
{
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance());
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
}
// Build hole sub-program relative to (0,0)
var holeCenter = circle.Center;
@@ -487,6 +514,7 @@ namespace OpenNest.CNC.CuttingStrategy
return;
}
var turn = LeadTurn(contourType, winding);
double leadInNormal;
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
if (scrapPierce is { } pierce)
@@ -496,9 +524,9 @@ namespace OpenNest.CNC.CuttingStrategy
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
Parameters.GetEffectivePierceClearance(), out leadInNormal);
if (contourType == ContourType.Internal)
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius());
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius(), turn);
}
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
var reindexedShape = shape.ReindexAt(point, entity);
@@ -529,9 +557,20 @@ namespace OpenNest.CNC.CuttingStrategy
}
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding));
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
}
/// <summary>
/// The direction a lead arc turns to meet a contour of <paramref name="winding"/>
/// tangentially, in the cut direction. Normals point away from the material, so the arc
/// center lies outside an outside perimeter (turning against its winding) and inside a
/// cutout (turning with it).
/// </summary>
public static RotationType LeadTurn(ContourType contourType, RotationType winding) =>
contourType == ContourType.External
? winding == RotationType.CW ? RotationType.CCW : RotationType.CW
: winding;
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
{
if (scribeEntities.Count == 0)
@@ -616,8 +655,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
/// reflex corner it bisects the notch. The result does not depend on which of
/// the two edges meeting at the corner was picked. Other styles and contour
/// types keep <see cref="ComputeLeadInNormal"/>.
/// the two edges meeting at the corner was picked. A curved lead-in at a convex
/// perimeter corner uses the normal of the edge cut first, so its arc meets that
/// edge tangentially. Other styles and contour types keep
/// <see cref="ComputeLeadInNormal"/>.
/// </summary>
public static LeadIn ResolveLeadIn(
Shape shape,
@@ -631,8 +672,19 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|| !TryGetCorner(shape, point, entity, out var corner))
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
return leadIn;
if (leadIn is ArcLeadIn or LineArcLeadIn or CleanHoleLeadIn)
{
// The arc meets the edge cut first tangentially; its center sits off that edge,
// which at a convex corner keeps the whole arc clear of the part.
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
return leadIn;
}
if (leadIn is not LineLeadIn line)
return leadIn;
switch (ClassifyCorner(corner, winding))
@@ -662,7 +714,8 @@ namespace OpenNest.CNC.CuttingStrategy
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
/// bisects the notch. Other styles and contour types keep the entity normal.
/// bisects the notch. An <see cref="ArcLeadOut"/> at a convex corner uses the normal of
/// the edge cut last. Other styles and contour types keep the entity normal.
/// </summary>
public static LeadOut ResolveLeadOut(
Shape shape,
@@ -676,8 +729,18 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|| !TryGetCorner(shape, point, entity, out var corner))
if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
return leadOut;
if (leadOut is ArcLeadOut)
{
// The arc leaves the edge cut last tangentially.
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
return leadOut;
}
if (leadOut is not LineLeadOut line)
return leadOut;
switch (ClassifyCorner(corner, winding))
@@ -945,7 +1008,7 @@ namespace OpenNest.CNC.CuttingStrategy
if (leadIn is NoLeadIn || clearance <= 0)
return leadIn;
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle, circle.Rotation);
var maxRadius = circle.Radius - clearance;
if (maxRadius <= 0)
return leadIn;
@@ -994,10 +1057,11 @@ namespace OpenNest.CNC.CuttingStrategy
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
/// knows nothing of parts nested inside the hole. Shared by program generation and the
/// manual placement preview.
/// manual placement preview. <paramref name="turn"/> is the hole's
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
/// </summary>
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
double pierceRadius)
double pierceRadius, RotationType turn)
{
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|| !hole.IsClosed())
@@ -1007,7 +1071,7 @@ namespace OpenNest.CNC.CuttingStrategy
var polygon = circle == null
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
: null;
if (Clear(leadIn.GetPiercePoint(point, normal)))
if (Clear(leadIn.GetPiercePoint(point, normal, turn)))
return leadIn;
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
@@ -1067,8 +1131,7 @@ namespace OpenNest.CNC.CuttingStrategy
{
return contourType switch
{
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn,
ContourType.Internal => Parameters.InternalLeadIn,
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadIn,
_ => Parameters.ExternalLeadIn,
};
}
@@ -1077,8 +1140,7 @@ namespace OpenNest.CNC.CuttingStrategy
{
return contourType switch
{
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut,
ContourType.Internal => Parameters.InternalLeadOut,
ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadOut,
_ => Parameters.ExternalLeadOut,
};
}
@@ -1351,7 +1413,7 @@ namespace OpenNest.CNC.CuttingStrategy
return mirrored;
}
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
var pierce = leadIn.GetPiercePoint(entry, normal);
var pierce = leadIn.GetPiercePoint(entry, normal, winding);
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
}
@@ -15,13 +15,14 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
/// <summary>
/// The lead-in of every cutout, full circles included; circles still get their own
/// geometry handling (radius clamp, angle rounding) when the lead is emitted.
/// </summary>
public LeadIn InternalLeadIn { get; set; } =
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
public double PierceClearance { get; set; } = 0.0625;
/// <summary>
@@ -40,12 +41,55 @@ namespace OpenNest.CNC.CuttingStrategy
public bool RoundLeadInAngles { get; set; }
public double LeadInAngleIncrement { get; set; } = 5.0;
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
public Tab TabConfig { get; set; }
public bool TabsEnabled { get; set; }
/// <summary>
/// Tabs each part its plate's support bed cannot hold level once it is cut free, at the
/// perimeter point that best keeps its center of gravity over the bed's contacts (see
/// docs/support-tabs.md). Needs <see cref="TabConfig"/> for the tab size.
/// </summary>
public bool SupportTabs { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the narrowest part
/// width that is tabbed instead (see <see cref="TabsWithoutBedAt"/>).
/// </summary>
public double NoBedTabMinWidth { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the widest part
/// width that is tabbed instead; 0 turns the rule off, so nothing is tabbed without a bed.
/// </summary>
public double NoBedTabMaxWidth { get; set; }
/// <summary>Whether <see cref="NoBedTabMinWidth"/> to <see cref="NoBedTabMaxWidth"/> is a usable range.</summary>
public bool HasNoBedTabRange() =>
NoBedTabMaxWidth > 0 && double.IsFinite(NoBedTabMaxWidth)
&& NoBedTabMinWidth >= 0 && NoBedTabMinWidth <= NoBedTabMaxWidth;
/// <summary>
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
/// bounding rectangle) is tabbed when its plate has no support bed.
/// </summary>
public bool TabsWithoutBedAt(double width) =>
HasNoBedTabRange()
&& width >= NoBedTabMinWidth - Math.Tolerance.Epsilon
&& width <= NoBedTabMaxWidth + Math.Tolerance.Epsilon;
/// <summary>
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
/// it. 0 turns the rule off.
/// </summary>
public double TabHeldMaxLength { get; set; }
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
public bool TabHoldsAt(double length) =>
TabHeldMaxLength > 0 && double.IsFinite(TabHeldMaxLength)
&& length <= TabHeldMaxLength + Math.Tolerance.Epsilon;
public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
}
@@ -0,0 +1,141 @@
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// Pierces found by eroding a cutout: the part of it that keeps a clearance from its wall
/// (see <see cref="ErodedPierce.Find"/>).
/// </summary>
/// <param name="Pierces">The pierce for each target in turn, up to the maximum asked for.</param>
/// <param name="Clearance">The least any pierce keeps from the wall, at the erosion level that found it.</param>
public sealed record ErodedPierces(IReadOnlyList<Vector> Pierces, double Clearance)
{
public static ErodedPierces None { get; } = new(System.Array.Empty<Vector>(), 0);
}
/// <summary>
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
/// The erosion goes as deep as the pierce clearance when the cutout has room for it, else
/// almost to its pole of inaccessibility. Each target, an entry the planner ranked, gets the
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
/// half that depth: a letter stroke is pierced on its own centre line next to the entry, and
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
/// straight from the pierce to the wall.
/// </summary>
public static class ErodedPierce
{
/// <summary>
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
/// not fit: deep enough to stay near the centre line, with room for every place the
/// cutout is about as wide as its widest.
/// </summary>
public const double DepthFraction = 0.9;
private const int PoleSearchCells = 4096;
// Shallower erosion levels a target may fall back to, each a tenth of the deepest less.
private const int LevelCount = 6;
private const double LevelStep = 0.1;
/// <summary>
/// A pierce in the closed cutout <paramref name="hole"/> for each of the first
/// <paramref name="max"/> <paramref name="targets"/>: the eroded point nearest it. A cutout
/// too small to erode is pierced at its pole for every target. Nothing for an open or
/// degenerate contour.
/// </summary>
public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList<Vector> targets, int max)
{
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|| !double.IsFinite(clearance))
return ErodedPierces.None;
// The inscribed radius, from the true wall: the deepest any erosion can go.
var coarse = ClipperBridge.Flatten(hole, Tolerance(hole.BoundingBox.Width, hole.BoundingBox.Length), false);
if (coarse.Vertices.Count < 3)
return ErodedPierces.None;
var pole = PolyLabel.Find(coarse, null, Tolerance(coarse.BoundingBox.Width, coarse.BoundingBox.Length) / 10,
PoleSearchCells);
if (!double.IsFinite(pole.X) || !double.IsFinite(pole.Y) || !coarse.ContainsPoint(pole))
return ErodedPierces.None;
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
if (!(inscribed > 0))
return ErodedPierces.None;
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
// each by the tolerance; Clipper rounds to its precision. Erode past both.
var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction);
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
if (depth + margin >= inscribed - tolerance)
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
var polygon = ClipperBridge.Flatten(hole, tolerance, circumscribe: false);
var path = new PathsD { ClipperBridge.ToPath(polygon, positive: true) };
var levels = new List<PathD>[LevelCount];
List<PathD> Level(int level) => levels[level] ??= Clipper.InflatePaths(path,
-(Depth(level) + margin), JoinType.Round, EndType.Polygon, 2.0, ClipperBridge.Precision, tolerance)
.Where(p => p.Count >= 3).ToList();
double Depth(int level) => depth * (1 - level * LevelStep);
if (Level(0).Count == 0)
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
// Each target gets the deepest level that reaches the wall beside it: a stroke
// narrower than the cutout's widest place is still pierced on its own centre line, near
// the entry, rather than only at the widest place.
var pierces = new List<Vector>();
var shallowest = depth;
foreach (var target in targets)
{
var pierce = Nearest(Level(0), target);
var reached = 0;
for (var level = 0; level < LevelCount; level++)
{
var region = Level(level);
if (region.Count == 0)
continue;
var nearest = Nearest(region, target);
if (nearest.DistanceTo(target) <= 2 * (Depth(level) + margin))
{
pierce = nearest;
reached = level;
break;
}
}
pierces.Add(pierce);
shallowest = System.Math.Min(shallowest, Depth(reached));
if (pierces.Count == max)
break;
}
return new(pierces, shallowest);
}
// A hundredth of the smaller side: fine enough to find a thin cutout's pole.
private static double Tolerance(double width, double length) =>
System.Math.Max(System.Math.Min(width, length) / 100, System.Math.Pow(10, -ClipperBridge.Precision));
// The closest point of the region's boundary; targets lie on the wall, outside it.
private static Vector Nearest(List<PathD> paths, Vector target)
{
var best = default(Vector);
var bestDistance = double.PositiveInfinity;
foreach (var path in paths)
for (var i = 0; i < path.Count; i++)
{
var a = new Vector(path[i].x, path[i].y);
var b = new Vector(path[(i + 1) % path.Count].x, path[(i + 1) % path.Count].y);
var point = new Line(a, b).ClosestPointTo(target);
var distance = point.DistanceTo(target);
if (distance < bestDistance)
{
bestDistance = distance;
best = point;
}
}
return best;
}
}
}
@@ -1,43 +1,62 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A single arc that ends tangent to the cut. Its center lies <see cref="Radius"/> along the
/// contour normal and it turns <see cref="SweepAngle"/> degrees in the lead's turn direction:
/// 90 is a quarter circle, 180 (the default) a half circle starting two radii out.
/// </summary>
public class ArcLeadIn : LeadIn
{
public const double DefaultSweepAngle = 180.0;
public double Radius { get; set; }
/// <summary>Degrees of arc, in (0, 180].</summary>
public double SweepAngle { get; set; } = DefaultSweepAngle;
public override List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenter = new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
new ArcMove(contourStartPoint, ArcCenter(contourStartPoint, contourNormalAngle), winding)
{
Layer = LayerType.Leadin,
},
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
public override Vector GetPiercePoint(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
// The contour point sits opposite the normal from the center; the pierce is the
// sweep back from there, against the turn. A half circle pierces on the normal.
var shortfall = Angle.ToRadians(DefaultSweepAngle - SweepAngle);
var angle = contourNormalAngle + (winding == RotationType.CCW ? shortfall : -shortfall);
var center = ArcCenter(contourStartPoint, contourNormalAngle);
return new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
center.X + Radius * System.Math.Cos(angle),
center.Y + Radius * System.Math.Sin(angle)
);
}
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
public override LeadIn Scale(double factor) =>
new ArcLeadIn { Radius = Radius * factor, SweepAngle = SweepAngle };
private Vector ArcCenter(Vector contourStartPoint, double contourNormalAngle) => new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
}
}
@@ -1,11 +1,12 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
public class CleanHoleLeadIn : LeadIn
{
private const double ApproachAngle = 135.0;
public double LineLength { get; set; }
public double ArcRadius { get; set; }
public double Kerf { get; set; }
@@ -14,42 +15,11 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY);
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW) =>
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn
@@ -58,5 +28,8 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor,
Kerf = Kerf,
};
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
}
}
@@ -3,6 +3,11 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A lead into a contour point. <c>winding</c> is the direction a lead arc turns to meet the
/// cut tangentially: the cut's own direction around the arc center, which lies along the
/// normal (see <see cref="ContourCuttingStrategy.LeadTurn"/>). Straight styles ignore it.
/// </summary>
public abstract class LeadIn
{
public abstract List<ICode> Generate(
@@ -11,7 +16,8 @@ namespace OpenNest.CNC.CuttingStrategy
RotationType winding = RotationType.CW
);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
public virtual LeadIn Scale(double factor) => this;
}
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A straight line into an arc that ends on the contour start, shared by
/// <see cref="LineArcLeadIn"/> and <see cref="CleanHoleLeadIn"/>. The arc's center lies
/// the arc radius along the contour normal; the line meets the arc at the approach angle
/// (degrees from the normal, on the side the arc turns from) and starts at the pierce point.
/// </summary>
internal readonly struct LineArcApproach
{
private LineArcApproach(Vector arcCenter, Vector arcStart, Vector piercePoint)
{
ArcCenter = arcCenter;
ArcStart = arcStart;
PiercePoint = piercePoint;
}
public Vector ArcCenter { get; }
public Vector ArcStart { get; }
public Vector PiercePoint { get; }
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
double arcRadius, double approachAngle, double lineLength, RotationType winding)
{
var arcCenterX = contourStartPoint.X + arcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + arcRadius * System.Math.Sin(contourNormalAngle);
// The approach angle is measured back from the normal against the turn, so the arc
// runs the short way round to the contour point whichever way it turns.
var approach = Angle.ToRadians(approachAngle);
var lineAngle = contourNormalAngle + (winding == RotationType.CCW ? approach : -approach);
var arcStartX = arcCenterX + arcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + arcRadius * System.Math.Sin(lineAngle);
return new LineArcApproach(
new Vector(arcCenterX, arcCenterY),
new Vector(arcStartX, arcStartY),
new Vector(
arcStartX + lineLength * System.Math.Cos(lineAngle),
arcStartY + lineLength * System.Math.Sin(lineAngle)));
}
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
public List<ICode> ToCodes(Vector contourStartPoint, RotationType winding) =>
new List<ICode>
{
new RapidMove(PiercePoint),
new LinearMove(ArcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin },
};
}
}
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -14,42 +13,11 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY);
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW) =>
Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
public override LeadIn Scale(double factor) =>
new LineArcLeadIn
@@ -58,5 +26,8 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor,
ApproachAngle = ApproachAngle,
};
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
}
}
@@ -24,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
return new Vector(
@@ -33,7 +33,8 @@ namespace OpenNest.CNC.CuttingStrategy
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
@@ -14,7 +14,8 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> { new RapidMove(contourStartPoint) };
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{
return contourStartPoint;
}
@@ -3,6 +3,10 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A quarter circle leaving the cut tangentially, turning <c>winding</c> about a center
/// <see cref="Radius"/> along the contour normal.
/// </summary>
public class ArcLeadOut : LeadOut
{
public double Radius { get; set; }
@@ -17,9 +21,12 @@ namespace OpenNest.CNC.CuttingStrategy
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY);
// The contour point sits opposite the normal from the center; turn a quarter on.
var endAngle = contourNormalAngle
+ (winding == RotationType.CCW ? -System.Math.PI / 2 : System.Math.PI / 2);
var endPoint = new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
arcCenterX + Radius * System.Math.Cos(endAngle),
arcCenterY + Radius * System.Math.Sin(endAngle)
);
return new List<ICode>
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
/// <summary>
/// The never-cut outline of a supplied blank, kept at the end of an applied cutting program.
/// An applied program for a blank with a <see cref="LayerType.DisplayPerimeter"/> outline cuts
/// only its cutouts; the outline follows them after <see cref="Marker"/> so the part keeps the
/// blank's extent for bounds, overlap and cut-offs. Nothing that executes or posts a program may
/// run the tail: executed reads stop at the marker and posts take <see cref="ForPosting"/>.
/// </summary>
public static class DisplayPerimeterTail
{
/// <summary>The comment that starts the tail; every code after it is display only.</summary>
public const string Marker = "DISPLAY PERIMETER NEVER CUT";
/// <summary>Why the manual and legacy lead-in tools leave a display-perimeter part alone.</summary>
public const string LeadInRefusal =
"This part's outline is a display perimeter: a supplied blank that is never cut. "
+ "Lead-ins for its cutouts come from Plan Cutting, which keeps every pierce inside a cutout.";
/// <summary>
/// The index of the tail's marker in <paramref name="program"/>'s codes, or -1. A marker that is
/// repeated, sits in a sub-program, or is followed by anything but one rapid and moves on the
/// display perimeter layer is a malformed program (<see cref="ArgumentException"/>): cuts after
/// it must never be dropped silently.
/// </summary>
public static int IndexOf(Program program)
{
ArgumentNullException.ThrowIfNull(program);
var marker = -1;
for (var i = 0; i < program.Codes.Count; i++)
{
if (program.Codes[i] is Comment { Value: Marker })
{
if (marker >= 0)
throw new ArgumentException("A program has more than one display perimeter tail.");
marker = i;
}
else if (program.Codes[i] is SubProgramCall call
&& HasMarker(call.Program, new HashSet<Program>(ReferenceEqualityComparer.Instance)))
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
}
if (marker < 0)
return -1;
var tail = program.Codes.Count - marker - 1;
if (tail < 2 || program.Codes[marker + 1] is not RapidMove
|| !program.Codes.Skip(marker + 2).All(code =>
code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter }))
throw new ArgumentException(
"A display perimeter tail must be one rapid followed only by the outline on the display perimeter layer.");
return marker;
}
private static bool HasMarker(Program program, HashSet<Program> visited)
{
if (program?.Codes == null || !visited.Add(program))
return false;
foreach (var code in program.Codes)
if (code is Comment { Value: Marker } || code is SubProgramCall call && HasMarker(call.Program, visited))
return true;
return false;
}
public static bool HasTail(Program program) => IndexOf(program) >= 0;
/// <summary>
/// Appends the outline after the marker: a rapid to its start, then its entities on the
/// display perimeter layer. <paramref name="program"/> must still be in absolute mode.
/// </summary>
internal static void Append(Program program, IReadOnlyList<ICode> outline, Vector start)
{
if (program.Mode != Mode.Absolute)
throw new InvalidOperationException("Append the display tail before switching to incremental mode.");
program.Codes.Add(new Comment(Marker));
program.Codes.Add(new RapidMove(start));
program.Codes.AddRange(outline);
}
/// <summary>
/// The executed program for estimates and drawing: <paramref name="program"/> itself when it
/// has no well-formed tail, otherwise a shallow copy of the codes before the marker. Posts
/// and verification use the strict <see cref="ForPosting"/> and executed reads instead.
/// </summary>
public static Program WithoutTail(Program program)
{
try
{
var marker = IndexOf(program);
return marker < 0 ? program : program.HeadCopy(marker);
}
catch (ArgumentException)
{
return program;
}
}
/// <summary>Whether <paramref name="program"/> or any sub-program it calls moves on the display perimeter layer.</summary>
public static bool HasDisplayPerimeterMotion(Program program) =>
HasDisplayPerimeterMotion(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
private static bool HasDisplayPerimeterMotion(Program program, HashSet<Program> visited)
{
if (program?.Codes == null || !visited.Add(program))
return false;
foreach (var code in program.Codes)
{
if (code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter })
return true;
if (code is SubProgramCall call && HasDisplayPerimeterMotion(call.Program, visited))
return true;
}
return false;
}
/// <summary>
/// The program a post may write for a part: its tail removed. A display perimeter anywhere
/// else means the part was never planned, and posting it would cut the blank's outline.
/// </summary>
public static Program ForPosting(Program program, string partName)
{
int marker;
try
{
marker = IndexOf(program);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{partName} cannot be posted: {ex.Message}", ex);
}
var executed = marker < 0 ? program : program.HeadCopy(marker);
if (HasDisplayPerimeterMotion(executed))
throw new InvalidOperationException(
$"{partName} has a display perimeter (a supplied blank's outline that is never cut) but no "
+ "cutting plan. Run Plan Cutting before posting so only its cutouts are cut.");
return executed;
}
/// <summary>The name posts give a part in a refusal.</summary>
public static string PartName(Part part) => $"Part '{part.BaseDrawing?.Name}'";
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: every part's program
/// must pass <see cref="ForPosting"/>, so a refusal never leaves an empty or partial file.
/// </summary>
public static void EnsurePostable(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
foreach (var part in plate.Parts)
if (part?.Program != null && part.BaseDrawing?.IsCutOff != true)
ForPosting(part.Program, PartName(part));
}
}
}
+103
View File
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
namespace OpenNest.CNC
{
/// <summary>
/// The head-height lock around a cut-off's overcut past the nominal far sheet edge. Stock can
/// arrive wider than nominal, so a cut-off overcuts past the far edge to cut the oversize off;
/// past the real edge there is no material to sense, so the head height is locked first. A
/// cut-off program marks the locked stretch with a <see cref="Lock"/> comment and ends it with an
/// <see cref="Unlock"/> comment; a rapid between them keeps the head locked and is not a head
/// raise. Posts write each marker as their machine's lock or unlock code and refuse to post a
/// marked program when no code is configured, because the overcut is unsafe without the lock.
/// See docs/automatic-scrap-cutoffs.md.
/// </summary>
public static class HeadHeightLock
{
/// <summary>The comment before the first motion made with the head height locked.</summary>
public const string Lock = "HEAD HEIGHT LOCK";
/// <summary>The comment after the last motion made with the head height locked.</summary>
public const string Unlock = "HEAD HEIGHT UNLOCK";
public static bool IsLock(ICode code) => code is Comment { Value: Lock };
public static bool IsUnlock(ICode code) => code is Comment { Value: Unlock };
/// <summary>Whether <paramref name="program"/>'s own codes hold a lock marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsLock(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether the head height is locked while it runs: the
/// codes after a lock marker up to its unlock marker. Markers must alternate, starting with a
/// lock and ending unlocked; anything else is a malformed program (<see cref="ArgumentException"/>),
/// since a post that lost track of the lock could cut off the sheet unlocked or never unlock.
/// </summary>
public static bool[] LockedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
var locked = false;
for (var i = 0; i < codes.Count; i++)
{
if (IsLock(codes[i]))
{
if (locked)
throw new ArgumentException("A head-height lock is set twice without an unlock.");
locked = true;
}
else if (IsUnlock(codes[i]))
{
if (!locked)
throw new ArgumentException("A head-height unlock has no lock before it.");
locked = false;
}
else
mask[i] = locked;
}
if (locked)
throw new ArgumentException("A head-height lock is never released.");
return mask;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCodes, 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 (!hasCodes)
throw new InvalidOperationException(
$"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
+ "or delete the cut-off and add it again with the head-height lock off.");
try
{
LockedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
+9
View File
@@ -21,5 +21,14 @@ namespace OpenNest.CNC
/// leaves it out, cutting the rest of the contour as open pieces.
/// </summary>
Tab,
/// <summary>
/// The outside edge of a supplied blank that is only shown, never cut (for example a
/// purchased door whose instrument cutouts are the only cuts). It bounds material like
/// <see cref="Cut"/>, so nesting, containment, lead clearance and cut-offs all see the
/// blank, but it has no pierce, lead or motion in any emitted program. It must be one whole
/// closed contour enclosing every other contour.
/// </summary>
DisplayPerimeter,
}
}
+15
View File
@@ -508,6 +508,21 @@ namespace OpenNest.CNC
return true;
}
/// <summary>
/// A shallow copy holding only the first <paramref name="count"/> codes: the same
/// instruction, variable and sub-program instances, mode and rotation.
/// </summary>
internal Program HeadCopy(int count)
{
var head = new Program() { mode = this.mode, Rotation = this.Rotation };
head.Codes.AddRange(Codes.GetRange(0, count));
foreach (var kvp in Variables)
head.Variables[kvp.Key] = kvp.Value;
foreach (var kvp in SubPrograms)
head.SubPrograms[kvp.Key] = kvp.Value;
return head;
}
public object Clone()
{
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
+2
View File
@@ -31,6 +31,8 @@ namespace OpenNest.CNC
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
{
// The head never travels to a display perimeter's tail.
pgm = DisplayPerimeterTail.WithoutTail(pgm);
// Draw the approach to the first rapid destination, or to the program
// origin when it starts cutting without a rapid. Only an initial rapid
// (or call arrival) is already represented by this approach; a later
@@ -183,6 +183,9 @@ namespace OpenNest.Converters
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
return LayerType.Tab;
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
return LayerType.DisplayPerimeter;
return LayerType.Cut;
}
}
@@ -213,6 +213,9 @@ namespace OpenNest.Converters
case LayerType.Tab:
return SpecialLayers.Tab;
case LayerType.DisplayPerimeter:
return SpecialLayers.DisplayPerimeter;
default:
return new Layer(layer.ToString());
}
+167 -6
View File
@@ -21,6 +21,23 @@ namespace OpenNest
public double? EndLimit { get; set; }
public Drawing Drawing { get; private set; }
/// <summary>
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
/// arrive wider than nominal, so only the origin-side edge is certain: the overcut cuts the
/// oversize off. It replaces <see cref="CutOffSettings.Overtravel"/> at the far edge and
/// applies only when the line runs to that edge (no limit on that side).
/// </summary>
public double HeadLockOvercut { get; set; }
/// <summary>
/// Whether the segment reaching the far edge is cut from it toward the origin: it starts at
/// the nominal far edge, overcuts outward with the head locked, rapids back with the head
/// still locked, unlocks and cuts toward the origin. Ignored without a head-locked overcut,
/// since nothing else may start a cut at an edge whose position is unknown.
/// </summary>
public bool FarEdgeFirst { get; set; }
public CutOff(Vector position, CutOffAxis axis)
{
Position = position;
@@ -35,10 +52,128 @@ namespace OpenNest
)
{
var segments = ComputeSegments(plate, settings, cache);
var program = BuildProgram(segments, settings);
var program = BuildProgram(segments, settings, LockedFarEdge(plate));
Drawing.Program = program;
}
/// <summary>
/// The regenerated program split into its cutting segments, one rapid-and-cut program
/// each in program order, so the plate can sequence every segment on its own. A rapid with
/// the head height locked returns along the overcut and stays in its segment.
/// </summary>
public IReadOnlyList<Program> SegmentPrograms()
{
var program = Drawing.Program;
var result = new List<Program>();
if (program == null)
return result;
Program current = null;
var locked = false;
foreach (var code in program.Codes)
{
if (code is RapidMove && !locked || current == null)
{
current = new Program(program.Mode);
result.Add(current);
}
if (HeadHeightLock.IsLock(code))
locked = true;
else if (HeadHeightLock.IsUnlock(code))
locked = false;
current.Codes.Add(code);
}
return result;
}
/// <summary>
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
/// </summary>
internal static bool FarEdgeIsHigh(CutOffAxis axis, int quadrant) =>
axis == CutOffAxis.Vertical ? quadrant is 1 or 2 : quadrant is 1 or 4;
/// <summary>
/// The nominal far edge this cut-off overcuts with the head locked, along its axis, or null
/// when it has no head-locked overcut or a limit stops it short of that edge.
/// </summary>
private (double Edge, bool High)? LockedFarEdge(Plate plate)
{
if (!(HeadLockOvercut > 0) || !double.IsFinite(HeadLockOvercut))
return null;
var bounds = plate.BoundingBox(includeParts: false);
var high = FarEdgeIsHigh(Axis, plate.Quadrant);
if ((high ? EndLimit : StartLimit) != null)
return null;
var vertical = Axis == CutOffAxis.Vertical;
return (high ? (vertical ? bounds.Top : bounds.Right) : (vertical ? bounds.Bottom : bounds.Left), high);
}
/// <summary>
/// The same segment cut the other way at the far edge: a head-locked segment cut toward the
/// far edge becomes one started there, and back; null when <paramref name="segment"/> is not
/// one of the two forms <see cref="Regenerate"/> builds for the segment reaching that edge.
/// </summary>
public static Program ReverseAtFarEdge(Program segment)
{
if (segment?.Codes == null || segment.Mode != Mode.Absolute)
return null;
var codes = segment.Codes;
var result = new Program(segment.Mode);
// Toward the far edge: rapid near, cut to the edge, lock, overcut, unlock.
if (codes.Count == 5 && codes[0] is RapidMove { Suppressed: false } near
&& codes[1] is LinearMove { Suppressed: false, Layer: LayerType.Cut } toEdge
&& HeadHeightLock.IsLock(codes[2])
&& codes[3] is LinearMove { Suppressed: false, Layer: LayerType.Cut } overcut
&& HeadHeightLock.IsUnlock(codes[4]))
{
result.Codes.Add(new RapidMove(toEdge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Lock));
result.Codes.Add(new LinearMove(overcut.EndPoint));
result.Codes.Add(new RapidMove(toEdge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
result.Codes.Add(new LinearMove(near.EndPoint));
return result;
}
// From the far edge: rapid to the edge, lock, overcut, rapid back, unlock, cut away.
if (codes.Count == 6 && codes[0] is RapidMove { Suppressed: false } edge
&& HeadHeightLock.IsLock(codes[1])
&& codes[2] is LinearMove { Suppressed: false, Layer: LayerType.Cut } outward
&& codes[3] is RapidMove { Suppressed: false } back && back.EndPoint == edge.EndPoint
&& HeadHeightLock.IsUnlock(codes[4])
&& codes[5] is LinearMove { Suppressed: false, Layer: LayerType.Cut } away)
{
result.Codes.Add(new RapidMove(away.EndPoint));
result.Codes.Add(new LinearMove(edge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Lock));
result.Codes.Add(new LinearMove(outward.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
return result;
}
return null;
}
/// <summary>
/// Installs Plan Cutting's choice for the far-edge segment: <paramref name="segment"/> (one
/// of this cut-off's placed parts) takes <paramref name="program"/>, the same segment cut the
/// other way, and <see cref="FarEdgeFirst"/> and the whole-line program follow it, so
/// regeneration keeps the direction. The caller has checked that the program is that flip.
/// </summary>
internal void InstallFarEdgeSegment(Part segment, Program program)
{
var codes = new List<ICode>();
foreach (var part in SegmentPrograms())
codes.AddRange((ReverseAtFarEdge(part) != null ? program : part).Codes);
var whole = new Program(Drawing.Program?.Mode ?? Mode.Absolute);
whole.Codes.AddRange(codes);
segment.ReplaceCutOffProgram(program);
FarEdgeFirst = StartsAtFarEdge(program);
Drawing.Program = whole;
}
/// <summary>Whether <paramref name="segment"/> is the far-edge segment cut from the far edge.</summary>
public static bool StartsAtFarEdge(Program segment) =>
segment?.Codes is { Count: 6 } codes && codes[0] is RapidMove && HeadHeightLock.IsLock(codes[1]);
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
internal (double Length, int SegmentCount) MeasureSegments(
Plate plate,
@@ -73,13 +208,20 @@ namespace OpenNest
Vector? position = null;
double? low = null, high = null;
var cutting = false;
var locked = false;
foreach (var code in part.Program.Codes)
{
if (HeadHeightLock.IsLock(code))
locked = true;
else if (HeadHeightLock.IsUnlock(code))
locked = false;
switch (code)
{
case RapidMove rapid:
if (cutting)
return false; // A second pierce leaves an uncut gap.
// A second pierce leaves an uncut gap; a head-locked rapid returns along
// the overcut to where the cut toward the origin begins.
if (cutting && !locked)
return false;
position = rapid.EndPoint + part.Location;
break;
case LinearMove move when position != null:
@@ -119,6 +261,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;
double lineStart,
lineEnd,
@@ -128,13 +272,13 @@ namespace OpenNest
{
cutPosition = Position.X;
lineStart = StartLimit ?? bounds.Y;
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
}
else
{
cutPosition = Position.Y;
lineStart = StartLimit ?? bounds.X;
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
}
var exclusions = new List<(double Start, double End)>();
@@ -436,7 +580,8 @@ namespace OpenNest
private Program BuildProgram(
List<(double Start, double End)> segments,
CutOffSettings settings
CutOffSettings settings,
(double Edge, bool High)? farEdge
)
{
var program = new Program();
@@ -453,6 +598,22 @@ namespace OpenNest
foreach (var seg in segments)
{
if (farEdge is var (edge, high) && (high ? seg.End : seg.Start) == edge)
{
// The segment reaching the far edge overcuts it with the head height locked,
// whichever way the rest of the line runs.
var near = MakePoint(cutPos, high ? seg.Start : seg.End);
var atEdge = MakePoint(cutPos, edge);
var beyond = MakePoint(cutPos, high ? edge + HeadLockOvercut : edge - HeadLockOvercut);
var towardEdge = new Program();
towardEdge.Codes.Add(new RapidMove(near));
towardEdge.Codes.Add(new LinearMove(atEdge));
towardEdge.Codes.Add(new Comment(HeadHeightLock.Lock));
towardEdge.Codes.Add(new LinearMove(beyond));
towardEdge.Codes.Add(new Comment(HeadHeightLock.Unlock));
program.Codes.AddRange((FarEdgeFirst ? ReverseAtFarEdge(towardEdge) : towardEdge).Codes);
continue;
}
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
+98
View File
@@ -0,0 +1,98 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
/// segment before it (or the line's start) to the start of the one after it (or the line's
/// end). The parts the nominal line passes through lie in the gaps between segments, so each
/// is inside the span of the segment on either side of it; a segment is cut before exactly
/// those parts.
/// </summary>
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
/// <summary>
/// A cut-off's trimmed segments are materialized as one part each, so every segment has its
/// own place in the cut sequence. This finds those parts and the span each stands for.
/// </summary>
public static class CutOffSegments
{
/// <summary>The definition's placed segment parts in ascending order along its axis.</summary>
public static IReadOnlyList<CutOffSegment> Of(Plate plate, CutOff definition) =>
Of(definition, plate.Parts, plate.BoundingBox(includeParts: false));
/// <summary>
/// The definition's segment parts among <paramref name="parts"/> (those placed from its
/// drawing) in ascending order along its axis, with spans measured against the nominal
/// line's limits on <paramref name="sheet"/>. A part whose cut cannot be measured stands
/// for the whole line.
/// </summary>
public static IReadOnlyList<CutOffSegment> Of(CutOff definition, IEnumerable<Part> parts, Box sheet)
{
var vertical = definition.Axis == CutOffAxis.Vertical;
var lineStart = definition.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left);
var lineEnd = definition.EndLimit ?? (vertical ? sheet.Top : sheet.Right);
var low = System.Math.Min(lineStart, lineEnd);
var high = System.Math.Max(lineStart, lineEnd);
var measured = new List<(Part Part, double Start, double End)>();
var whole = new List<CutOffSegment>();
foreach (var part in parts)
{
if (!ReferenceEquals(part.BaseDrawing, definition.Drawing))
continue;
if (Extent(part, vertical) is { } extent)
measured.Add((part, extent.Start, extent.End));
else
whole.Add(new CutOffSegment(part, low, high, low, high));
}
measured.Sort((a, b) => a.Start != b.Start ? a.Start.CompareTo(b.Start) : a.End.CompareTo(b.End));
var result = new List<CutOffSegment>(measured.Count + whole.Count);
for (var i = 0; i < measured.Count; i++)
{
var (part, start, end) = measured[i];
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
}
result.AddRange(whole);
return result;
}
/// <summary>
/// The extent along the cut-off's axis of the part's cutting moves, or null when the
/// part cuts nothing or its program is not the absolute rapid-and-cut form cut-offs use.
/// </summary>
public static (double Start, double End)? Extent(Part part, bool vertical)
{
if (part?.Program == null || part.Program.Mode != Mode.Absolute)
return null;
double? min = null, max = null;
Vector? position = null;
foreach (var code in part.Program.Codes)
{
switch (code)
{
case RapidMove rapid:
position = rapid.EndPoint + part.Location;
break;
case Motion motion when position != null:
var end = motion.EndPoint + part.Location;
foreach (var point in new[] { position.Value, end })
{
var along = vertical ? point.Y : point.X;
min = min == null ? along : System.Math.Min(min.Value, along);
max = max == null ? along : System.Math.Max(max.Value, along);
}
position = end;
break;
case Motion:
return null;
}
}
return min == null ? null : (min.Value, max.Value);
}
}
}
+13
View File
@@ -12,5 +12,18 @@ namespace OpenNest
public double Overtravel { get; set; }
public double MinSegmentLength { get; set; } = 0.05;
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
/// <summary>
/// Whether new cut-offs lock the head height and overcut past the nominal far sheet edge
/// by <see cref="HeadLockOvercut"/> (see <see cref="CutOff.HeadLockOvercut"/>). Off until
/// the machine's post has head-height lock codes.
/// </summary>
public bool LockHeadHeight { get; set; }
/// <summary>How far past the nominal far edge a head-locked cut-off cuts, in model units.</summary>
public double HeadLockOvercut { get; set; } = 1.5;
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
}
}
@@ -28,12 +28,17 @@ public static class ClampCutDiagnostics
var cut = plate.CutOffs[index];
if (cut == null || !Enum.IsDefined(cut.Axis))
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
// A head-locked overcut runs on past the far edge, where an off-sheet clamp can be.
var overcut = cut.HeadLockOvercut > 0 && double.IsFinite(cut.HeadLockOvercut) ? cut.HeadLockOvercut : 0;
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
var low = farHigh ? 0 : overcut;
var high = farHigh ? overcut : 0;
var start = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
var end = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
foreach (var zone in zones)
if (Intersects(start, end, zone.Bounds, out var location))
findings.Add(new CutOffClampFinding(index, zone.Id, location));
@@ -12,7 +12,7 @@ public sealed class OverlapGeometryStamp
private readonly Plate plate;
private readonly Entry[] entries;
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, long Overcut)[] cutoffs;
private readonly (long Width, long Length, int Quadrant) sheet;
private OverlapGeometryStamp(Plate plate)
@@ -25,7 +25,7 @@ public sealed class OverlapGeometryStamp
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
for (var i = 0; i < zones.Length; i++)
zones[i] = ZoneState(plate.ClampZones[i]);
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, long)[plate.CutOffs.Count];
for (var i = 0; i < cutoffs.Length; i++)
cutoffs[i] = CutOffState(plate.CutOffs[i]);
}
@@ -36,11 +36,12 @@ public sealed class OverlapGeometryStamp
zone?.Bounds is not { } box ? default
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
private static (CutOff, CutOffAxis, long, long, long?, long?, long) CutOffState(CutOff cut) =>
cut == null ? default
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
Bits(cut.HeadLockOvercut));
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
+9 -80
View File
@@ -15,20 +15,16 @@ namespace OpenNest.Diagnostics;
/// Nest's layout check finds them: each part's clean nesting material
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin
/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is
/// confirmed on the native contours flattened at <see cref="FineTolerance"/> and reported only when
/// that gap is short of the spacing by more than the slack. Zero spacing enforces nothing, so
/// contact stays valid.
/// circumscribes arcs by up to its chord tolerance, more than the slack, and interactive fills
/// place native arcs exactly at the spacing. So each candidate is confirmed on
/// <see cref="NativeContours"/>, as the layout check confirms it, and reported only when that gap
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
/// valid.
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
/// that check. The sheet edge and clamps are not spacing-checked here.
/// </summary>
internal static class PartSpacingReview
{
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
CancellationToken cancellationToken)
@@ -49,7 +45,7 @@ internal static class PartSpacingReview
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
var profiles = new Dictionary<(Program, long), ShapeProfile>();
var shapes = new ShapeProfile[parts.Count];
var fine = new FineRings[parts.Count];
var fine = new NativeContours[parts.Count];
var raw = new SpacingOutline[parts.Count];
var inflated = new SpacingOutline[parts.Count];
for (var i = 0; i < parts.Count; i++)
@@ -99,9 +95,9 @@ internal static class PartSpacingReview
Vector marker;
try
{
fine[i] ??= FineRings.Of(shapes[i], parts[i].Location);
fine[j] ??= FineRings.Of(shapes[j], parts[j].Location);
gap = Gap(fine[i], fine[j], spacing, out marker);
fine[i] ??= NativeContours.Of(shapes[i], parts[i].Location);
fine[j] ??= NativeContours.Of(shapes[j], parts[j].Location);
gap = fine[i].GapTo(fine[j], spacing, out marker);
}
catch (Exception exception) when (IsInvalid(exception))
{
@@ -148,73 +144,6 @@ internal static class PartSpacingReview
return profile;
}
/// <summary>Closest boundary approach between two parts, including a part inside the other's
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
/// skipped. Infinity when no edges are that near.</summary>
private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
{
var best = double.PositiveInfinity;
var middle = a.Bounds.Center;
var perimeterA = Near(a.Perimeter, b.Bounds, reach);
var perimeterB = Near(b.Perimeter, a.Bounds, reach);
Measure(perimeterA, perimeterB);
foreach (var hole in a.Holes)
Measure(Near(hole, b.Bounds, reach), perimeterB);
foreach (var hole in b.Holes)
Measure(perimeterA, Near(hole, a.Bounds, reach));
marker = middle;
return best;
void Measure(List<Line> ringA, List<Line> ringB)
{
if (ringA.Count == 0 || ringB.Count == 0)
return;
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
if (distance < best)
{
best = distance;
middle = (onA + onB) * 0.5;
}
}
}
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
lines.Where(line =>
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
/// <summary>A part's native contours in world space, flattened at <see cref="FineTolerance"/>.
/// Circumscribed, so the measured gap never exceeds the true one by more than rounding.</summary>
private sealed class FineRings
{
public List<Line> Perimeter { get; private init; }
public List<List<Line>> Holes { get; private init; }
public Box Bounds { get; private init; }
public static FineRings Of(ShapeProfile profile, Vector location)
{
var perimeter = World(profile.Perimeter, location, out var bounds);
return new FineRings
{
Perimeter = perimeter.ToLines(),
Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
Bounds = bounds,
};
}
private static Polygon World(Shape shape, Vector location, out Box bounds)
{
var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
polygon.Offset(location);
polygon.UpdateBounds();
bounds = polygon.BoundingBox;
return polygon;
}
}
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
private static string Name(Part part) => part.BaseDrawing.Name;
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
using OpenNest.Support;
namespace OpenNest.Diagnostics;
/// <summary>
/// Pre-post review of part support (docs/support-tabs.md). Every part whose cutting parameters
/// ask for support tabs and that needs one (<see cref="SupportTabSettings.Analyze"/>: its plate's
/// support bed cannot hold it level or, without a bed, its width is in the no-bed range) must be
/// cut with a tab that keeps it level, however its program was made (planned, placed by hand or
/// locked); without a bed any tab will do. The tab is the gap the outside cut leaves between its end and its start. Reports
/// only; a part whose material or program cannot be read is reported as not checked.
/// </summary>
internal static class PartSupportReview
{
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
CancellationToken token)
{
var bed = plate.SupportBed;
for (var index = 0; index < plate.Parts.Count; index++)
{
token.ThrowIfCancellationRequested();
var part = plate.Parts[index];
var parameters = part?.CuttingParameters ?? plate.CuttingParameters;
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff || parameters?.SupportTabs != true
|| (bed == null && !parameters.HasNoBedTabRange()))
continue;
var partNumber = index + 1;
try
{
var support = SupportTabSettings.Analyze(parameters, bed, plate.Quadrant, SupportMaterial.FromPart(part));
if (support == null)
continue;
if (!support.IsComplete)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
"Support check: " + support.Reason));
continue;
}
if (support.IsSupported)
continue;
var tab = OutsideTab(ExecutionMotionReader.Read(part.Program, part.Location, null, token));
if (tab is { } point && support.IsSteadiedByTabAt(point))
continue;
findings.Add(new(PostVerificationKind.PartSupport, plateNumber, partNumber, null,
Message(support, tab))
{ Location = support.CenterOfGravity });
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
"Support check: " + exception.Message));
}
}
}
/// <summary>
/// The middle of the gap the outside cut leaves open, or null when it closes: the outside cut
/// is the chain of cutting motions with the largest extent.
/// </summary>
internal static Vector? OutsideTab(OwnedExecution execution)
{
Vector? start = null, end = null, bestStart = null, bestEnd = null;
var minX = double.PositiveInfinity; var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity; var maxY = double.NegativeInfinity;
var bestArea = -1.0;
foreach (var motion in execution.Motions.Append(null))
{
if (motion is { Rapid: false, Layer: LayerType.Cut or LayerType.Display })
{
start ??= motion.Start ?? motion.End;
end = motion.End;
foreach (var p in new[] { motion.Start ?? motion.End, motion.End, motion.Curve?.Midpoint ?? motion.End })
{
minX = System.Math.Min(minX, p.X); minY = System.Math.Min(minY, p.Y);
maxX = System.Math.Max(maxX, p.X); maxY = System.Math.Max(maxY, p.Y);
}
continue;
}
// Anything else ends a chain: a rapid, a lead, a scribe or a break cut.
if (start != null && (maxX - minX) * (maxY - minY) > bestArea)
{
bestArea = (maxX - minX) * (maxY - minY);
bestStart = start;
bestEnd = end;
}
start = end = null;
minX = minY = double.PositiveInfinity;
maxX = maxY = double.NegativeInfinity;
}
if (bestStart is not { } first || bestEnd is not { } last
|| first.DistanceTo(last) <= PostVerificationGeometry.Epsilon)
return null;
return (first + last) * 0.5;
}
private static string Message(PartSupport support, Vector? tab)
{
// Without a bed, or for a part its tab alone holds, any tab steadies the part, so the only
// failure is no tab at all.
if (!support.HasBed || support.IsHeldByTab)
return (support.HasBed
? FormattableString.Invariant(
$"The part is {support.Length:0.####} long, short enough for its tab alone to hold, ")
: FormattableString.Invariant(
$"The part is {support.Width:0.####} wide, in the width range tabbed without a support bed, "))
+ "and it has no tab; it may tip once cut free."
+ (support.BestTabPoint is { } suggested
? FormattableString.Invariant($" Tab it near ({suggested.X:0.####}, {suggested.Y:0.####}).")
: "");
var held = support.Margin == double.NegativeInfinity
? "rests on no support contact"
: support.Margin < 0
? FormattableString.Invariant($"has its center of gravity {-support.Margin:0.####} outside its support contacts")
: FormattableString.Invariant($"has its center of gravity only {support.Margin:0.####} inside its support contacts");
var tabText = tab is { } point
? FormattableString.Invariant($"its tab at ({point.X:0.####}, {point.Y:0.####}) does not steady it")
: "it has no tab";
var best = support.BestTabPoint is { } bestPoint && support.TabCanSupport
? FormattableString.Invariant($" A tab near ({bestPoint.X:0.####}, {bestPoint.Y:0.####}) would.")
: " No single tab can steady it.";
return FormattableString.Invariant($"The part {held} (needs {support.RequiredMargin:0.####}) and {tabText}; ")
+ "it may tip once cut free." + best;
}
}
@@ -135,6 +135,11 @@ public static class PostVerificationAnalyzer
if (part == null || !double.IsFinite(part.Rotation))
throw new ArgumentException("Missing part or invalid rotation.");
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
// Executed reads stop at an applied program's display tail, so any display
// perimeter left is an unplanned blank whose outline a post would refuse.
if (moves.Any(move => move.Layer == LayerType.DisplayPerimeter))
Incomplete("This part has a display perimeter (a supplied blank's outline that is never cut) "
+ "but no cutting plan; posts refuse it. Run Plan Cutting so only its cutouts are cut.");
if (expectsCuts && !moves.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
@@ -219,6 +224,8 @@ public static class PostVerificationAnalyzer
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
// Parts that do not overlap may still be closer than the plate's part spacing.
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
// Parts the support bed cannot hold level need a tab that keeps them level.
PartSupportReview.Add(plate, plateNumber, findings, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
return new PostVerificationReport(findings);
@@ -78,7 +78,7 @@ internal static class PostVerificationGeometry
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
// (including the empty extent's) fail this test too.
private bool IsWellConditioned =>
internal bool IsWellConditioned =>
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
}
@@ -110,17 +110,56 @@ internal static class PostVerificationGeometry
// If there are no contacts except possibly departure, all open-segment points
// have the same inside/outside status. A midpoint catches travel entirely inside
// and departure into the interior, without flagging start-only outward contact.
var midpoint = start + delta * 0.5;
return Inside(start + delta * 0.5, curves, token);
}
/// <summary>
/// Ray parity of <paramref name="point"/> against closed <paramref name="curves"/>, cast at
/// <see cref="RayY"/> so no vertex of them lies on the ray.
/// </summary>
internal static bool Inside(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
{
var rayY = RayY(point, curves, token);
var inside = false;
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
if (curve.CrossesRay(midpoint))
if (curve.CrossesRay(point, rayY))
inside = !inside;
}
return inside;
}
/// <summary>
/// The height a parity ray from <paramref name="point"/> runs at: just above it, by about
/// <see cref="RaySeamTolerance"/>, and clear of every vertex height of <paramref name="curves"/>.
/// The two copies of a seam vertex can differ in the last bits; a ray running between them
/// counts one edge and not the other, so a point that happens to sit a tolerance below another
/// contour's vertex (text baselines a micro-inch apart) would read as inside it.
/// </summary>
internal static double RayY(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
{
const int steps = 8;
const double band = RaySeamTolerance / (4 * steps);
for (var step = 0; step < steps; step++)
{
var rayY = point.Y + RaySeamTolerance * (1 + (double)step / steps);
var clear = true;
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
if (curve.HasVertexNear(rayY, band))
{
clear = false;
break;
}
}
if (clear)
return rayY;
}
return point.Y + RaySeamTolerance;
}
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
IReadOnlyList<Curve> curves, CancellationToken token)
{
@@ -279,6 +318,18 @@ internal static class PostVerificationGeometry
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
/// <summary>The native point of this finite curve closest to <paramref name="point"/>.</summary>
internal Vector ClosestPoint(Vector point) => nativeEntity.Value.ClosestPointTo(point);
/// <summary>
/// This curve from its start up to <paramref name="point"/>, which must be one of its own
/// interior points (see <see cref="ClosestPoint"/>); an arc keeps its center and radius.
/// </summary>
internal Curve Until(Vector point) => Center is { } center
? new Curve(Start, point, center, Radius,
System.Math.Sign(Sweep) * Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)))
: new Curve(Start, point, null, 0, 0);
internal bool Contains(Vector point) => Contains(point, Epsilon);
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
@@ -455,7 +506,20 @@ internal static class PostVerificationGeometry
}
}
internal bool CrossesRay(Vector point)
/// <summary>
/// Whether an endpoint, or the top or bottom of an arc's circle, lies within
/// <paramref name="band"/> of height <paramref name="y"/>.
/// </summary>
internal bool HasVertexNear(double y, double band) =>
System.Math.Abs(Start.Y - y) <= band || System.Math.Abs(End.Y - y) <= band
|| Center is { } center && (System.Math.Abs(center.Y + Radius - y) <= band
|| System.Math.Abs(center.Y - Radius - y) <= band);
/// <summary>
/// Whether a ray running in +X from <paramref name="point"/>, at height
/// <paramref name="rayY"/> (see <see cref="RayY"/>), crosses this curve.
/// </summary>
internal bool CrossesRay(Vector point, double rayY)
{
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
@@ -466,7 +530,6 @@ internal static class PostVerificationGeometry
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
// still count. Containment of a point that itself lies within the tolerance of a
// boundary edge stays ambiguous by design, exactly as it was before.
var rayY = point.Y + RaySeamTolerance;
if (Center is not { } center)
return (Start.Y > rayY) != (End.Y > rayY)
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
@@ -34,7 +34,13 @@ public enum PostVerificationKind
/// Two parts are closer than the plate's part spacing without sharing material, judged as
/// Auto Nest's layout check judges them.
/// </summary>
PartSpacing
PartSpacing,
/// <summary>
/// A part its plate's support bed cannot hold level has no tab that keeps it level, so it may
/// tip once cut free (see docs/support-tabs.md).
/// </summary>
PartSupport
}
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
@@ -84,6 +90,8 @@ public sealed class PostVerificationReport
Summary(PostVerificationKind.PierceZone, "Pierce zones");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
Summary(PostVerificationKind.PartSpacing, "Part spacing");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSupport))
Summary(PostVerificationKind.PartSupport, "Part support");
foreach (var finding in Findings)
{
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
+107
View File
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Geometry
{
/// <summary>
/// A part's native contours in world space, flattened at <see cref="ChordTolerance"/>, to
/// confirm a part-spacing candidate that <see cref="SpacingOutline"/> found. The outline
/// circumscribes arcs by up to its chord tolerance, more than <see cref="SpacingOutline.SpacingSlack"/>,
/// so two convex arcs facing each other exactly at the spacing can overlap there; these
/// contours measure that gap to within a tenth of the slack. Every spacing verdict (Auto
/// Nest's layout check, the whole-job runner's candidate validation and the pre-post review)
/// confirms with them, so none can disagree.
/// </summary>
public sealed class NativeContours
{
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
public const double ChordTolerance = SpacingOutline.SpacingSlack / 20;
private Polygon perimeter;
private List<Polygon> holes;
private List<Line> perimeterLines;
private List<List<Line>> holeLines;
private NativeContours() { }
public Box Bounds { get; private set; }
/// <summary>Flattens <paramref name="profile"/> (already rotated) at <paramref name="location"/>.</summary>
public static NativeContours Of(ShapeProfile profile, Vector location)
{
var perimeter = World(profile.Perimeter, location);
var holes = profile.Cutouts.Select(cutout => World(cutout, location)).ToList();
return new NativeContours
{
perimeter = perimeter,
holes = holes,
perimeterLines = perimeter.ToLines(),
holeLines = holes.Select(hole => hole.ToLines()).ToList(),
Bounds = perimeter.BoundingBox,
};
}
/// <summary>
/// True when the two parts are closer than <paramref name="spacing"/> less
/// <see cref="SpacingOutline.SpacingSlack"/>, or share material: a boundary within that
/// gap, or one part wholly inside the other's material rather than in a cutout.
/// </summary>
public bool TooClose(NativeContours other, double spacing)
{
if (GapTo(other, spacing, out _) < spacing - SpacingOutline.SpacingSlack)
return true;
// No boundaries meet, so either part is wholly inside or outside the other's material.
return other.InMaterial(perimeter.Vertices[0]) || InMaterial(other.perimeter.Vertices[0]);
}
/// <summary>Closest boundary approach between two parts, including a part inside the other's
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
/// skipped. Infinity when no edges are that near.</summary>
public double GapTo(NativeContours other, double reach, out Vector marker)
{
var best = double.PositiveInfinity;
var middle = Bounds.Center;
var perimeterA = Near(perimeterLines, other.Bounds, reach);
var perimeterB = Near(other.perimeterLines, Bounds, reach);
Measure(perimeterA, perimeterB);
foreach (var hole in holeLines)
Measure(Near(hole, other.Bounds, reach), perimeterB);
foreach (var hole in other.holeLines)
Measure(perimeterA, Near(hole, Bounds, reach));
marker = middle;
return best;
void Measure(List<Line> ringA, List<Line> ringB)
{
if (ringA.Count == 0 || ringB.Count == 0)
return;
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
if (distance < best)
{
best = distance;
middle = (onA + onB) * 0.5;
}
}
}
private bool InMaterial(Vector point) =>
perimeter.ContainsPoint(point) && !holes.Any(hole => hole.ContainsPoint(point));
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
lines.Where(line =>
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
private static Polygon World(Shape shape, Vector location)
{
var polygon = ClipperBridge.Flatten(shape, ChordTolerance, true);
polygon.Offset(location);
polygon.UpdateBounds();
return polygon;
}
}
}
+21 -4
View File
@@ -519,16 +519,33 @@ namespace OpenNest.Geometry
get { return EntityType.Polygon; }
}
/// <summary>
/// Drops each vertex equal to the one before it in the original sequence. Compacts in
/// place in one pass: removing duplicates one at a time from the middle of the list was
/// O(duplicates x vertices), which dominated loading large drawings whose arcs are
/// flattened to many points.
/// </summary>
internal void Cleanup()
{
for (int i = Vertices.Count - 1; i > 0; i--)
if (Vertices.Count < 2)
return;
// Compare against the original predecessor, not the last kept vertex, so a run of
// tolerance-equal points collapses exactly as the old back-to-front RemoveAt loop did.
var previous = Vertices[0];
var kept = 1;
for (int i = 1; i < Vertices.Count; i++)
{
var vertex = Vertices[i];
var nextVertex = Vertices[i - 1];
if (vertex == nextVertex)
Vertices.RemoveAt(i);
if (vertex != previous)
Vertices[kept++] = vertex;
previous = vertex;
}
Vertices.RemoveRange(kept, Vertices.Count - kept);
}
public BoundingRectangleResult FindBestRotation()
+9 -6
View File
@@ -47,14 +47,16 @@ namespace OpenNest.Geometry
return new BoundingRectangleResult(angle, length, 0);
}
// Only need half the edges — opposite edges produce the same
// bounding rectangle (width/height swapped), so the minimum
// area repeats every 180°.
var edgeCount = (n + 1) / 2;
// Every edge is a candidate: opposite edges only give the same rectangle
// when the hull is centrally symmetric. For those hulls (rectangles,
// best-fit pairs) the second half repeats the first turned 180°, so a
// second-half edge wins only when smaller beyond rounding; that keeps
// their orientation from flipping on floating-point noise.
var half = (n + 1) / 2;
BoundingRectangleResult best = null;
for (int i = 0; i < edgeCount; i++)
for (int i = 0; i < n; i++)
{
int next = (i + 1) % n;
var edgeX = vertices[next].X - vertices[i].X;
@@ -94,8 +96,9 @@ namespace OpenNest.Geometry
var width = maxU - minU;
var height = maxV - minV;
var area = width * height;
var margin = i < half || best == null ? 0 : Tolerance.Epsilon * best.Area;
if (best == null || area < best.Area)
if (best == null || area < best.Area - margin)
{
var edgeAngle = System.Math.Atan2(edgeY, edgeX);
best = new BoundingRectangleResult(edgeAngle, width, height);
+17 -3
View File
@@ -264,10 +264,24 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Converts the shape to a polygon.
/// Maximum chord error, in drawing units, when <see cref="ToPolygon()"/> flattens arcs
/// and circles. Ten times tighter than the spacing/overlap checks' 0.001 because callers
/// use this polygon for area, and an inscribed polygon always reads low.
/// </summary>
/// <returns></returns>
public Polygon ToPolygon(int arcSegments = 1000)
public const double DefaultChordTolerance = 0.0001;
/// <summary>
/// Converts the shape to a polygon, flattening arcs to within
/// <see cref="DefaultChordTolerance"/>. A fixed segment count per arc (formerly 1000)
/// made large arcs no more accurate and small ones needlessly dense: arc-heavy drawings
/// produced hundreds of thousands of vertices every time their area was computed.
/// </summary>
public Polygon ToPolygon() => ToPolygonWithTolerance(DefaultChordTolerance);
/// <summary>
/// Converts the shape to a polygon with a fixed number of segments per arc and circle.
/// </summary>
public Polygon ToPolygon(int arcSegments)
{
var polygon = new Polygon();
+7 -2
View File
@@ -36,6 +36,11 @@ namespace OpenNest.Geometry
// tangential classification can admit, so keep it far below spacing tolerances.
internal const double IncidenceTolerance = 1e-7;
// Drawing data fixes arc centers to finite precision, so a tangent joint's arc end
// can sit a little past its neighbour's end. A contact this close to a joint takes
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
internal const double JointTolerance = 1e-6;
private readonly List<Entity> entities;
private readonly int[] loopOf;
private readonly int[] previous;
@@ -254,11 +259,11 @@ namespace OpenNest.Geometry
private bool StartsNear(int index, Vector point) =>
TryEndpoints(entities[index], out var start, out _)
&& start.DistanceTo(point) <= IncidenceTolerance;
&& start.DistanceTo(point) <= JointTolerance;
private bool EndsNear(int index, Vector point) =>
TryEndpoints(entities[index], out _, out var end)
&& end.DistanceTo(point) <= IncidenceTolerance;
&& end.DistanceTo(point) <= JointTolerance;
private bool InRun(int index, int first, int last)
{
+2 -2
View File
@@ -64,8 +64,8 @@ namespace OpenNest.Geometry
public static SpacingOutline Build(ShapeProfile profile, Vector location, double inflateBy)
{
ArgumentNullException.ThrowIfNull(profile);
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 segments per
// arc): arc-heavy real parts otherwise produce thousands of vertices.
// Flatten by chord tolerance, not a fixed segment count: arc-heavy real parts
// otherwise produce thousands of vertices.
var region = ClipperBridge.OffsetForValidation(
profile,
inflateBy > Tolerance.Epsilon ? inflateBy : 0,
+19
View File
@@ -0,0 +1,19 @@
namespace OpenNest
{
/// <summary>
/// A configurable post whose settings travel as JSON, so a host can keep them somewhere other
/// than the post's own file (the nest server's shared copy, for one).
/// </summary>
public interface IJsonConfigPostProcessor : IConfigurablePostProcessor
{
/// <summary>The settings as the post's own file holds them.</summary>
string SerializeConfig();
/// <summary>
/// Replaces the settings with <paramref name="json"/>, in the post's file format; the
/// file itself is not written. Throws <see cref="System.Text.Json.JsonException"/> and
/// keeps the current settings when the JSON is not usable.
/// </summary>
void LoadConfig(string json);
}
}
+80 -2
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using OpenNest.Collections;
using OpenNest.Geometry;
using OpenNest.Support;
namespace OpenNest
{
@@ -18,6 +19,7 @@ namespace OpenNest
Name = name;
Plates = new ObservableList<Plate>();
Plates.ItemRemoved += Plates_PlateRemoved;
Plates.ItemAdded += Plates_PlateAdded;
Drawings = new DrawingCollection();
PlateDefaults = new PlateSettings();
Material = new Material();
@@ -30,6 +32,13 @@ namespace OpenNest
e.Item.Parts.Clear();
}
// Every sheet of the nest rests on the same machine bed.
private void Plates_PlateAdded(object sender, ItemAddedEventArgs<Plate> e)
{
if (e.Item != null)
e.Item.SupportBed = SupportBed;
}
public string Name { get; set; }
public string Customer { get; set; }
@@ -42,12 +51,57 @@ namespace OpenNest
public string MadeBy { get; set; } = "";
public double Thickness { get; set; }
/// <summary>The stock thickness. A different value clears every plate's <see cref="Plate.StockItemCodes"/>.</summary>
public double Thickness
{
get { return thickness; }
set
{
if (value != thickness)
ClearStockItemCodes();
thickness = value;
}
}
public Material Material { get; set; }
private double thickness;
/// <summary>
/// The stock material. A different name or grade (ignoring case and spacing) clears every
/// plate's <see cref="Plate.StockItemCodes"/>: the coded items are another material.
/// </summary>
public Material Material
{
get { return material; }
set
{
if (!SameStock(material, value))
ClearStockItemCodes();
material = value;
}
}
private Material material;
private static bool SameStock(Material a, Material b) =>
NameKey(a?.Name) == NameKey(b?.Name) && NameKey(a?.Grade) == NameKey(b?.Grade);
private static string NameKey(string name) =>
name == null ? "" : string.Join(' ', name.Split((char[])null, StringSplitOptions.RemoveEmptyEntries)).ToUpperInvariant();
private void ClearStockItemCodes()
{
// Plates is a public field a caller may have replaced.
if (Plates == null)
return;
foreach (var plate in Plates)
plate.StockItemCodes = Array.Empty<string>();
}
public Units Units { get; set; }
/// <summary>The machine this nest is cut on, or null when none is assigned.</summary>
public NestMachine Machine { get; set; }
public DateTime DateCreated { get; set; }
public DateTime DateLastModified { get; set; }
@@ -58,6 +112,16 @@ namespace OpenNest
public double SalvageRate { get; set; } = 0.5;
/// <summary>
/// The smallest side, in model units, of an offcut credited as salvage or reported as the
/// plate's expected remnant (<see cref="PlateRemnant.Expected"/>). Auto Nest records the
/// minimum it last used.
/// </summary>
public double MinimumRemnantDimension { get; set; } = DefaultMinimumRemnantDimension;
/// <summary>Auto Nest's default minimum remnant side.</summary>
public const double DefaultMinimumRemnantDimension = 12.0;
public Plate CreatePlate()
{
var plate = PlateDefaults.CreateNew();
@@ -80,6 +144,20 @@ namespace OpenNest
plate.SetClampRail(rail);
}
/// <summary>
/// The machine support bed every sheet of this nest rests on, or null for none. Set
/// through <see cref="SetSupportBed"/>; plates added later follow it too.
/// </summary>
public SupportBed SupportBed { get; private set; }
/// <summary>Makes <paramref name="bed"/> this nest's support bed and every plate's.</summary>
public void SetSupportBed(SupportBed bed)
{
SupportBed = bed;
foreach (var plate in Plates)
plate.SupportBed = bed;
}
public void UpdateDrawingQuantities()
{
foreach (var drawing in Drawings)
+21
View File
@@ -0,0 +1,21 @@
using System;
namespace OpenNest
{
/// <summary>
/// The machine a nest is cut on: the id of its machine configuration plus the name it had,
/// so a nest still says which machine it was made for on a PC without that configuration.
/// </summary>
public sealed class NestMachine
{
public NestMachine(Guid id, string name)
{
Id = id;
Name = name ?? string.Empty;
}
public Guid Id { get; }
public string Name { get; }
}
}
+33
View File
@@ -4,10 +4,43 @@ namespace OpenNest
/// Shop-floor workflow state of a saved nest, persisted as a string in
/// <c>nest.json</c> so unknown future values can fall back safely.
/// </summary>
/// <remarks>
/// New values go at the end: the nest server still accepts integer statuses from
/// existing callers, so existing values must keep their numbers. Lists shown to the
/// operator use <see cref="NestStatuses.WorkflowOrder"/>, not declaration order.
/// </remarks>
public enum NestStatus
{
Quote,
ToBeCut,
HasBeenCut,
/// <summary>
/// Created but not yet ready to quote or cut, such as a nest released from a
/// drawing set before it has been nested and checked. Comes before Quote.
/// </summary>
Draft,
}
public static class NestStatuses
{
/// <summary>Every status in the order a nest moves through them.</summary>
public static readonly NestStatus[] WorkflowOrder =
{
NestStatus.Draft,
NestStatus.Quote,
NestStatus.ToBeCut,
NestStatus.HasBeenCut,
};
/// <summary>The name shown to the operator, such as "To Be Cut".</summary>
public static string DisplayName(NestStatus status) => status switch
{
NestStatus.Draft => "Draft",
NestStatus.Quote => "Quote",
NestStatus.ToBeCut => "To Be Cut",
NestStatus.HasBeenCut => "Has Been Cut",
_ => status.ToString(),
};
}
}
+19
View File
@@ -45,6 +45,15 @@ namespace OpenNest
UpdateBounds();
}
/// <summary>A placement of one owned program of the drawing, such as one cut-off segment.</summary>
internal Part(Drawing baseDrawing, Program program)
{
BaseDrawing = baseDrawing;
Program = program;
ownsProgram = true;
UpdateBounds();
}
/// <summary>
/// Location of the part.
/// </summary>
@@ -177,6 +186,16 @@ namespace OpenNest
BoundingBox = bounds;
}
/// <summary>Installs an owned program for a cut-off segment, cut the other way; nothing else changes.</summary>
internal void ReplaceCutOffProgram(Program program)
{
var bounds = program.BoundingBox();
bounds.Offset(Location);
Program = program;
ownsProgram = true;
BoundingBox = bounds;
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
+140 -30
View File
@@ -6,6 +6,7 @@ using OpenNest.Collections;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
using OpenNest.Support;
namespace OpenNest
{
@@ -94,6 +95,9 @@ namespace OpenNest
get { return size; }
set
{
// 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>();
size = value;
ResolveClampRail();
}
@@ -101,6 +105,30 @@ namespace OpenNest
private Size size;
private static bool SameSheet(Size a, Size b) =>
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
/// <summary>
/// The inventory item each physical sheet of this plate is cut from: entry i names sheet
/// i of <see cref="Quantity"/>, and sheets past the end are uncoded. Empty when no sheet
/// is coded. A resize (other than turning the sheet) clears it, lowering
/// <see cref="Quantity"/> truncates it, and raising it leaves the extra sheets uncoded.
/// Display and hand-off data only: quantity accounting never reads it.
/// </summary>
public IReadOnlyList<string> StockItemCodes
{
get { return stockItemCodes; }
set
{
ArgumentNullException.ThrowIfNull(value);
if (value.Any(string.IsNullOrWhiteSpace))
throw new ArgumentException("Stock item codes must not be blank.", nameof(value));
stockItemCodes = value.Take(System.Math.Max(quantity, 0)).ToArray();
}
}
private IReadOnlyList<string> stockItemCodes = Array.Empty<string>();
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
/// <summary>
@@ -130,7 +158,7 @@ namespace OpenNest
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
/// the zone overlay. Carries no geometry arguments.
/// </summary>
public event EventHandler? ClampZonesChanged;
public event EventHandler ClampZonesChanged;
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
@@ -142,6 +170,13 @@ namespace OpenNest
/// </summary>
public ClampRail ClampRail { get; private set; }
/// <summary>
/// The machine support bed this sheet rests on (its nest's, see
/// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support
/// tabs only: never material, spacing or a keep-out.
/// </summary>
public SupportBed SupportBed { get; set; }
private static readonly IReadOnlySet<string> NoRemovedClamps = ImmutableHashSet<string>.Empty;
/// <summary>
@@ -258,17 +293,16 @@ namespace OpenNest
}
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
/// regenerated and put back at the same place in the cut sequence
/// (<see cref="Parts"/> order). New cut-offs are added at the end.
/// Regenerates all cut-off drawings and materializes each of their trimmed segments as
/// a part. Existing cut-off parts are removed first, then each regenerated segment goes
/// back to the place in the cut sequence (<see cref="Parts"/> order) of the saved segment
/// of its cut-off it overlaps most, so a part drag or cut-off move doesn't send it to the
/// end of the sequence; a segment with no saved counterpart follows the segment before
/// it. New cut-offs are added at the end.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings)
{
// Remember each cut-off's place in the cut sequence, so a part drag
// or cut-off move doesn't send it to the end of the sequence.
var sequence = new Dictionary<CutOff, int>();
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (!Parts[i].BaseDrawing.IsCutOff)
@@ -276,44 +310,108 @@ namespace OpenNest
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
if (cutoff != null)
sequence[cutoff] = i;
{
if (!saved.TryGetValue(cutoff, out var segments))
saved[cutoff] = segments = new List<(int, double, double)>();
var extent = CutOffSegments.Extent(Parts[i], cutoff.Axis == CutOffAxis.Vertical);
segments.Add((i, extent?.Start ?? double.NaN, extent?.End ?? double.NaN));
}
Parts.RemoveAt(i);
}
RegenerateCutOffs(settings, sequence);
RegenerateCutOffs(settings, (cutoff, extents) =>
{
if (!saved.TryGetValue(cutoff, out var segments))
return null;
var indices = new int[extents.Count];
var previous = segments.Min(s => s.Index);
for (var k = 0; k < extents.Count; k++)
{
var (start, end) = extents[k];
var best = -1;
var bestOverlap = 0.0;
foreach (var (index, savedStart, savedEnd) in segments)
{
var overlap = System.Math.Min(end, savedEnd) - System.Math.Max(start, savedStart);
if (overlap > bestOverlap)
{
best = index;
bestOverlap = overlap;
}
}
indices[k] = previous = best >= 0 ? best : previous;
}
return indices;
});
}
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts, placing
/// each cut-off at its index in <paramref name="sequence"/> (its place in
/// <see cref="Parts"/> order). Cut-offs missing from it are added at the end.
/// Callers must remove existing cut-off parts first.
/// Regenerates all cut-off drawings and materializes each of their trimmed segments as a
/// part, placing a cut-off's segments consecutively from its index in
/// <paramref name="sequence"/> (its place in <see cref="Parts"/> order). Cut-offs missing
/// 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)
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);
/// <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.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
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;
});
// 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.
private void RegenerateCutOffs(CutOffSettings settings,
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence)
{
var cache = BuildPerimeterCache(this);
var placed = new List<(int Index, Part Part)>();
var placed = new List<(int Index, int Order, Part Part)>();
// Regenerate and materialize each cut-off
// Regenerate and materialize each cut-off's segments
foreach (var cutoff in CutOffs)
{
cutoff.Regenerate(this, settings, cache);
if (cutoff.Drawing.Program.Codes.Count == 0)
var programs = cutoff.SegmentPrograms();
if (programs.Count == 0)
continue;
var part = new Part(cutoff.Drawing);
if (sequence != null && sequence.TryGetValue(cutoff, out var index))
placed.Add((index, part));
else
Parts.Add(part);
var vertical = cutoff.Axis == CutOffAxis.Vertical;
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);
for (var k = 0; k < parts.Length; k++)
{
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.
foreach (var (index, part) in placed.OrderBy(p => p.Index))
// 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);
}
@@ -386,9 +484,21 @@ namespace OpenNest
}
/// <summary>
/// The number of times to cut the plate.
/// The number of times to cut the plate. Lowering it drops the
/// <see cref="StockItemCodes"/> of the sheets no longer cut.
/// </summary>
public int Quantity { get; set; }
public int Quantity
{
get { return quantity; }
set
{
quantity = value;
if (stockItemCodes.Count > System.Math.Max(value, 0))
stockItemCodes = stockItemCodes.Take(System.Math.Max(value, 0)).ToArray();
}
}
private int quantity;
/// <summary>
/// The quadrant the plate is located in.
+1
View File
@@ -236,6 +236,7 @@ namespace OpenNest
remaining.CutOffs.Clear();
remaining.Parts.Clear();
remaining.Quantity = 1;
remaining.StockItemCodes = Array.Empty<string>();
}
finally
{
+6
View File
@@ -6,6 +6,12 @@ namespace OpenNest
public double Length { get; set; }
public double Cost { get; set; }
/// <summary>Sheets on hand for this request; null offers the size without limit. Not saved with the nest.</summary>
public int? Quantity { get; set; }
/// <summary>The engine stock id for this request; null uses the option's position. Not saved with the nest.</summary>
public string Id { get; set; }
public double Area => Width * Length;
}
}
+169
View File
@@ -0,0 +1,169 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest;
/// <summary>Where an expected remnant comes from.</summary>
public enum PlateRemnantSource
{
/// <summary>Material a full-span cut-off cuts free of the parts.</summary>
CutOff,
/// <summary>The largest full-width or full-length edge strip outside the parts, as salvage measures it.</summary>
Offcut,
}
/// <summary>
/// The rectangular drop a plate is expected to leave, in plate coordinates (the quadrant frame
/// of its parts and cut-offs). Derived from the current layout; never stored on the plate.
/// </summary>
public sealed class PlateRemnant
{
private PlateRemnant(Box box, PlateRemnantSource source)
{
Box = box;
Source = source;
}
public Box Box { get; }
public PlateRemnantSource Source { get; }
/// <summary>
/// The drop <paramref name="plate"/> is expected to leave, or null when it has no parts or no
/// rectangle has both sides at least <paramref name="minimumDimension"/>. A full-span cut-off
/// (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.
/// </summary>
public static PlateRemnant Expected(Plate plate, double minimumDimension)
{
// A part with no material (marks only) bounds nothing.
var bounds = plate.Parts
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
.Select(p => ConvertProgram.ToGeometry(p.Program).Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
is { Count: > 0 } material
? material.GetBoundingBox().Translate(p.Location)
: null)
.Where(b => b != null)
.ToList();
if (bounds.Count == 0)
return null;
var minimum = System.Math.Max(0, minimumDimension);
var cutOff = CutOffDrop(plate, bounds, minimum);
if (cutOff != null)
return new PlateRemnant(cutOff, PlateRemnantSource.CutOff);
var offcut = LargestOffcut(plate.WorkArea(), plate.PartSpacing,
bounds.Min(b => b.Left), bounds.Min(b => b.Bottom), bounds.Max(b => b.Right), bounds.Max(b => b.Top),
minimum);
return offcut == null ? null : new PlateRemnant(offcut, PlateRemnantSource.Offcut);
}
/// <summary>
/// The largest full-width/full-length edge strip of <paramref name="workArea"/> clear of the
/// material envelope (<paramref name="left"/> to <paramref name="top"/>) by
/// <paramref name="partSpacing"/>, with both sides at least <paramref name="minimum"/>; null
/// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage
/// offcut whole-job costing credits.
/// </summary>
public static Box LargestOffcut(Box workArea, double partSpacing,
double left, double bottom, double right, double top, double minimum)
{
var work = workArea;
var gap = partSpacing;
var candidates = new[]
{
new Box(work.Left, work.Bottom, work.Length, bottom - work.Bottom - gap),
new Box(work.Left, top + gap, work.Length, work.Top - top - gap),
new Box(work.Left, work.Bottom, left - work.Left - gap, work.Width),
new Box(right + gap, work.Bottom, work.Right - right - gap, work.Width),
};
Box best = null;
foreach (var candidate in candidates)
{
if (candidate.Length >= minimum && candidate.Width >= minimum
&& (best == null || candidate.Length * candidate.Width > best.Length * best.Width))
best = candidate;
}
return best;
}
/// <summary>Analytic world-space material bounds; surface marks never bound material.</summary>
public static Box MaterialBounds(Part part) =>
ConvertProgram.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.GetBoundingBox()
.Translate(part.Location);
/// <summary>
/// The largest piece between full-span cut-off lines (or a line and the sheet edge) that no
/// part material reaches into, with both sides at least <paramref name="minimum"/>.
/// </summary>
private static Box CutOffDrop(Plate plate, IReadOnlyList<Box> material, double minimum)
{
var sheet = plate.BoundingBox(includeParts: false);
var kerf = plate.CuttingParameters?.Kerf is double k && double.IsFinite(k) && k > 0 ? k : 0;
Box best = null;
foreach (var vertical in new[] { true, false })
{
var low = vertical ? sheet.Left : sheet.Bottom;
var high = vertical ? sheet.Right : sheet.Top;
var lines = plate.CutOffs
.Where(c => (c.Axis == CutOffAxis.Vertical) == vertical && SpansSheet(c, sheet))
.Select(c => vertical ? c.Position.X : c.Position.Y)
.Where(at => at > low + Tolerance.Epsilon && at < high - Tolerance.Epsilon)
.Distinct()
.OrderBy(at => at)
.ToList();
if (lines.Count == 0)
continue;
// Each piece runs from one boundary to the next; at least one is a cut-off line.
var boundaries = new List<(double At, bool Cut)> { (low, false) };
boundaries.AddRange(lines.Select(at => (at, true)));
boundaries.Add((high, false));
for (var i = 0; i + 1 < boundaries.Count; i++)
{
var (from, fromCut) = boundaries[i];
var (to, toCut) = boundaries[i + 1];
if (material.Any(b => Reaches(b, vertical, from, to)))
continue;
var start = fromCut ? from + kerf : from;
var end = toCut ? to - kerf : to;
var piece = vertical
? new Box(start, sheet.Bottom, end - start, sheet.Width)
: new Box(sheet.Left, start, sheet.Length, end - start);
if (piece.Length >= minimum && piece.Width >= minimum
&& (best == null || piece.Length * piece.Width > best.Length * best.Width))
best = piece;
}
}
return best;
}
/// <summary>
/// Whether the nominal line runs edge to edge. A head-locked overcut only extends the
/// program past the far edge, so the nominal limits decide.
/// </summary>
private static bool SpansSheet(CutOff cutOff, Box sheet)
{
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var alongMin = vertical ? sheet.Bottom : sheet.Left;
var alongMax = vertical ? sheet.Top : sheet.Right;
return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon)
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon);
}
private static bool Reaches(Box material, bool vertical, double from, double to)
{
var low = vertical ? material.Left : material.Bottom;
var high = vertical ? material.Right : material.Top;
return high > from + Tolerance.Epsilon && low < to - Tolerance.Epsilon;
}
}
@@ -35,6 +35,16 @@ namespace OpenNest.PostSettings
/// <summary>Column header for the value of a name/value table.</summary>
public string ValueHeader { get; set; }
/// <summary>
/// For a text field: the record table property whose column, named by
/// <see cref="SuggestionsColumn"/>, lists the values offered in a drop-down.
/// The field still accepts any text.
/// </summary>
public string SuggestionsTable { get; set; }
/// <summary>The column of <see cref="SuggestionsTable"/> offered as suggestions.</summary>
public string SuggestionsColumn { get; set; }
}
/// <summary>Declares a settings section's order and description.</summary>
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Reflection;
@@ -14,6 +15,29 @@ namespace OpenNest.PostSettings
Boolean,
Choice,
StringMap,
Table,
}
/// <summary>A column of a <see cref="PostSettingKind.Table"/> field: one property of its row type.</summary>
public sealed class PostSettingsColumn
{
internal PostSettingsColumn(PropertyInfo property, PostSettingKind kind)
{
Property = property;
Kind = kind;
Label =
property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName
?? property.Name;
}
public PropertyInfo Property { get; }
public string Name => Property.Name;
/// <summary>Text, Integer or Decimal.</summary>
public PostSettingKind Kind { get; }
public string Label { get; }
}
public sealed class PostSettingsField
@@ -21,11 +45,17 @@ namespace OpenNest.PostSettings
internal PostSettingsField(
PropertyInfo property,
PostSettingKind kind,
bool isOptional,
IReadOnlyList<PostSettingsColumn> columns,
PostSettingAttribute setting
)
{
Property = property;
Kind = kind;
IsOptional = isOptional;
Columns = columns ?? Array.Empty<PostSettingsColumn>();
SuggestionsTable = setting?.SuggestionsTable;
SuggestionsColumn = setting?.SuggestionsColumn;
Label =
property.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName
?? property.Name;
@@ -48,6 +78,17 @@ namespace OpenNest.PostSettings
public PostSettingKind Kind { get; }
/// <summary>A nullable number: the editor can leave it unset (null).</summary>
public bool IsOptional { get; }
/// <summary>The row type's columns for a <see cref="PostSettingKind.Table"/> field.</summary>
public IReadOnlyList<PostSettingsColumn> Columns { get; }
/// <summary>Table field whose <see cref="SuggestionsColumn"/> a text field offers; null for none.</summary>
public string SuggestionsTable { get; }
public string SuggestionsColumn { get; }
public string Label { get; }
public string Description { get; }
@@ -133,7 +174,7 @@ namespace OpenNest.PostSettings
for (var i = 0; i < properties.Count; i++)
{
var property = properties[i];
var kind = KindOf(property.PropertyType);
var kind = KindOf(property.PropertyType, out var isOptional, out var columns);
if (kind == null)
return null;
@@ -146,7 +187,7 @@ namespace OpenNest.PostSettings
section,
setting?.Order ?? int.MaxValue,
i,
new PostSettingsField(property, kind.Value, setting)
new PostSettingsField(property, kind.Value, isOptional, columns, setting)
)
);
}
@@ -207,6 +248,126 @@ namespace OpenNest.PostSettings
return map;
}
/// <summary>
/// Builds a <see cref="PostSettingKind.Table"/> field's list from edited
/// rows of cell text, one entry per column, in row order. Cells are
/// trimmed and numbers parsed with <paramref name="culture"/>; fully blank
/// rows are skipped. Throws <see cref="FormatException"/> naming the
/// 1-based row and column for a missing or malformed number.
/// </summary>
public static object BuildList(
PostSettingsField field,
IEnumerable<IReadOnlyList<string>> rows,
IFormatProvider culture
)
{
if (field == null || field.Kind != PostSettingKind.Table)
throw new ArgumentException("A table field is required.", nameof(field));
var list = (System.Collections.IList)Activator.CreateInstance(field.Property.PropertyType);
var rowType = ElementType(field.Property.PropertyType);
var row = 0;
foreach (var cells in rows ?? Enumerable.Empty<IReadOnlyList<string>>())
{
row++;
var text = field
.Columns.Select((_, c) => (cells != null && c < cells.Count ? cells[c] : null)?.Trim() ?? "")
.ToList();
if (text.All(t => t.Length == 0))
continue;
var item = Activator.CreateInstance(rowType);
for (var c = 0; c < field.Columns.Count; c++)
{
var column = field.Columns[c];
column.Property.SetValue(item, ParseCell(column, text[c], row, culture));
}
list.Add(item);
}
return list;
}
private static object ParseCell(PostSettingsColumn column, string text, int row, IFormatProvider culture)
{
if (column.Kind == PostSettingKind.Text)
return text;
if (text.Length == 0)
throw new FormatException($"Row {row}: {column.Label} is required.");
if (column.Kind == PostSettingKind.Integer)
{
if (int.TryParse(text, NumberStyles.Integer, culture, out var whole))
return whole;
}
else if (double.TryParse(text, NumberStyles.Float, culture, out var number) && double.IsFinite(number))
{
return number;
}
throw new FormatException($"Row {row}: {column.Label} \"{text}\" is not a valid number.");
}
private static PostSettingKind? KindOf(
Type type,
out bool isOptional,
out IReadOnlyList<PostSettingsColumn> columns
)
{
isOptional = false;
columns = null;
var underlying = Nullable.GetUnderlyingType(type);
if (underlying == typeof(int) || underlying == typeof(double))
{
isOptional = true;
return KindOf(underlying);
}
var rowType = ElementType(type);
if (rowType != null)
{
columns = ColumnsOf(rowType);
return columns == null ? null : PostSettingKind.Table;
}
return KindOf(type);
}
private static Type ElementType(Type type) =>
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)
? type.GetGenericArguments()[0]
: null;
/// <summary>
/// A row type's editable columns, or null when it is not a class with a
/// public parameterless constructor whose every public settable property
/// is text or a number.
/// </summary>
private static IReadOnlyList<PostSettingsColumn> ColumnsOf(Type rowType)
{
if (!rowType.IsClass || rowType == typeof(string) || rowType.GetConstructor(Type.EmptyTypes) == null)
return null;
var columns = new List<PostSettingsColumn>();
var properties = rowType
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetSetMethod() != null && p.GetIndexParameters().Length == 0)
.OrderBy(p => p.MetadataToken);
foreach (var property in properties)
{
var kind = KindOf(property.PropertyType);
if (kind is not (PostSettingKind.Text or PostSettingKind.Integer or PostSettingKind.Decimal))
return null;
columns.Add(new PostSettingsColumn(property, kind.Value));
}
return columns.Count == 0 ? null : columns;
}
private static PostSettingKind? KindOf(Type type)
{
if (type == typeof(string))
+5 -1
View File
@@ -24,12 +24,16 @@ namespace OpenNest
/// <summary>A tabbed cutout's uncut tabs: material boundary that is never cut.</summary>
public static readonly Layer Tab = new Layer("TAB") { Color = Color.Orange };
/// <summary>A supplied blank's shown-only outside edge: material boundary that is never cut.</summary>
public static readonly Layer DisplayPerimeter = new Layer("DISPLAY PERIMETER") { Color = Color.DarkCyan };
/// <summary>
/// True when an entity converted from a part program describes part material. Rapids
/// and scribe/etch marks are excluded: marks are only on the surface, so they never
/// bound material and must not affect nesting, collision, area, or validation. Break
/// cuts are excluded too: they run through material that stays attached at tabs. Tabs are
/// material: they close a tabbed cutout's boundary.
/// material: they close a tabbed cutout's boundary. A display perimeter is material: it is the
/// supplied blank's edge.
/// </summary>
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break;
}
+12
View File
@@ -0,0 +1,12 @@
namespace OpenNest.Support
{
/// <summary>The axis a <see cref="SupportSlats"/> bed's slats run along, the full length or width of the table.</summary>
public enum SlatAxis
{
/// <summary>Slats run along X, the table's full length; they repeat along Y.</summary>
X,
/// <summary>Slats run along Y, the table's full width; they repeat along X.</summary>
Y,
}
}
+531
View File
@@ -0,0 +1,531 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// Decides whether a cut part stays level on its support bed. Plate is of even thickness,
/// so the part's center of gravity is its area centroid; the part rests on the bed's
/// contacts under its material and stays level while that center lies inside their convex
/// hull. The margin is how far inside (positive) or outside (negative) it lies; a part with
/// fewer than three contacts that are not in line can only balance, so its margin is at
/// most zero. With a <see cref="SupportBed.RegistrationTolerance"/>, the bed is also
/// checked shifted by that much along each axis and diagonally, keeping the worst margin.
/// A perimeter tab holds the part at the tab, so a tab point counts as one more contact.
/// A tab never sits in an inside corner, where the operator cannot grind its nub flush.
/// </summary>
public static class SupportAnalyzer
{
/// <summary>Points sampled along the outline, besides its vertices, to find the best tab point.</summary>
public const int TabSamples = 256;
/// <summary>
/// The least the outline must turn inward, in radians, for an inside corner; a run of
/// inward-turning vertices (a chorded fillet) counts as one corner by its whole turn.
/// </summary>
public const double InsideCornerAngle = System.Math.PI / 6;
/// <summary>How far a tab of size <paramref name="tabSize"/> keeps from an inside corner: its uncut span and as much again.</summary>
public static double InsideCornerClearance(double tabSize) => tabSize > 0 ? 2 * tabSize : 0;
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
public static PartSupport Analyze(SupportBed bed, int quadrant, Part part, double insideCornerClearance = 0) =>
Analyze(bed, quadrant, SupportMaterial.FromPart(part), insideCornerClearance);
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
public static PartSupport Analyze(SupportBed bed, int quadrant, SupportMaterial material,
double insideCornerClearance = 0)
{
ArgumentNullException.ThrowIfNull(bed);
ArgumentNullException.ThrowIfNull(material);
if (!(insideCornerClearance >= 0) || double.IsInfinity(insideCornerClearance))
throw new ArgumentOutOfRangeException(nameof(insideCornerClearance));
if (!material.IsComplete)
return new PartSupport(material.Reason);
if (!material.TryGetCenterOfGravity(out var center, out _))
return new PartSupport("The part encloses no area.");
var hulls = Shifts(bed.RegistrationTolerance)
.Select(shift => Hull(bed.Contacts(material, quadrant, shift, extremesOnly: true)))
.ToArray();
var margin = hulls.Min(hull => Margin(center, hull));
var contacts = bed.Contacts(material, quadrant, Vector.Zero);
var corners = InsideCorners(material.Outline);
var best = BestTabPoint(material.Outline, center, corners, insideCornerClearance,
point => MarginWith(center, hulls, point), out var bestMargin);
return new PartSupport(center, contacts, margin, bed.StabilityMargin, hulls, best, bestMargin,
corners, insideCornerClearance);
}
/// <summary>
/// A part on a plate with no support bed, tabbed for its <see cref="Width"/> instead
/// (<see cref="CNC.CuttingStrategy.CuttingParameters.TabsWithoutBedAt"/>). With no contacts
/// to judge by, a tab anywhere clear of inside corners is accepted; the best tab point is
/// the clear outline point nearest the line through the center of gravity across the
/// outline's long side, then the one farthest from an inside corner.
/// </summary>
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
public static PartSupport AnalyzeWithoutBed(SupportMaterial material, double insideCornerClearance = 0)
{
ArgumentNullException.ThrowIfNull(material);
if (!(insideCornerClearance >= 0) || double.IsInfinity(insideCornerClearance))
throw new ArgumentOutOfRangeException(nameof(insideCornerClearance));
if (!material.IsComplete)
return new PartSupport(material.Reason);
if (!material.TryGetCenterOfGravity(out var center, out _))
return new PartSupport("The part encloses no area.");
var corners = InsideCorners(material.Outline);
var best = BestTabPoint(material.Outline, center, corners, insideCornerClearance,
_ => double.PositiveInfinity, out _);
return new PartSupport(center, best, corners, insideCornerClearance, Width(material));
}
/// <summary>
/// The part's width: the short side of its outline's minimum-area bounding rectangle, or
/// NaN when its material could not be read.
/// </summary>
public static double Width(SupportMaterial material)
{
ArgumentNullException.ThrowIfNull(material);
if (!material.IsComplete || material.Outline.Count < 3)
return double.NaN;
var box = BoundingRectangle(material.Outline);
return System.Math.Min(box.Width, box.Height);
}
/// <summary>
/// The part's length: the long side of its outline's minimum-area bounding rectangle, or
/// NaN when its material could not be read.
/// </summary>
public static double Length(SupportMaterial material)
{
ArgumentNullException.ThrowIfNull(material);
if (!material.IsComplete || material.Outline.Count < 3)
return double.NaN;
var box = BoundingRectangle(material.Outline);
return System.Math.Max(box.Width, box.Height);
}
/// <summary>
/// <paramref name="support"/>, a bed analysis of a part the bed cannot hold level, for a
/// part short enough for its tab alone to hold
/// (<see cref="CNC.CuttingStrategy.CuttingParameters.TabHoldsAt"/>): its contacts and
/// margin are kept, any tab clear of inside corners steadies it, and the best tab point is
/// chosen as without a bed (<see cref="AnalyzeWithoutBed"/>).
/// </summary>
public static PartSupport HeldByTab(PartSupport support, SupportMaterial material)
{
ArgumentNullException.ThrowIfNull(support);
ArgumentNullException.ThrowIfNull(material);
if (!support.IsComplete || support.IsSupported || !support.HasBed)
return support;
var best = BestTabPoint(material.Outline, support.CenterOfGravity, support.InsideCorners,
support.InsideCornerClearance, _ => double.PositiveInfinity, out _);
return support.WithTabHold(best, Length(material));
}
// The outline point a tab steadies the part most from, by `score`, clear of every inside
// corner where the outline allows it. A tab cannot lift the margin past the contacts' own
// hull edges, so a whole stretch of outline often ties; of those, the one nearest the line
// through the center across the outline's long side, then the one farthest from an inside
// corner, or the first.
private static Vector? BestTabPoint(IReadOnlyList<Vector> outline, Vector center, IReadOnlyList<Vector> corners,
double insideCornerClearance, Func<Vector, double> score, out double bestMargin)
{
var samples = OutlineSamples(outline)
.Select(point => (Point: point, Clear: Clearance(corners, point)))
.ToList();
var allowed = samples.Where(s => IsClear(s.Clear, insideCornerClearance)).ToList();
if (allowed.Count > 0)
samples = allowed;
var scored = samples.Select(s => (s.Point, s.Clear, Margin: score(s.Point))).ToList();
bestMargin = scored.Count == 0 ? double.NegativeInfinity : scored.Max(s => s.Margin);
var along = scored.Count == 0 ? Vector.Zero : LongSide(outline);
Vector? best = null;
var bestOffset = double.PositiveInfinity;
var bestClear = double.NegativeInfinity;
foreach (var sample in scored)
{
if (sample.Margin < bestMargin - Math.Tolerance.Epsilon)
continue;
var offset = System.Math.Abs((sample.Point - center).DotProduct(along));
if (offset < bestOffset - Math.Tolerance.Epsilon
|| (offset <= bestOffset + Math.Tolerance.Epsilon && sample.Clear > bestClear))
{
best = sample.Point;
bestOffset = offset;
bestClear = sample.Clear;
}
}
return best;
}
/// <summary>The unit direction of the longer side of the outline's minimum-area bounding rectangle.</summary>
private static Vector LongSide(IReadOnlyList<Vector> outline)
{
var box = BoundingRectangle(outline);
var angle = box.Width >= box.Height ? box.Angle : box.Angle + Math.Angle.HalfPI;
return new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
}
// The full angle range checks every hull edge.
private static BoundingRectangleResult BoundingRectangle(IReadOnlyList<Vector> outline) =>
RotatingCalipers.MinimumBoundingRectangle(ConvexHull.Compute(outline.ToList()), 0, Math.Angle.TwoPI);
// A point `clear` from the nearest inside corner keeps `clearance` from it; with no
// clearance it must still not be the corner.
internal static bool IsClear(double clear, double clearance) =>
clearance > 0 ? clear >= clearance : clear > Math.Tolerance.Epsilon;
internal static double Clearance(IReadOnlyList<Vector> corners, Vector point)
{
var clear = double.PositiveInfinity;
foreach (var corner in corners)
clear = System.Math.Min(clear, corner.DistanceTo(point));
return clear;
}
/// <summary>
/// The outline vertices that lie in an inside corner: each run of consecutive vertices
/// turning inward whose turns add up to at least <see cref="InsideCornerAngle"/>.
/// </summary>
internal static Vector[] InsideCorners(IReadOnlyList<Vector> outline)
{
var count = outline.Count;
if (count < 3)
return Array.Empty<Vector>();
var area = 0.0;
for (var i = 0; i < count; i++)
{
var a = outline[i];
var b = outline[(i + 1) % count];
area += a.X * b.Y - b.X * a.Y;
}
if (area == 0)
return Array.Empty<Vector>();
// Signed turn at each vertex: negative turns inward, whatever the outline's winding.
var turns = new double[count];
for (var i = 0; i < count; i++)
{
var into = outline[i] - outline[(i + count - 1) % count];
var outOf = outline[(i + 1) % count] - outline[i];
var cross = into.X * outOf.Y - into.Y * outOf.X;
var dot = into.X * outOf.X + into.Y * outOf.Y;
turns[i] = System.Math.Atan2(cross, dot) * System.Math.Sign(area);
}
// Start after a vertex that does not turn inward so no run is split by the wrap.
var start = Array.FindIndex(turns, turn => turn >= 0);
if (start < 0)
return Array.Empty<Vector>();
var corners = new List<Vector>();
var run = new List<Vector>();
var turned = 0.0;
for (var step = 1; step <= count; step++)
{
var i = (start + step) % count;
if (turns[i] < 0)
{
run.Add(outline[i]);
turned -= turns[i];
continue;
}
if (turned >= InsideCornerAngle - Math.Tolerance.Epsilon)
corners.AddRange(run);
run.Clear();
turned = 0;
}
return corners.ToArray();
}
/// <summary>The worst margin of <paramref name="center"/> over every shifted hull, each with <paramref name="tab"/> added.</summary>
internal static double MarginWith(Vector center, IReadOnlyList<Vector[]> hulls, Vector tab)
{
var worst = double.PositiveInfinity;
foreach (var hull in hulls)
{
var points = new Vector[hull.Length + 1];
hull.CopyTo(points, 0);
points[^1] = tab;
worst = System.Math.Min(worst, Margin(center, Hull(points)));
}
return worst;
}
/// <summary>
/// Signed distance of <paramref name="point"/> inside the convex polygon
/// <paramref name="hull"/> (counter-clockwise): positive inside, negative outside, and
/// never positive for a point or segment hull. An empty hull is negative infinity.
/// </summary>
internal static double Margin(Vector point, IReadOnlyList<Vector> hull)
{
switch (hull.Count)
{
case 0:
return double.NegativeInfinity;
case 1:
return -point.DistanceTo(hull[0]);
case 2:
return -SegmentDistance(point, hull[0], hull[1]);
}
var inside = double.PositiveInfinity;
var outside = double.PositiveInfinity;
var isInside = true;
for (var i = 0; i < hull.Count; i++)
{
var a = hull[i];
var b = hull[(i + 1) % hull.Count];
var edge = b - a;
var length = System.Math.Sqrt(edge.X * edge.X + edge.Y * edge.Y);
var side = (edge.X * (point.Y - a.Y) - edge.Y * (point.X - a.X)) / length;
if (side < 0)
isInside = false;
inside = System.Math.Min(inside, side);
outside = System.Math.Min(outside, SegmentDistance(point, a, b));
}
return isInside ? inside : -outside;
}
/// <summary>The convex hull, counter-clockwise, without repeated or collinear points (monotone chain).</summary>
internal static Vector[] Hull(IEnumerable<Vector> points)
{
var sorted = points.Distinct().OrderBy(p => p.X).ThenBy(p => p.Y).ToArray();
if (sorted.Length < 3)
return sorted;
var hull = new Vector[sorted.Length * 2];
var count = 0;
foreach (var point in sorted)
{
while (count >= 2 && Cross(hull[count - 2], hull[count - 1], point) <= 0)
count--;
hull[count++] = point;
}
var lower = count + 1;
for (var i = sorted.Length - 2; i >= 0; i--)
{
while (count >= lower && Cross(hull[count - 2], hull[count - 1], sorted[i]) <= 0)
count--;
hull[count++] = sorted[i];
}
// The last point repeats the first.
return hull.Take(count - 1).ToArray();
}
private static double Cross(Vector o, Vector a, Vector b) =>
(a.X - o.X) * (b.Y - o.Y) - (a.Y - o.Y) * (b.X - o.X);
private static double SegmentDistance(Vector point, Vector a, Vector b)
{
var edge = b - a;
var lengthSquared = edge.X * edge.X + edge.Y * edge.Y;
if (lengthSquared <= 0)
return point.DistanceTo(a);
var t = System.Math.Clamp(((point.X - a.X) * edge.X + (point.Y - a.Y) * edge.Y) / lengthSquared, 0, 1);
return point.DistanceTo(a + edge * t);
}
private static IEnumerable<Vector> Shifts(double tolerance)
{
if (tolerance <= 0)
return new[] { Vector.Zero };
var steps = new[] { -tolerance, 0, tolerance };
return steps.SelectMany(x => steps.Select(y => new Vector(x, y)));
}
// Every outline vertex, then points spaced evenly along the outline.
private static IEnumerable<Vector> OutlineSamples(IReadOnlyList<Vector> outline)
{
foreach (var vertex in outline)
yield return vertex;
var perimeter = 0.0;
for (var i = 0; i < outline.Count; i++)
perimeter += outline[i].DistanceTo(outline[(i + 1) % outline.Count]);
if (!(perimeter > 0))
yield break;
var step = perimeter / TabSamples;
var travelled = 0.0;
var next = step;
for (var i = 0; i < outline.Count; i++)
{
var a = outline[i];
var b = outline[(i + 1) % outline.Count];
var length = a.DistanceTo(b);
while (length > 0 && next < travelled + length)
{
yield return a + (b - a) * ((next - travelled) / length);
next += step;
}
travelled += length;
}
}
}
/// <summary>
/// How one placed part sits on its support bed (world coordinates): see
/// <see cref="SupportAnalyzer"/>. Immutable.
/// </summary>
public sealed class PartSupport
{
private readonly IReadOnlyList<Vector[]> hulls = Array.Empty<Vector[]>();
internal PartSupport(string reason)
{
Reason = reason ?? "The part's support could not be analyzed.";
Contacts = Array.Empty<Vector>();
}
internal PartSupport(Vector centerOfGravity, IReadOnlyList<Vector> contacts, double margin,
double requiredMargin, IReadOnlyList<Vector[]> hulls, Vector? bestTabPoint, double bestTabMargin,
IReadOnlyList<Vector> insideCorners, double insideCornerClearance)
{
CenterOfGravity = centerOfGravity;
Contacts = contacts;
Margin = margin;
RequiredMargin = requiredMargin;
this.hulls = hulls;
BestTabPoint = bestTabPoint;
BestTabMargin = bestTabMargin;
InsideCorners = insideCorners;
InsideCornerClearance = insideCornerClearance;
}
// A part on a plate with no bed, tabbed for its width: it rests on nothing modeled, and a
// tab anywhere clear of inside corners is as good as any other.
// A copy of `source` held by its tab alone: it keeps its contacts and margin, and any tab
// clear of inside corners steadies it.
private PartSupport(PartSupport source, Vector? bestTabPoint, double length)
{
CenterOfGravity = source.CenterOfGravity;
Contacts = source.Contacts;
Margin = source.Margin;
RequiredMargin = source.RequiredMargin;
hulls = source.hulls;
InsideCorners = source.InsideCorners;
InsideCornerClearance = source.InsideCornerClearance;
BestTabPoint = bestTabPoint;
BestTabMargin = double.PositiveInfinity;
IsHeldByTab = true;
Length = length;
}
internal PartSupport WithTabHold(Vector? bestTabPoint, double length) => new(this, bestTabPoint, length);
internal PartSupport(Vector centerOfGravity, Vector? bestTabPoint, IReadOnlyList<Vector> insideCorners,
double insideCornerClearance, double width)
{
HasBed = false;
CenterOfGravity = centerOfGravity;
Contacts = Array.Empty<Vector>();
BestTabPoint = bestTabPoint;
BestTabMargin = double.PositiveInfinity;
InsideCorners = insideCorners;
InsideCornerClearance = insideCornerClearance;
Width = width;
}
/// <summary>
/// False for a part on a plate with no support bed, tabbed for its <see cref="Width"/>
/// (<see cref="SupportAnalyzer.AnalyzeWithoutBed"/>): it has no contacts, never counts as
/// supported, and any tab clear of inside corners steadies it.
/// </summary>
public bool HasBed { get; } = true;
/// <summary>
/// True for a part on a bed that cannot hold it level but short enough for its tab alone
/// to hold (<see cref="SupportAnalyzer.HeldByTab"/>): any tab clear of inside corners
/// steadies it.
/// </summary>
public bool IsHeldByTab { get; }
/// <summary>The part's length when <see cref="IsHeldByTab"/> (see <see cref="SupportAnalyzer.Length"/>); NaN otherwise.</summary>
public double Length { get; } = double.NaN;
/// <summary>The part's width when analyzed without a bed (see <see cref="SupportAnalyzer.Width"/>); NaN otherwise.</summary>
public double Width { get; } = double.NaN;
/// <summary>True when the part was analyzed; otherwise <see cref="Reason"/> says why not.</summary>
public bool IsComplete => Reason == null;
public string Reason { get; }
public Vector CenterOfGravity { get; } = Vector.Invalid;
/// <summary>Every bed contact under the part at the bed's nominal position.</summary>
public IReadOnlyList<Vector> Contacts { get; }
/// <summary>The outline vertices in an inside corner (see <see cref="SupportAnalyzer.InsideCorners"/>).</summary>
public IReadOnlyList<Vector> InsideCorners { get; } = Array.Empty<Vector>();
/// <summary>How far an accepted tab point keeps from an inside corner.</summary>
public double InsideCornerClearance { get; }
/// <summary>The worst signed distance of the center of gravity inside the contacts' hull.</summary>
public double Margin { get; } = double.NegativeInfinity;
/// <summary>The bed's <see cref="SupportBed.StabilityMargin"/>.</summary>
public double RequiredMargin { get; }
/// <summary>True when the bed alone holds the part level by at least the required margin.</summary>
public bool IsSupported => IsComplete && Meets(Margin);
/// <summary>
/// The outline point clear of inside corners where one tab steadies the part most, or
/// null when not analyzed. Of equally good points, the one farthest from an inside corner.
/// </summary>
public Vector? BestTabPoint { get; }
/// <summary>The margin with a tab at <see cref="BestTabPoint"/>.</summary>
public double BestTabMargin { get; } = double.NegativeInfinity;
/// <summary>True when a tab at <see cref="BestTabPoint"/> holds the part level by the required margin.</summary>
public bool TabCanSupport => IsComplete && Meets(BestTabMargin);
/// <summary>The worst margin with a tab holding the part at <paramref name="tab"/>.</summary>
public double MarginWith(Vector tab) =>
!IsComplete ? double.NegativeInfinity
: !HasBed || IsHeldByTab ? double.PositiveInfinity
: SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab);
/// <summary>True when a tab at <paramref name="tab"/> holds the part level by the required margin.</summary>
public bool IsSteadiedByTabAt(Vector tab) => IsComplete && Meets(MarginWith(tab));
/// <summary>
/// Whether a tab at <paramref name="tab"/> is good enough: it keeps clear of inside corners,
/// and it holds the part level by the required margin or, where no single tab can, it is
/// as good as the best one found.
/// </summary>
public bool AcceptsTabAt(Vector tab)
{
if (!IsComplete || !IsClearOfInsideCorners(tab))
return false;
var margin = MarginWith(tab);
return TabCanSupport ? Meets(margin) : margin >= BestTabMargin - Math.Tolerance.Epsilon;
}
/// <summary>Whether a tab at <paramref name="tab"/> holds the part level by the required margin.</summary>
public bool HoldsLevelWithTabAt(Vector tab) => IsComplete && Meets(MarginWith(tab));
/// <summary>Whether <paramref name="point"/> keeps <see cref="InsideCornerClearance"/> from every inside corner.</summary>
public bool IsClearOfInsideCorners(Vector point) =>
SupportAnalyzer.IsClear(SupportAnalyzer.Clearance(InsideCorners, point), InsideCornerClearance);
private bool Meets(double margin) => margin > 0 && margin >= RequiredMargin;
}
}
+65
View File
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// What a cut part rests on: the machine's support bed, fixed to the machine and measured
/// from the plate origin into the sheet, so its position on any sheet follows from the
/// sheet's quadrant alone. A layout only reports where it touches material; the
/// <see cref="SupportAnalyzer"/> decides from those contacts whether a part stays level.
/// The laser grid (<see cref="SupportGrid"/>) is the built-in layout; straight or curved
/// slats imported from a CAD layout can follow as further subclasses.
/// </summary>
public abstract class SupportBed : IEquatable<SupportBed>
{
protected SupportBed(double registrationTolerance, double stabilityMargin)
{
if (!double.IsFinite(registrationTolerance) || registrationTolerance < 0)
throw new ArgumentOutOfRangeException(nameof(registrationTolerance),
"Registration tolerance must be finite and zero or more.");
if (!double.IsFinite(stabilityMargin) || stabilityMargin < 0)
throw new ArgumentOutOfRangeException(nameof(stabilityMargin),
"Stability margin must be finite and zero or more.");
RegistrationTolerance = registrationTolerance;
StabilityMargin = stabilityMargin;
}
/// <summary>
/// How far a sheet may sit from its nominal place on the bed, along each axis. The
/// analyzer checks the bed shifted by up to this much and keeps the worst result.
/// </summary>
public double RegistrationTolerance { get; }
/// <summary>
/// How far inside its contacts a part's center of gravity must lie for the part to count
/// as supported; it allows for slag, gas pressure and a contact that is worn low.
/// </summary>
public double StabilityMargin { get; }
/// <summary>
/// The contacts under <paramref name="material"/> (world coordinates) on a sheet in
/// <paramref name="quadrant"/>, with the bed moved by <paramref name="shift"/>. With
/// <paramref name="extremesOnly"/>, a layout may omit contacts that cannot be corners of
/// their convex hull; the analyzer needs no others.
/// </summary>
public abstract IReadOnlyList<Vector> Contacts(SupportMaterial material, int quadrant, Vector shift,
bool extremesOnly = false);
/// <summary>This layout with every length multiplied by <paramref name="factor"/> (a unit change).</summary>
public abstract SupportBed Scaled(double factor);
public abstract bool Equals(SupportBed other);
public override bool Equals(object obj) => obj is SupportBed other && Equals(other);
public abstract override int GetHashCode();
/// <summary>-1 where a sheet in <paramref name="quadrant"/> lies on the negative side of the origin along X.</summary>
protected static double SignX(int quadrant) => quadrant is 2 or 3 ? -1 : 1;
/// <summary>-1 where a sheet in <paramref name="quadrant"/> lies on the negative side of the origin along Y.</summary>
protected static double SignY(int quadrant) => quadrant is 3 or 4 ? -1 : 1;
}
}
+116
View File
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// A laser bed whose parts rest on a regular grid of contact points: one point every
/// <see cref="PitchX"/> along X and every <see cref="PitchY"/> along Y, the first at
/// (<see cref="OffsetX"/>, <see cref="OffsetY"/>) measured from the plate origin into the
/// sheet. A sheet in another quadrant sees the same grid mirrored, since it lies on the
/// other side of the origin. Lengths are in the nest's units.
/// </summary>
public sealed class SupportGrid : SupportBed
{
public SupportGrid(double pitchX, double pitchY, double offsetX = 0, double offsetY = 0,
double registrationTolerance = 0, double stabilityMargin = 0)
: base(registrationTolerance, stabilityMargin)
{
PitchX = Positive(pitchX, nameof(pitchX));
PitchY = Positive(pitchY, nameof(pitchY));
OffsetX = Finite(offsetX, nameof(offsetX));
OffsetY = Finite(offsetY, nameof(offsetY));
}
public double PitchX { get; }
public double PitchY { get; }
public double OffsetX { get; }
public double OffsetY { get; }
public override IReadOnlyList<Vector> Contacts(SupportMaterial material, int quadrant, Vector shift,
bool extremesOnly = false)
{
ArgumentNullException.ThrowIfNull(material);
var contacts = new List<Vector>();
if (!material.IsComplete)
return contacts;
// The grid's world lattice: OffsetX into the sheet from the origin, then every PitchX.
var baseX = SignX(quadrant) * OffsetX + shift.X;
var baseY = SignY(quadrant) * OffsetY + shift.Y;
var bounds = material.Bounds;
var firstRow = (long)System.Math.Ceiling((bounds.Bottom - baseY) / PitchY);
var lastRow = (long)System.Math.Floor((bounds.Top - baseY) / PitchY);
for (var row = firstRow; row <= lastRow; row++)
{
var y = baseY + row * PitchY;
foreach (var (start, end) in material.SpansAtY(y))
{
var first = (long)System.Math.Ceiling((start - baseX) / PitchX);
var last = (long)System.Math.Floor((end - baseX) / PitchX);
if (first > last)
continue;
// Within one span only its end points can be corners of the contacts' hull.
if (extremesOnly)
{
contacts.Add(new Vector(baseX + first * PitchX, y));
if (last != first)
contacts.Add(new Vector(baseX + last * PitchX, y));
continue;
}
for (var column = first; column <= last; column++)
contacts.Add(new Vector(baseX + column * PitchX, y));
}
}
return contacts;
}
/// <summary>
/// The grid points within <paramref name="region"/> (world coordinates) on a sheet in
/// <paramref name="quadrant"/>, for display; empty when there would be more than
/// <paramref name="limit"/>.
/// </summary>
public IReadOnlyList<Vector> PointsIn(Box region, int quadrant, int limit = 20000)
{
ArgumentNullException.ThrowIfNull(region);
var baseX = SignX(quadrant) * OffsetX;
var baseY = SignY(quadrant) * OffsetY;
var firstColumn = System.Math.Ceiling((region.Left - baseX) / PitchX);
var lastColumn = System.Math.Floor((region.Right - baseX) / PitchX);
var firstRow = System.Math.Ceiling((region.Bottom - baseY) / PitchY);
var lastRow = System.Math.Floor((region.Top - baseY) / PitchY);
var count = (lastColumn - firstColumn + 1) * (lastRow - firstRow + 1);
if (!double.IsFinite(count) || count <= 0 || count > limit)
return Array.Empty<Vector>();
var points = new List<Vector>((int)count);
for (var row = firstRow; row <= lastRow; row++)
for (var column = firstColumn; column <= lastColumn; column++)
points.Add(new Vector(baseX + column * PitchX, baseY + row * PitchY));
return points;
}
public override SupportBed Scaled(double factor) =>
new SupportGrid(PitchX * factor, PitchY * factor, OffsetX * factor, OffsetY * factor,
RegistrationTolerance * factor, StabilityMargin * factor);
public override bool Equals(SupportBed other) =>
other is SupportGrid grid
&& grid.PitchX == PitchX && grid.PitchY == PitchY
&& grid.OffsetX == OffsetX && grid.OffsetY == OffsetY
&& grid.RegistrationTolerance == RegistrationTolerance
&& grid.StabilityMargin == StabilityMargin;
public override int GetHashCode() =>
HashCode.Combine(PitchX, PitchY, OffsetX, OffsetY, RegistrationTolerance, StabilityMargin);
private static double Positive(double value, string name) =>
double.IsFinite(value) && value > 0 ? value
: throw new ArgumentOutOfRangeException(name, "Grid pitch must be finite and positive.");
private static double Finite(double value, string name) =>
double.IsFinite(value) ? value
: throw new ArgumentOutOfRangeException(name, "Grid offset must be finite.");
}
}
+252
View File
@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// The material of one placed part as it drops onto the support bed, in world coordinates:
/// its outline minus its holes, chorded at <see cref="ChordTolerance"/>. A tabbed cutout's
/// slug stays attached while the sheet is cut (see <see cref="NestingMaterial.ForNesting"/>),
/// so it counts as part. Immutable; an unusable program gives an incomplete material.
/// </summary>
public sealed class SupportMaterial
{
/// <summary>Chord tolerance for arcs, in model units.</summary>
public const double ChordTolerance = 0.001;
private readonly Vector[] outline;
private readonly Vector[][] holes;
private readonly Box bounds = new();
private SupportMaterial(Vector[] outline, Vector[][] holes, string reason)
{
this.outline = outline ?? Array.Empty<Vector>();
this.holes = holes ?? Array.Empty<Vector[]>();
Reason = reason;
if (reason != null)
return;
var minX = double.PositiveInfinity;
var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity;
var maxY = double.NegativeInfinity;
foreach (var point in this.outline)
{
minX = System.Math.Min(minX, point.X);
minY = System.Math.Min(minY, point.Y);
maxX = System.Math.Max(maxX, point.X);
maxY = System.Math.Max(maxY, point.Y);
}
bounds = new Box(minX, minY, maxX - minX, maxY - minY);
}
/// <summary>True when the material was captured; otherwise <see cref="Reason"/> says why not.</summary>
public bool IsComplete => Reason == null;
public string Reason { get; }
/// <summary>The outline's vertices, without a repeated closing vertex.</summary>
public IReadOnlyList<Vector> Outline => outline;
/// <summary>Each hole's vertices, without a repeated closing vertex.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Holes => holes;
/// <summary>A copy of the outline's bounding box.</summary>
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
/// <summary>
/// Material from an outline and holes already in world coordinates. Fewer than three
/// vertices, or a nonfinite one, gives an incomplete material.
/// </summary>
public static SupportMaterial FromPolygons(IEnumerable<Vector> outline, IEnumerable<IEnumerable<Vector>> holes = null)
{
var ring = Ring(outline);
if (ring == null)
return Incomplete("The part outline is not a polygon.");
var cutouts = new List<Vector[]>();
foreach (var hole in holes ?? Enumerable.Empty<IEnumerable<Vector>>())
{
var cutout = Ring(hole);
if (cutout == null)
return Incomplete("A part hole is not a polygon.");
cutouts.Add(cutout);
}
return new SupportMaterial(ring, cutouts.ToArray(), null);
}
/// <summary>The material of a placed part: its program at its location.</summary>
public static SupportMaterial FromPart(Part part)
{
ArgumentNullException.ThrowIfNull(part);
return FromProgram(part.Program, part.Location);
}
/// <summary>
/// The material of <paramref name="program"/> (part coordinates, rotation applied) placed
/// at <paramref name="location"/>. Leads, rapids, scribes and break cuts are not material.
/// A perimeter left open by a tab still bounds its material, closed across the tab.
/// </summary>
public static SupportMaterial FromProgram(Program program, Vector location)
{
ArgumentNullException.ThrowIfNull(program);
try
{
var entities = ConvertProgram.ToGeometry(program)
.Where(e => e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout)
.ForNesting();
if (entities.Count == 0)
return Incomplete("The part has no material contour.");
var profile = new ShapeProfile(entities);
var ring = Ring(Chord(profile.Perimeter, location));
if (ring == null)
return Incomplete("The part outline is not a polygon.");
var cutouts = new List<Vector[]>();
foreach (var cutout in profile.Cutouts)
if (Ring(Chord(cutout, location)) is { } hole)
cutouts.Add(hole);
return new SupportMaterial(ring, cutouts.ToArray(), null);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or NotSupportedException
or ArithmeticException or IndexOutOfRangeException)
{
return Incomplete(ex.Message);
}
}
private static SupportMaterial Incomplete(string reason) => new(null, null, reason);
private static IEnumerable<Vector> Chord(Shape shape, Vector location) =>
shape.ToPolygonWithTolerance(ChordTolerance).Vertices.Select(v => v + location);
// The distinct vertices of a closed ring, or null when it cannot bound any area.
private static Vector[] Ring(IEnumerable<Vector> vertices)
{
if (vertices == null)
return null;
var ring = new List<Vector>();
foreach (var vertex in vertices)
{
if (!double.IsFinite(vertex.X) || !double.IsFinite(vertex.Y))
return null;
if (ring.Count == 0 || ring[^1] != vertex)
ring.Add(vertex);
}
if (ring.Count > 1 && ring[0] == ring[^1])
ring.RemoveAt(ring.Count - 1);
return ring.Count < 3 ? null : ring.ToArray();
}
/// <summary>
/// The material's area and center of gravity (its area centroid: plate is of even
/// thickness), or false when the material is incomplete or encloses no area.
/// </summary>
public bool TryGetCenterOfGravity(out Vector centerOfGravity, out double area)
{
centerOfGravity = Vector.Invalid;
area = 0;
if (!IsComplete || !PolygonAreaMoments.TryCompute(outline, out var whole))
return false;
var cutouts = new List<PolygonAreaMoments>();
foreach (var hole in holes)
if (PolygonAreaMoments.TryCompute(hole, out var moments))
cutouts.Add(moments);
if (!PolygonAreaMoments.TrySubtract(whole, cutouts, out var net))
return false;
centerOfGravity = net.Centroid;
area = net.Area;
return true;
}
/// <summary>
/// The spans of the horizontal line at <paramref name="y"/> that lie in material, in
/// ascending order. A line along an edge or through a vertex counts that crossing once,
/// on the half-open rule, so touching is not crossing.
/// </summary>
public IReadOnlyList<(double Start, double End)> SpansAtY(double y)
{
var crossings = new List<double>();
if (!IsComplete || y < bounds.Bottom || y > bounds.Top)
return Array.Empty<(double, double)>();
AddCrossings(outline, y, crossings);
foreach (var hole in holes)
AddCrossings(hole, y, crossings);
crossings.Sort();
var spans = new List<(double, double)>(crossings.Count / 2);
for (var i = 0; i + 1 < crossings.Count; i += 2)
spans.Add((crossings[i], crossings[i + 1]));
return spans;
}
/// <summary>
/// The spans of the vertical line at <paramref name="x"/> that lie in material, in
/// ascending order, on the same half-open rule as <see cref="SpansAtY"/>.
/// </summary>
public IReadOnlyList<(double Start, double End)> SpansAtX(double x)
{
var crossings = new List<double>();
if (!IsComplete || x < bounds.Left || x > bounds.Right)
return Array.Empty<(double, double)>();
AddCrossingsAtX(outline, x, crossings);
foreach (var hole in holes)
AddCrossingsAtX(hole, x, crossings);
crossings.Sort();
var spans = new List<(double, double)>(crossings.Count / 2);
for (var i = 0; i + 1 < crossings.Count; i += 2)
spans.Add((crossings[i], crossings[i + 1]));
return spans;
}
/// <summary>Whether <paramref name="point"/> lies in material (even-odd over outline and holes).</summary>
public bool Contains(Vector point)
{
if (!IsComplete)
return false;
var inside = Inside(outline, point);
foreach (var hole in holes)
if (Inside(hole, point))
inside = !inside;
return inside;
}
private static void AddCrossings(Vector[] ring, double y, List<double> crossings)
{
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
var a = ring[j];
var b = ring[i];
if ((a.Y > y) != (b.Y > y))
crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y));
}
}
private static void AddCrossingsAtX(Vector[] ring, double x, List<double> crossings)
{
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
var a = ring[j];
var b = ring[i];
if ((a.X > x) != (b.X > x))
crossings.Add(a.Y + (x - a.X) * (b.Y - a.Y) / (b.X - a.X));
}
}
private static bool Inside(Vector[] ring, Vector point)
{
var inside = false;
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
var a = ring[j];
var b = ring[i];
if ((a.Y > point.Y) != (b.Y > point.Y)
&& point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y))
inside = !inside;
}
return inside;
}
}
}
+152
View File
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Support
{
/// <summary>
/// A bed of straight slats, as on a plasma table: each slat a rectangle <see cref="Thickness"/>
/// wide running the full length of the table along <see cref="Axis"/>, one every
/// <see cref="Pitch"/> across it, the first centered <see cref="Offset"/> from the plate origin
/// into the sheet. A sheet in another quadrant sees the same slats mirrored. A part rests on
/// the region where its material crosses a slat; the corners of that region (where the
/// outline crosses a slat's edges, and outline vertices on the slat) are its contacts.
/// Lengths are in the nest's units.
/// </summary>
public sealed class SupportSlats : SupportBed
{
public SupportSlats(SlatAxis axis, double pitch, double thickness, double offset = 0,
double registrationTolerance = 0, double stabilityMargin = 0)
: base(registrationTolerance, stabilityMargin)
{
if (axis is not (SlatAxis.X or SlatAxis.Y))
throw new ArgumentOutOfRangeException(nameof(axis), "Slats run along X or Y.");
if (!double.IsFinite(pitch) || pitch <= 0)
throw new ArgumentOutOfRangeException(nameof(pitch), "Slat pitch must be finite and positive.");
if (!double.IsFinite(thickness) || thickness < 0)
throw new ArgumentOutOfRangeException(nameof(thickness), "Slat thickness must be finite and zero or more.");
if (!double.IsFinite(offset))
throw new ArgumentOutOfRangeException(nameof(offset), "Slat offset must be finite.");
Axis = axis;
Pitch = pitch;
Thickness = thickness;
Offset = offset;
}
public SlatAxis Axis { get; }
/// <summary>The distance between neighboring slats' center lines.</summary>
public double Pitch { get; }
/// <summary>How wide each slat's top edge is; zero treats a slat as a line.</summary>
public double Thickness { get; }
/// <summary>The first slat's center line, measured from the plate origin into the sheet.</summary>
public double Offset { get; }
public override IReadOnlyList<Vector> Contacts(SupportMaterial material, int quadrant, Vector shift,
bool extremesOnly = false)
{
ArgumentNullException.ThrowIfNull(material);
var contacts = new List<Vector>();
if (!material.IsComplete)
return contacts;
// Work across the slats: Y for slats along X, X for slats along Y.
var alongX = Axis == SlatAxis.X;
var baseline = alongX ? SignY(quadrant) * Offset + shift.Y : SignX(quadrant) * Offset + shift.X;
var bounds = material.Bounds;
var low = alongX ? bounds.Bottom : bounds.Left;
var high = alongX ? bounds.Top : bounds.Right;
var half = Thickness / 2;
var first = (long)System.Math.Ceiling((low - half - baseline) / Pitch);
var last = (long)System.Math.Floor((high + half - baseline) / Pitch);
if (first > last)
return contacts;
// Where the outline crosses each slat's edges.
for (var slat = first; slat <= last; slat++)
{
var center = baseline + slat * Pitch;
AddEdge(material, alongX, center - half, contacts);
if (half > 0)
AddEdge(material, alongX, center + half, contacts);
}
// Outline and hole vertices that sit on a slat: every vertex on one slat's top.
AddVertices(material.Outline, alongX, baseline, half, contacts);
foreach (var hole in material.Holes)
AddVertices(hole, alongX, baseline, half, contacts);
return contacts;
}
/// <summary>
/// The slats within <paramref name="region"/> (world coordinates) on a sheet in
/// <paramref name="quadrant"/>, each cut off to the region, for display; empty when there
/// would be more than <paramref name="limit"/>.
/// </summary>
public IReadOnlyList<Box> SlatsIn(Box region, int quadrant, int limit = 5000)
{
ArgumentNullException.ThrowIfNull(region);
var alongX = Axis == SlatAxis.X;
var baseline = alongX ? SignY(quadrant) * Offset : SignX(quadrant) * Offset;
var low = alongX ? region.Bottom : region.Left;
var high = alongX ? region.Top : region.Right;
var half = Thickness / 2;
var first = System.Math.Ceiling((low - half - baseline) / Pitch);
var last = System.Math.Floor((high + half - baseline) / Pitch);
var count = last - first + 1;
if (!double.IsFinite(count) || count <= 0 || count > limit)
return Array.Empty<Box>();
var slats = new List<Box>((int)count);
for (var slat = first; slat <= last; slat++)
{
var center = baseline + slat * Pitch;
var start = System.Math.Max(low, center - half);
var end = System.Math.Min(high, center + half);
slats.Add(alongX
? new Box(region.Left, start, region.Length, end - start)
: new Box(start, region.Bottom, end - start, region.Width));
}
return slats;
}
public override SupportBed Scaled(double factor) =>
new SupportSlats(Axis, Pitch * factor, Thickness * factor, Offset * factor,
RegistrationTolerance * factor, StabilityMargin * factor);
public override bool Equals(SupportBed other) =>
other is SupportSlats slats
&& slats.Axis == Axis && slats.Pitch == Pitch
&& slats.Thickness == Thickness && slats.Offset == Offset
&& slats.RegistrationTolerance == RegistrationTolerance
&& slats.StabilityMargin == StabilityMargin;
public override int GetHashCode() =>
HashCode.Combine(Axis, Pitch, Thickness, Offset, RegistrationTolerance, StabilityMargin);
private static void AddEdge(SupportMaterial material, bool alongX, double across, List<Vector> contacts)
{
var spans = alongX ? material.SpansAtY(across) : material.SpansAtX(across);
foreach (var (start, end) in spans)
{
contacts.Add(alongX ? new Vector(start, across) : new Vector(across, start));
contacts.Add(alongX ? new Vector(end, across) : new Vector(across, end));
}
}
private void AddVertices(IReadOnlyList<Vector> ring, bool alongX, double baseline, double half,
List<Vector> contacts)
{
foreach (var vertex in ring)
{
var across = alongX ? vertex.Y : vertex.X;
// The nearest slat center; the vertex is on that slat when within half its thickness.
var center = baseline + System.Math.Round((across - baseline) / Pitch) * Pitch;
if (System.Math.Abs(across - center) <= half)
contacts.Add(vertex);
}
}
}
}
@@ -0,0 +1,78 @@
using System;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.Support
{
/// <summary>
/// Which parts <see cref="CuttingParameters.SupportTabs"/> tabs (docs/support-tabs.md): the
/// parts the plate's support bed cannot hold level or, on a plate with no bed, the parts
/// whose width is in the settings' no-bed range. Also the cutting settings for a lead-in
/// placed by hand: the tool cuts where it is clicked, so the part is tabbed there. Plan
/// Cutting chooses the tab point itself.
/// </summary>
public static class SupportTabSettings
{
/// <summary>
/// The analysis that tabs a part, or null when support tabs are off or the part is
/// outside the rule: with a bed, the bed's analysis, held by its tab alone
/// (<see cref="SupportAnalyzer.HeldByTab"/>) when the bed cannot hold it level and its
/// length is within <see cref="CuttingParameters.TabHoldsAt"/>; without one, a no-bed analysis
/// (<see cref="SupportAnalyzer.AnalyzeWithoutBed"/>) of a part whose width is in
/// <see cref="CuttingParameters.TabsWithoutBedAt"/>'s range. The part needs a tab when
/// the result is complete and not supported; an incomplete result could not be read.
/// </summary>
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
public static PartSupport Analyze(CuttingParameters parameters, SupportBed bed, int quadrant,
SupportMaterial material, double insideCornerClearance = 0)
{
ArgumentNullException.ThrowIfNull(material);
if (parameters?.SupportTabs != true)
return null;
if (bed != null)
{
var support = SupportAnalyzer.Analyze(bed, quadrant, material, insideCornerClearance);
return support is { IsComplete: true, IsSupported: false }
&& parameters.TabHoldsAt(SupportAnalyzer.Length(material))
? SupportAnalyzer.HeldByTab(support, material)
: support;
}
if (!parameters.HasNoBedTabRange())
return null;
if (!material.IsComplete)
return SupportAnalyzer.AnalyzeWithoutBed(material, insideCornerClearance);
return parameters.TabsWithoutBedAt(SupportAnalyzer.Width(material))
? SupportAnalyzer.AnalyzeWithoutBed(material, insideCornerClearance)
: null;
}
/// <summary>
/// Whether support tabs are on but can tab nothing: the plate has no support bed and the
/// settings set no no-bed width range.
/// </summary>
public static bool SkipsWithoutBed(CuttingParameters parameters, SupportBed bed) =>
parameters?.SupportTabs == true && bed == null && !parameters.HasNoBedTabRange();
/// <summary>
/// <paramref name="parameters"/> itself, or a copy with tabs enabled when support tabs are
/// on, a tab size is set and <paramref name="part"/> needs a tab (see <see cref="Analyze"/>).
/// The caller's settings are never changed.
/// </summary>
public static CuttingParameters ForManualLeadIn(CuttingParameters parameters, Plate plate, Part part)
{
ArgumentNullException.ThrowIfNull(parameters);
if (!parameters.SupportTabs || parameters.TabsEnabled || parameters.TabConfig == null
|| plate == null || part == null
|| (plate.SupportBed == null && !parameters.HasNoBedTabRange()))
return parameters;
var support = Analyze(parameters, plate.SupportBed, plate.Quadrant, SupportMaterial.FromPart(part));
if (support is not { IsComplete: true, IsSupported: false })
return parameters;
var tabbed = OwnedCuttingParameters.Copy(parameters);
tabbed.TabsEnabled = true;
return tabbed;
}
}
}
+3 -1
View File
@@ -10,6 +10,8 @@ namespace OpenNest
{
public static TimingInfo GetTimingInfo(Program pgm)
{
// A display perimeter's tail is never cut, so it takes no time.
pgm = DisplayPerimeterTail.WithoutTail(pgm);
var entities = ConvertProgram.ToGeometry(pgm);
var shapes = ShapeBuilder.GetShapes(
entities.Where(entity => entity.Layer != SpecialLayers.Rapid)
@@ -36,7 +38,7 @@ namespace OpenNest
foreach (var part in plate.Parts)
{
var endpt = part.Program.EndPoint() + part.Location;
var endpt = DisplayPerimeterTail.WithoutTail(part.Program).EndPoint() + part.Location;
info += GetTimingInfo(part.Program);
info.TravelDistance += pos.DistanceTo(endpt);
pos = endpt;
+196
View File
@@ -0,0 +1,196 @@
using System.Globalization;
namespace OpenNest.Data;
/// <summary>The paging facts a <see cref="BrowseSession{TPage, TSort}"/> reads from a server page.</summary>
public interface IBrowsePage
{
int Offset { get; }
/// <summary>Number of records matching the request across all pages.</summary>
int Total { get; }
/// <summary>Number of records on this page.</summary>
int ItemCount { get; }
}
/// <summary>
/// Browse state shared by the paged server lists: the requested search, sort and page, and the
/// last page applied. Every change issues one bounded server query. A response that a later
/// request superseded is discarded (and the earlier request is cancelled), so an older result
/// can never replace a newer one. Call from one thread (the UI thread).
/// </summary>
public abstract class BrowseSession<TPage, TSort> : IDisposable
where TPage : class, IBrowsePage
where TSort : struct, Enum
{
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
protected BrowseSession(int pageSize, int maxPageSize, TSort sort, bool descending = true)
{
if (pageSize < 1 || pageSize > maxPageSize)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {maxPageSize}.");
PageSize = pageSize;
Sort = sort;
Descending = descending;
}
public int PageSize { get; }
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public TSort Sort { get; private set; }
public bool Descending { get; private set; }
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public TPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.ItemCount < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 nests".</summary>
public abstract string Summary { get; }
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
return FirstPageAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(TSort field)
{
Descending = EqualityComparer<TSort>.Default.Equals(field, Sort) && !Descending;
Sort = field;
return FirstPageAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>Re-queries from the first page, after a derived filter changed.</summary>
protected Task<bool> FirstPageAsync()
{
Offset = 0;
return RunAsync();
}
/// <summary>Queries the page at <see cref="Offset"/> with the current filters.</summary>
protected abstract Task<TPage> QueryAsync(CancellationToken cancellationToken);
/// <summary>The standard summary: <paramref name="empty"/> when nothing matches, else the shown range of <paramref name="noun"/>.</summary>
protected string PageSummary(string noun, string empty)
{
if (Page is not { } page)
return "";
if (page.Total == 0)
return empty;
if (page.ItemCount == 0)
return $"No {noun} on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.ItemCount)} of {Count(page.Total)} {noun}";
}
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// A deletion, an archive or another PC can leave the requested page past the end.
if (page.ItemCount == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the server client is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+47 -21
View File
@@ -194,19 +194,39 @@ public sealed class ClampSettings
/// </summary>
public static ClampSettings Load(string path, out ClampSettingsStatus status)
{
var settings = Fallback;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = ClampSettingsStatus.Missing;
return settings;
return Fallback;
}
string json;
try
{
json = File.ReadAllText(path);
}
catch (Exception ex) when (IsUnreadableFile(ex))
{
status = ClampSettingsStatus.Invalid;
return Fallback;
}
return Parse(json, out status);
}
/// <summary>
/// Reads settings from <paramref name="json"/> (a file or the shared copy) the way
/// <see cref="Load(string, out ClampSettingsStatus)"/> reads a file, never throwing.
/// </summary>
public static ClampSettings Parse(string json, out ClampSettingsStatus status)
{
var settings = Fallback;
// Read as a raw document so a missing field (fallback, Ok) is distinguishable from a
// present but unusable one, including an explicit null (fallback, Partial).
Dictionary<string, JsonElement> values;
try
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
using var document = JsonDocument.Parse(json ?? "");
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
status = ClampSettingsStatus.Invalid;
@@ -307,24 +327,7 @@ public sealed class ClampSettings
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var dto = new Dictionary<string, object>
{
["version"] = CurrentVersion,
["enabled"] = Enabled,
["units"] = Units.ToString().ToLowerInvariant(),
["width"] = Width,
["depth"] = Depth,
["removalMinutes"] = RemovalMinutes,
["centers"] = Centers,
};
// Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters;
if (OriginEndCenters.Count > 0)
dto["originEndCenters"] = OriginEndCenters;
if (FarEndCenters.Count > 0)
dto["farEndCenters"] = FarEndCenters;
var json = JsonSerializer.Serialize(dto, JsonOptions);
var json = ToJson();
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
@@ -344,6 +347,29 @@ public sealed class ClampSettings
}
}
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
public string ToJson()
{
var dto = new Dictionary<string, object>
{
["version"] = CurrentVersion,
["enabled"] = Enabled,
["units"] = Units.ToString().ToLowerInvariant(),
["width"] = Width,
["depth"] = Depth,
["removalMinutes"] = RemovalMinutes,
["centers"] = Centers,
};
// Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters;
if (OriginEndCenters.Count > 0)
dto["originEndCenters"] = OriginEndCenters;
if (FarEndCenters.Count > 0)
dto["farEndCenters"] = FarEndCenters;
return JsonSerializer.Serialize(dto, JsonOptions);
}
private static double Scale(Units from, Units to) =>
from == to ? 1
: from == Units.Inches ? MillimetersPerInch
+16 -16
View File
@@ -16,7 +16,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
@@ -25,7 +25,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
@@ -34,7 +34,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
@@ -43,7 +43,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
@@ -52,7 +52,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
@@ -61,7 +61,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
@@ -70,7 +70,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.375, "angle": 90.0, "radius": 0.1875 },
"leadOut": { "type": "Line", "length": 0.1875, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.625, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
},
{
@@ -79,7 +79,7 @@
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.5, "angle": 90.0, "radius": 0.25 },
"leadOut": { "type": "Line", "length": 0.25, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.75, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
}
]
@@ -95,7 +95,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -104,7 +104,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -113,7 +113,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -122,7 +122,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
@@ -138,7 +138,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -147,7 +147,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -156,7 +156,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
@@ -165,7 +165,7 @@
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
+17 -174
View File
@@ -1,42 +1,31 @@
using System.Globalization;
using OpenNest;
namespace OpenNest.Data;
/// <summary>
/// Browse state for Add from Drawing Library: the requested search, sort, page and
/// nest filter, and the last page applied. By default only drawings with the nest's
/// material, grade and thickness are listed; <see cref="SetShowAllAsync"/> lifts that
/// filter. Every change issues one bounded server query. A response that a later request
/// superseded is discarded (and the earlier request is cancelled), so an older result can
/// never replace a newer one. Call from one thread (the UI thread).
/// filter. Paging, superseding and disposal follow <see cref="BrowseSession{TPage, TSort}"/>.
/// Call from one thread (the UI thread).
/// </summary>
public sealed class DrawingLibraryBrowseSession : IDisposable
public sealed class DrawingLibraryBrowseSession : BrowseSession<DrawingLibraryPage, DrawingSortField>
{
private readonly IDrawingLibrary _library;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
/// <param name="nest">The nest to filter to, or null to list every active drawing (File &gt; Drawing Library).</param>
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest? nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
: base(pageSize, DrawingLibraryQuery.MaxLimit, DrawingSortField.SavedAt)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(nest);
if (pageSize < 1 || pageSize > DrawingLibraryQuery.MaxLimit)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingLibraryQuery.MaxLimit}.");
_library = library;
PageSize = pageSize;
MaterialName = (nest.Material?.Name ?? "").Trim();
MaterialGrade = (nest.Material?.Grade ?? "").Trim();
MaterialName = (nest?.Material?.Name ?? "").Trim();
MaterialGrade = (nest?.Material?.Grade ?? "").Trim();
// A nest without a thickness has nothing to match; it filters by material only.
Thickness = double.IsFinite(nest.Thickness) && nest.Thickness > 0
Thickness = nest != null && double.IsFinite(nest.Thickness) && nest.Thickness > 0
? DrawingLibraryRules.NormalizeThickness(nest.Thickness)
: null;
}
public int PageSize { get; }
/// <summary>The nest's material name, applied unless <see cref="ShowAll"/>; blank means any.</summary>
public string MaterialName { get; }
@@ -53,148 +42,21 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
public bool IsFilteredToNest =>
!ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt;
public bool Descending { get; private set; } = true;
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public DrawingLibraryPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 drawings".</summary>
public string Summary
{
get
{
if (Page is not { } page)
return "";
if (page.Total == 0)
{
if (Search.Length > 0)
return "No drawings match the search.";
return IsFilteredToNest
? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
: "No drawings in the library.";
}
if (page.Items.Count == 0)
return $"No drawings on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} drawings";
}
}
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
Offset = 0;
return RunAsync();
}
/// <inheritdoc />
public override string Summary =>
PageSummary("drawings",
Search.Length > 0 ? "No drawings match the search."
: IsFilteredToNest ? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
: "No drawings in the library.");
/// <summary>Lifts or restores the nest filter and returns to the first page.</summary>
public Task<bool> SetShowAllAsync(bool showAll)
{
ShowAll = showAll;
Offset = 0;
return RunAsync();
return FirstPageAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(DrawingSortField field)
{
Descending = field == Sort && !Descending;
Sort = field;
Offset = 0;
return RunAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// An archive or another PC can leave the requested page past the end.
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
protected override Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
_library.QueryAsync(
new DrawingLibraryQuery
{
@@ -208,23 +70,4 @@ public sealed class DrawingLibraryBrowseSession : IDisposable
Limit = PageSize,
},
cancellationToken);
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the library client is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
+83
View File
@@ -0,0 +1,83 @@
namespace OpenNest.Data;
/// <summary>
/// The customers, materials, grades and thicknesses an edit offers in its drop-downs: those
/// already used by library drawings, plus the materials and grades of a material catalog.
/// Names match by <see cref="SharedListNames.Key"/> and keep the first spelling seen, so
/// pass the newest records first. Thicknesses come only from records in the edit's units.
/// The lists are suggestions; any typed value may still be saved.
/// </summary>
public sealed class DrawingLibraryChoices
{
private readonly List<DrawingLibraryRecord> _records;
private readonly List<MaterialConfig> _catalog;
private DrawingLibraryChoices(List<DrawingLibraryRecord> records, List<MaterialConfig> catalog, Units units)
{
_records = records;
Units = units;
_catalog = catalog;
Customers = Distinct(records.Select(r => r.Customer));
Materials = Distinct(records.Select(r => r.MaterialName).Concat(catalog.Select(m => m.Name)));
}
public static DrawingLibraryChoices Empty { get; } = new(new(), new(), Units.Inches);
/// <param name="records">Library drawings, newest first; archived ones are skipped.</param>
/// <param name="units">The edit's units; thicknesses in other units are not offered.</param>
/// <param name="catalog">Materials and grades on file, for example the machine configurations'.</param>
public static DrawingLibraryChoices From(IEnumerable<DrawingLibraryRecord> records, Units units,
IEnumerable<MaterialConfig>? catalog = null)
{
ArgumentNullException.ThrowIfNull(records);
return new DrawingLibraryChoices(records.Where(r => r != null && !r.Archived).ToList(),
catalog?.Where(m => m != null).ToList() ?? new(), units);
}
public Units Units { get; }
/// <summary>Customers, sorted.</summary>
public IReadOnlyList<string> Customers { get; }
/// <summary>Material names, sorted.</summary>
public IReadOnlyList<string> Materials { get; }
/// <summary>The grades used or on file for <paramref name="material"/>, sorted.</summary>
public IReadOnlyList<string> GradesOf(string? material)
{
var key = SharedListNames.Key(material);
if (key.Length == 0)
return Array.Empty<string>();
return Distinct(_records.Where(r => SharedListNames.Key(r.MaterialName) == key).Select(r => r.MaterialGrade)
.Concat(_catalog.Where(m => SharedListNames.Key(m.Name) == key).Select(m => m.Grade)));
}
/// <summary>
/// The thicknesses library drawings of <paramref name="material"/> (and of
/// <paramref name="grade"/>, when one is given) use, thinnest first.
/// </summary>
public IReadOnlyList<double> ThicknessesOf(string? material, string? grade)
{
var materialKey = SharedListNames.Key(material);
if (materialKey.Length == 0)
return Array.Empty<double>();
var gradeKey = SharedListNames.Key(grade);
return _records
.Where(r => r.Units == Units && SharedListNames.Key(r.MaterialName) == materialKey
&& (gradeKey.Length == 0 || SharedListNames.Key(r.MaterialGrade) == gradeKey)
&& double.IsFinite(r.Thickness) && r.Thickness > 0)
.Select(r => DrawingLibraryRules.NormalizeThickness(r.Thickness))
.Distinct()
.OrderBy(t => t)
.ToList();
}
private static IReadOnlyList<string> Distinct(IEnumerable<string?> names) =>
names
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => MaterialListRules.Spelling(name))
.GroupBy(SharedListNames.Key)
.Select(group => group.First())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
+115 -26
View File
@@ -1,10 +1,23 @@
namespace OpenNest.Data;
/// <summary>
/// Edits one listed library drawing's notes and nesting settings in place: the save is a
/// metadata-only revision guarded by the revision it was listed with, so the package (and
/// its hash) stays the same. A stale revision raises <see cref="DrawingLibraryConflictException"/>
/// and changes nothing.
/// A library drawing's editable details. Units are not among them: they give the stored
/// geometry its meaning, so changing them would rescale the part.
/// </summary>
public sealed record DrawingLibraryDetails(
string Name,
string Customer,
string MaterialName,
string MaterialGrade,
double Thickness,
CutType CutType);
/// <summary>
/// Edits one listed library drawing's details (name, customer, material, grade, thickness,
/// cut type), notes and nesting settings in place: the save is a metadata-only revision
/// guarded by the revision it was listed with, so the package (and its hash) stays the same
/// and nests keep the revision they hold. A stale revision raises
/// <see cref="DrawingLibraryConflictException"/> and changes nothing.
/// </summary>
public sealed class DrawingLibraryEditSession
{
@@ -26,42 +39,118 @@ public sealed class DrawingLibraryEditSession
public string SavedBy { get; }
/// <summary>The record to save: the listed details with new notes and settings.</summary>
public DrawingLibraryRecord Build(string notes, DrawingNestingSettings settings) => new()
/// <summary>The listed record's details, the starting point of an edit.</summary>
public DrawingLibraryDetails Details => new(Listed.Name, Listed.Customer, Listed.MaterialName,
Listed.MaterialGrade, Listed.Thickness, Listed.CutType);
/// <summary>
/// The record to save: the edited details (text trimmed, thickness rounded as stored),
/// the listed units, and the new notes and settings.
/// </summary>
public DrawingLibraryRecord Build(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings)
{
Id = Listed.Id,
Name = Listed.Name,
Customer = Listed.Customer,
MaterialName = Listed.MaterialName,
MaterialGrade = Listed.MaterialGrade,
Thickness = Listed.Thickness,
Units = Listed.Units,
CutType = Listed.CutType,
Notes = notes ?? "",
Settings = settings ?? throw new ArgumentNullException(nameof(settings)),
SavedBy = SavedBy,
};
ArgumentNullException.ThrowIfNull(details);
return new DrawingLibraryRecord
{
Id = Listed.Id,
Name = (details.Name ?? "").Trim(),
Customer = (details.Customer ?? "").Trim(),
MaterialName = (details.MaterialName ?? "").Trim(),
MaterialGrade = (details.MaterialGrade ?? "").Trim(),
Thickness = double.IsFinite(details.Thickness)
? DrawingLibraryRules.NormalizeThickness(details.Thickness)
: details.Thickness,
Units = Listed.Units,
CutType = details.CutType,
Notes = notes ?? "",
Settings = settings ?? throw new ArgumentNullException(nameof(settings)),
SavedBy = SavedBy,
};
}
/// <summary>The first rule the edit breaks, or null when it may be saved.</summary>
public string? GetError(string notes, DrawingNestingSettings settings) =>
DrawingLibraryRules.GetMetadataError(Build(notes, settings));
public string? GetError(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) =>
DrawingLibraryRules.GetMetadataError(Build(details, notes, settings));
/// <summary>True when saving would change nothing.</summary>
public bool IsUnchanged(string notes, DrawingNestingSettings settings) =>
(notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings);
public bool IsUnchanged(DrawingLibraryDetails details, string notes, DrawingNestingSettings settings) =>
SameDetails(Build(details, notes, settings), Listed)
&& (notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings);
/// <summary>Saves the edit as the next revision; returns the stored record.</summary>
public async Task<DrawingLibraryRecord> SaveAsync(string notes, DrawingNestingSettings settings,
/// <summary>
/// True when the edit changes the details that identify a library drawing (D4: customer,
/// name, material, grade, thickness and cut type, normalized as duplicates are matched).
/// </summary>
public bool ChangesIdentity(DrawingLibraryDetails details) =>
!DrawingLibraryDuplicates.SameDuplicateKey(BuildDetails(details), Listed);
/// <summary>
/// The other active library drawings the edited details would duplicate (D4), newest
/// first. Empty when the edit keeps this drawing's identity, so drawings that already
/// shared it are not reported on every save.
/// </summary>
public async Task<IReadOnlyList<DrawingLibraryRecord>> FindCollisionsAsync(DrawingLibraryDetails details,
CancellationToken cancellationToken = default)
{
var record = Build(notes, settings);
var record = BuildDetails(details);
if (!ChangesIdentity(details) || DrawingLibraryRules.GetMetadataError(record) != null)
return Array.Empty<DrawingLibraryRecord>();
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(_library, record, cancellationToken);
return matches.Where(m => m.Id != Listed.Id).ToList();
}
/// <summary>
/// The drop-down choices for this edit: the customers, materials, grades and thicknesses
/// of the active library drawings (the newest <see cref="DrawingLibrarySaveSession.MaxScannedDrawings"/>
/// when there are more) and of the listed drawing, plus <paramref name="catalog"/>'s materials.
/// </summary>
public async Task<DrawingLibraryChoices> LoadChoicesAsync(IEnumerable<MaterialConfig>? catalog = null,
CancellationToken cancellationToken = default)
{
var records = new List<DrawingLibraryRecord>();
var offset = 0;
while (offset < DrawingLibrarySaveSession.MaxScannedDrawings)
{
var page = await _library.QueryAsync(new DrawingLibraryQuery
{
Sort = DrawingSortField.SavedAt,
Descending = true,
Offset = offset,
Limit = DrawingLibraryQuery.MaxLimit,
}, cancellationToken);
records.AddRange(page.Items);
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
break;
}
records.Add(Listed);
return DrawingLibraryChoices.From(records, Listed.Units, catalog);
}
// The record with the edited details and the listed notes and settings, for identity checks.
private DrawingLibraryRecord BuildDetails(DrawingLibraryDetails details) =>
Build(details, Listed.Notes, Listed.Settings ?? new DrawingNestingSettings());
/// <summary>Saves the edit as the next revision; returns the stored record.</summary>
public async Task<DrawingLibraryRecord> SaveAsync(DrawingLibraryDetails details, string notes,
DrawingNestingSettings settings, CancellationToken cancellationToken = default)
{
var record = Build(details, notes, settings);
var error = DrawingLibraryRules.GetMetadataError(record);
if (error != null)
throw new ArgumentException(error, nameof(settings));
throw new ArgumentException(error, nameof(details));
var saved = await _library.UpdateMetadataAsync(Listed.Id, record, Listed.Revision, cancellationToken);
if (!string.Equals(saved.PackageSha256, Listed.PackageSha256, StringComparison.OrdinalIgnoreCase)
|| !SameDetails(saved, record) || saved.Units != record.Units
|| !Equals(saved.Settings, record.Settings) || saved.Notes != record.Notes)
throw new IOException("The nest server stored a different library drawing than was edited. Reload the library.");
return saved;
}
// Compares a built (trimmed, rounded) record with another, as stored.
private static bool SameDetails(DrawingLibraryRecord built, DrawingLibraryRecord other) =>
built.Name == other.Name.Trim() && built.Customer == other.Customer.Trim()
&& built.MaterialName == other.MaterialName.Trim() && built.MaterialGrade == other.MaterialGrade.Trim()
&& built.Thickness == DrawingLibraryRules.NormalizeThickness(other.Thickness)
&& built.CutType == other.CutType;
}
+3 -1
View File
@@ -1,7 +1,7 @@
namespace OpenNest.Data;
/// <summary>One bounded page of library drawing metadata.</summary>
public sealed class DrawingLibraryPage
public sealed class DrawingLibraryPage : IBrowsePage
{
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
public IReadOnlyList<DrawingLibraryRecord> Items { get; init; } = Array.Empty<DrawingLibraryRecord>();
@@ -12,4 +12,6 @@ public sealed class DrawingLibraryPage
public int Offset { get; init; }
public int Limit { get; init; }
int IBrowsePage.ItemCount => Items.Count;
}
+46 -36
View File
@@ -85,6 +85,51 @@ public sealed class DrawingLibraryDuplicates
/// <summary>True when these duplicates were found for exactly <paramref name="metadata"/>'s matching details.</summary>
public bool IsFor(DrawingLibraryRecord metadata) => SameDuplicateKey(Metadata, metadata);
/// <summary>
/// The active library drawings whose duplicate key equals <paramref name="metadata"/>'s,
/// newest first, reading every page the server filters down to; none when it is invalid.
/// </summary>
internal static async Task<List<DrawingLibraryRecord>> FindMatchesAsync(IDrawingLibrary library,
DrawingLibraryRecord metadata, CancellationToken cancellationToken)
{
var matches = new List<DrawingLibraryRecord>();
if (string.IsNullOrWhiteSpace(metadata.Name) || DrawingLibraryRules.GetMetadataError(metadata) != null)
return matches;
// The server matches customer, material and grade by the same normalized key. A
// blank one means "any" there, so every key is checked again here; the name search
// narrows by its longest word because LIKE does not collapse whitespace.
var offset = 0;
while (true)
{
var page = await library.QueryAsync(new DrawingLibraryQuery
{
Search = LongestWord(metadata.Name),
Customer = metadata.Customer,
MaterialName = metadata.MaterialName,
MaterialGrade = metadata.MaterialGrade,
Thickness = metadata.Thickness,
CutType = metadata.CutType,
Sort = DrawingSortField.SavedAt,
Descending = true,
Offset = offset,
Limit = DrawingLibraryQuery.MaxLimit,
}, cancellationToken);
matches.AddRange(page.Items.Where(r => !r.Archived && SameDuplicateKey(r, metadata)
&& matches.All(m => m.Id != r.Id)));
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
return matches;
if (offset >= DrawingLibrarySaveSession.MaxScannedDrawings)
throw new IOException($"More than {DrawingLibrarySaveSession.MaxScannedDrawings} library drawings share this name's details. Narrow the name or customer before saving.");
}
}
private static string LongestWord(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.OrderByDescending(word => word.Length)
.FirstOrDefault() ?? "";
internal static bool SameDuplicateKey(DrawingLibraryRecord a, DrawingLibraryRecord b) =>
SharedListNames.Key(a.Customer) == SharedListNames.Key(b.Customer)
&& SharedListNames.Key(a.Name) == SharedListNames.Key(b.Name)
@@ -169,37 +214,7 @@ public sealed class DrawingLibrarySaveSession
{
ArgumentNullException.ThrowIfNull(metadata);
var snapshot = Copy(metadata);
var matches = new List<DrawingLibraryRecord>();
if (!string.IsNullOrWhiteSpace(snapshot.Name) && DrawingLibraryRules.GetMetadataError(snapshot) == null)
{
// The server matches customer, material and grade by the same normalized key. A
// blank one means "any" there, so every key is checked again here; the name search
// narrows by its longest word because LIKE does not collapse whitespace.
var offset = 0;
while (true)
{
var page = await _library.QueryAsync(new DrawingLibraryQuery
{
Search = LongestWord(snapshot.Name),
Customer = snapshot.Customer,
MaterialName = snapshot.MaterialName,
MaterialGrade = snapshot.MaterialGrade,
Thickness = snapshot.Thickness,
CutType = snapshot.CutType,
Sort = DrawingSortField.SavedAt,
Descending = true,
Offset = offset,
Limit = DrawingLibraryQuery.MaxLimit,
}, cancellationToken);
matches.AddRange(page.Items.Where(r => !r.Archived && DrawingLibraryDuplicates.SameDuplicateKey(r, snapshot)
&& matches.All(m => m.Id != r.Id)));
offset += page.Items.Count;
if (page.Items.Count == 0 || offset >= page.Total)
break;
if (offset >= MaxScannedDrawings)
throw new IOException($"More than {MaxScannedDrawings} library drawings share this name's details. Narrow the name or customer before saving.");
}
}
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(_library, snapshot, cancellationToken);
DrawingLibraryRecord? linked = null;
if (Drawing.Library is { } link && link.ServerKey == ServerKey
@@ -336,11 +351,6 @@ public sealed class DrawingLibrarySaveSession
return new DrawingLibrarySaveResult(saved, outcome, recoveredEarlierSave);
}
private static string LongestWord(string name) =>
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.OrderByDescending(word => word.Length)
.FirstOrDefault() ?? "";
private static DrawingLibraryRecord Copy(DrawingLibraryRecord record) => new()
{
Id = record.Id,
+45
View File
@@ -0,0 +1,45 @@
namespace OpenNest.Data;
/// <summary>
/// Browse state for Nest &gt; Drawing Sets: the requested search, archived toggle, sort and
/// page, and the last page applied. Paging, superseding and disposal follow
/// <see cref="BrowseSession{TPage, TSort}"/>. Call from one thread (the UI thread).
/// </summary>
public sealed class DrawingSetBrowseSession : BrowseSession<DrawingSetPage, DrawingSetSortField>
{
private readonly IDrawingSetStore _sets;
public DrawingSetBrowseSession(IDrawingSetStore sets, int pageSize = DrawingSetQuery.DefaultLimit)
: base(pageSize, DrawingSetQuery.MaxLimit, DrawingSetSortField.SavedAt)
{
ArgumentNullException.ThrowIfNull(sets);
_sets = sets;
}
/// <summary>True when archived sets are listed with the active ones.</summary>
public bool IncludeArchived { get; private set; }
/// <inheritdoc />
public override string Summary =>
PageSummary("sets", Search.Length > 0 ? "No sets match the search." : "No drawing sets yet. Choose New Set to make one.");
/// <summary>Lists or hides archived sets and returns to the first page.</summary>
public Task<bool> SetIncludeArchivedAsync(bool includeArchived)
{
IncludeArchived = includeArchived;
return FirstPageAsync();
}
protected override Task<DrawingSetPage> QueryAsync(CancellationToken cancellationToken) =>
_sets.QueryAsync(
new DrawingSetQuery
{
Search = Search,
IncludeArchived = IncludeArchived,
Sort = Sort,
Descending = Descending,
Offset = Offset,
Limit = PageSize,
},
cancellationToken);
}
+241
View File
@@ -0,0 +1,241 @@
namespace OpenNest.Data;
/// <summary>What adding library drawings to a set did.</summary>
public sealed class DrawingSetAddResult
{
internal DrawingSetAddResult(IReadOnlyList<DrawingLibraryRecord> added, IReadOnlyList<DrawingLibraryRecord> alreadyMembers)
{
Added = added;
AlreadyMembers = alreadyMembers;
}
public IReadOnlyList<DrawingLibraryRecord> Added { get; }
/// <summary>Drawings left out because the set already lists them (S6: one member per drawing).</summary>
public IReadOnlyList<DrawingLibraryRecord> AlreadyMembers { get; }
}
/// <summary>
/// Edits one drawing set (new or read from the server): its name, customer and notes and its
/// ordered members, each one library drawing with a quantity. A save of an existing set is
/// guarded by the revision it was read at, so a stale save raises
/// <see cref="DrawingSetSaveConflictException"/> and changes nothing.
/// <para>
/// A new set's id is allocated here, before the first save. When that save fails without a
/// definite answer it stays pending: the next save first reads the id, adopts the stored set
/// if it is there (then saves any later change as its next revision), and otherwise creates it
/// again under the same id. A new set is therefore never stored twice.
/// </para>
/// </summary>
public sealed class DrawingSetEditSession
{
private readonly IDrawingSetStore _sets;
private readonly List<DrawingSetMember> _members = new();
private bool _pendingCreate;
/// <param name="current">A set read with <see cref="IDrawingSetStore.GetAsync"/>, or null for a new set.</param>
public DrawingSetEditSession(IDrawingSetStore sets, DrawingSetRecord? current, string savedBy)
{
_sets = sets ?? throw new ArgumentNullException(nameof(sets));
SavedBy = savedBy ?? "";
if (current == null)
{
Id = Guid.NewGuid();
return;
}
if (current.Id == Guid.Empty || current.Revision < 1)
throw new ArgumentException("Choose a set read from the server to edit.", nameof(current));
if (current.Archived)
throw new ArgumentException("An archived set cannot be edited. Restore it first.", nameof(current));
if (current.Members is null || current.Members.Any(m => m?.Drawing is null))
throw new ArgumentException("The set's members are missing their library drawings. Reload the set.", nameof(current));
Id = current.Id;
Load(current);
}
public Guid Id { get; }
/// <summary>The store the set is read from and saved to; also reads its history.</summary>
public IDrawingSetStore Store => _sets;
public string SavedBy { get; }
/// <summary>The set as last read or saved; null until a new set is first saved.</summary>
public DrawingSetRecord? Saved { get; private set; }
public bool IsNew => Saved == null;
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
public string Notes { get; set; } = "";
/// <summary>The members in order. Each carries the library record it was added or read with.</summary>
public IReadOnlyList<DrawingSetMember> Members => _members;
/// <summary>An earlier save failed without a definite answer; the next save checks it first.</summary>
public bool HasPendingSave => _pendingCreate;
/// <summary>
/// Appends each drawing not already in the set, in order, with <paramref name="quantity"/>.
/// Drawings the set already lists (or listed twice in <paramref name="drawings"/>) are skipped.
/// </summary>
public DrawingSetAddResult AddDrawings(IEnumerable<DrawingLibraryRecord> drawings, int quantity = 1)
{
ArgumentNullException.ThrowIfNull(drawings);
if (quantity < 1 || quantity > DrawingSetRules.MaxQuantity)
throw new ArgumentOutOfRangeException(nameof(quantity), quantity, $"Quantity must be 1 to {DrawingSetRules.MaxQuantity}.");
var added = new List<DrawingLibraryRecord>();
var skipped = new List<DrawingLibraryRecord>();
foreach (var drawing in drawings)
{
if (drawing is null || drawing.Id == Guid.Empty)
throw new ArgumentException("Only library drawings can be added to a set.", nameof(drawings));
if (_members.Any(m => m.DrawingId == drawing.Id))
{
skipped.Add(drawing);
continue;
}
_members.Add(new DrawingSetMember { DrawingId = drawing.Id, Quantity = quantity, Drawing = drawing });
added.Add(drawing);
}
return new DrawingSetAddResult(added, skipped);
}
public void RemoveAt(int index)
{
CheckIndex(index);
_members.RemoveAt(index);
}
/// <summary>Moves a member by <paramref name="offset"/> places; false when it is already at that end.</summary>
public bool Move(int index, int offset)
{
CheckIndex(index);
var target = index + offset;
if (target < 0 || target >= _members.Count || offset == 0)
return false;
var member = _members[index];
_members.RemoveAt(index);
_members.Insert(target, member);
return true;
}
public void SetQuantity(int index, int quantity)
{
CheckIndex(index);
if (quantity < 1 || quantity > DrawingSetRules.MaxQuantity)
throw new ArgumentOutOfRangeException(nameof(quantity), quantity, $"Quantity must be 1 to {DrawingSetRules.MaxQuantity}.");
_members[index].Quantity = quantity;
}
/// <summary>
/// Shows <paramref name="drawing"/>, a newer revision of a member's library drawing (for
/// example one just edited), on that member. Members are saved by id, so the set itself is
/// not changed; false when no member lists the drawing or it already shows that revision.
/// </summary>
public bool UpdateDrawing(DrawingLibraryRecord drawing)
{
ArgumentNullException.ThrowIfNull(drawing);
var member = _members.FirstOrDefault(m => m.DrawingId == drawing.Id);
if (member == null || (member.Drawing != null && member.Drawing.Revision >= drawing.Revision))
return false;
member.Drawing = drawing;
return true;
}
/// <summary>The record a save sends.</summary>
public DrawingSetRecord Build() => new()
{
Id = Id,
Name = Name ?? "",
Customer = Customer ?? "",
Notes = Notes ?? "",
SavedBy = SavedBy,
Members = _members.Select(m => new DrawingSetMember { DrawingId = m.DrawingId, Quantity = m.Quantity, Drawing = m.Drawing }).ToList(),
};
/// <summary>The first rule the set breaks, or null when it may be saved.</summary>
public string? GetError() => DrawingSetRules.GetError(Build());
/// <summary>True when saving would store something the server does not already hold.</summary>
public bool IsChanged => Saved == null || !SameContent(Saved, Build());
/// <summary>
/// Saves the set: revision 1 for a new set, otherwise the next revision when the one it was
/// read at is still current. On success the session holds the stored record and its members.
/// </summary>
public async Task<DrawingSetRecord> SaveAsync(CancellationToken cancellationToken = default)
{
var record = Build();
var error = DrawingSetRules.GetError(record);
if (error != null)
throw new ArgumentException(error);
if (_pendingCreate && await _sets.GetAsync(Id, cancellationToken) is { } stored)
Adopt(stored);
DrawingSetRecord saved;
if (Saved == null)
{
_pendingCreate = true;
try
{
saved = await _sets.CreateAsync(record, cancellationToken);
}
catch (DrawingSetSaveConflictException conflict)
{
// The id is this session's own: an earlier save whose reply was lost was stored.
Adopt(conflict.Current);
if (!IsChanged)
{
Load(conflict.Current);
return conflict.Current;
}
saved = await _sets.UpdateAsync(Id, record, conflict.Current.Revision, cancellationToken);
}
}
else
{
if (!IsChanged)
return Saved;
saved = await _sets.UpdateAsync(Id, record, Saved.Revision, cancellationToken);
}
_pendingCreate = false;
Load(saved);
return saved;
}
private void Adopt(DrawingSetRecord stored)
{
_pendingCreate = false;
Saved = stored;
}
private void Load(DrawingSetRecord record)
{
Saved = record;
Name = record.Name;
Customer = record.Customer;
Notes = record.Notes;
_members.Clear();
_members.AddRange(record.Members.Select(m => new DrawingSetMember { DrawingId = m.DrawingId, Quantity = m.Quantity, Drawing = m.Drawing }));
}
private void CheckIndex(int index)
{
if (index < 0 || index >= _members.Count)
throw new ArgumentOutOfRangeException(nameof(index));
}
// The server's identical-save rule: name, customer, notes and members in order.
private static bool SameContent(DrawingSetRecord a, DrawingSetRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.Notes == b.Notes
&& a.Members.Select(m => (m.DrawingId, m.Quantity)).SequenceEqual(b.Members.Select(m => (m.DrawingId, m.Quantity)));
}
+319
View File
@@ -0,0 +1,319 @@
using System.Security.Cryptography;
namespace OpenNest.Data;
/// <summary>A drawing to import into a set: its library details, package and quantity per set.</summary>
public sealed class DrawingSetImportItem
{
public DrawingSetImportItem(string label, DrawingLibraryRecord metadata, byte[] package, long quantity)
{
Label = label ?? "";
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Package = package ?? throw new ArgumentNullException(nameof(package));
Quantity = quantity;
}
/// <summary>How the operator knows the drawing, such as "PT01 (items 3, 7)".</summary>
public string Label { get; }
/// <summary>The details a new library drawing gets; customer, name, material, grade, thickness, units and cut type are its identity (D4).</summary>
public DrawingLibraryRecord Metadata { get; }
/// <summary>The canonical drawing package, compared by geometry with the library's.</summary>
public byte[] Package { get; }
public long Quantity { get; }
}
/// <summary>What an import does with one drawing.</summary>
public enum DrawingSetImportAction
{
/// <summary>An active library drawing with the same identity holds the same geometry: it is used as it is.</summary>
Reuse,
/// <summary>One active library drawing has the same identity but other geometry: it gains the next revision.</summary>
Revise,
/// <summary>No active library drawing has this identity: a new one is uploaded.</summary>
Create,
/// <summary>The drawing cannot be imported; <see cref="DrawingSetImportEntry.Reason"/> says why.</summary>
Skip,
}
/// <summary>One planned drawing: the action and the library drawing it uses or revises.</summary>
public sealed class DrawingSetImportEntry
{
internal DrawingSetImportEntry(DrawingSetImportItem item, DrawingSetImportAction action,
DrawingLibraryRecord? existing, string reason, Guid newId = default)
{
Item = item;
Action = action;
Existing = existing;
Reason = reason;
NewId = newId;
}
public DrawingSetImportItem Item { get; }
public DrawingSetImportAction Action { get; }
/// <summary>The library drawing reused or revised, as listed when planned.</summary>
public DrawingLibraryRecord? Existing { get; }
/// <summary>Why a skipped drawing is left out; empty otherwise.</summary>
public string Reason { get; }
/// <summary>The id a created drawing is uploaded under, allocated when planned.</summary>
public Guid NewId { get; }
/// <summary>The operator-facing account of the action, such as "New revision 4 (geometry changed)".</summary>
public string Describe() => Action switch
{
DrawingSetImportAction.Reuse => $"In the library (revision {Existing!.Revision})",
DrawingSetImportAction.Revise => $"New revision {Existing!.Revision + 1} (geometry changed)",
DrawingSetImportAction.Create => "New library drawing",
_ => "Not imported: " + Reason,
};
}
/// <summary>A library write failed part-way; <see cref="Written"/> lists what was stored before it.</summary>
public sealed class DrawingSetImportException : IOException
{
internal DrawingSetImportException(string message, IReadOnlyList<DrawingLibraryRecord> written, Exception inner)
: base(message, inner)
{
Written = written;
}
public IReadOnlyList<DrawingLibraryRecord> Written { get; }
}
/// <summary>
/// Imports drawings (for example a BOM's) into a new drawing set through the library.
/// <see cref="PlanAsync"/> only reads: it matches each drawing to the active library
/// drawings with its identity (D4: normalized customer, name, material and grade, exact
/// thickness, units and cut type) and compares geometry. Same identity and
/// geometry: reuse the drawing. Same identity, other geometry: the next revision of that
/// drawing. No match: a new drawing. Several matches with none holding the geometry are
/// skipped, as are invalid items and a second item with the identity of an earlier one.
/// <para>
/// <see cref="ApplyAsync"/> is the only step that writes, and only the uploads and
/// revisions the plan lists; the caller confirms them first. It then adds every
/// imported drawing to a <see cref="DrawingSetEditSession"/>, which stores nothing
/// until the operator saves the set. A failed write stops the import with the drawings
/// already stored listed; planning the same drawings again reuses them.
/// </para>
/// </summary>
public sealed class DrawingSetImport
{
private DrawingSetImport(IReadOnlyList<DrawingSetImportEntry> entries)
{
Entries = entries;
}
/// <summary>Every item, in the order given.</summary>
public IReadOnlyList<DrawingSetImportEntry> Entries { get; }
public IEnumerable<DrawingSetImportEntry> Imported => Entries.Where(e => e.Action != DrawingSetImportAction.Skip);
public int Count(DrawingSetImportAction action) => Entries.Count(e => e.Action == action);
/// <summary>True when applying stores anything in the library.</summary>
public bool HasWrites => Entries.Any(e => e.Action is DrawingSetImportAction.Create or DrawingSetImportAction.Revise);
/// <param name="geometryKey">
/// A key equal for packages with the same geometry (<c>OpenNest.IO.DrawingPackage.GeometryKey</c>):
/// a file read from another folder records another path, and older packages carry random
/// entity ids, so equal hashes are not required. It may
/// throw <see cref="InvalidDataException"/> for an unreadable package.
/// </param>
public static async Task<DrawingSetImport> PlanAsync(IDrawingLibrary library, IEnumerable<DrawingSetImportItem> items,
Func<byte[], string> geometryKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(geometryKey);
var entries = new List<DrawingSetImportEntry>();
var planned = new List<DrawingSetImportItem>();
foreach (var item in items)
{
var reason = DrawingLibraryRules.GetMetadataError(item.Metadata)
?? DrawingLibraryRules.GetPackageError(item.Package)
?? (item.Quantity < 1 || item.Quantity > DrawingSetRules.MaxQuantity
? $"quantity {item.Quantity:N0} is outside 1 to {DrawingSetRules.MaxQuantity:N0}"
: null);
var twin = planned.FirstOrDefault(p => DrawingLibraryDuplicates.SameDuplicateKey(p.Metadata, item.Metadata));
if (reason == null && twin != null)
reason = $"same name and details as {twin.Label}; give one of them another material or thickness";
if (reason != null)
{
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null, reason));
continue;
}
planned.Add(item);
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(library, item.Metadata, cancellationToken);
var same = await FindSameGeometryAsync(library, item, matches, geometryKey, cancellationToken);
if (same != null)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Reuse, same, ""));
else if (matches.Count == 1)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Revise, matches[0], ""));
else if (matches.Count > 1)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null,
$"{matches.Count} library drawings have these details; add the right one in the set editor"));
else
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Create, null, "", Guid.NewGuid()));
}
return new DrawingSetImport(entries);
}
// The first match holding the item's geometry: by package hash, otherwise by downloading
// the match's current package and comparing geometry keys.
private static async Task<DrawingLibraryRecord?> FindSameGeometryAsync(IDrawingLibrary library,
DrawingSetImportItem item, IReadOnlyList<DrawingLibraryRecord> matches, Func<byte[], string> geometryKey,
CancellationToken cancellationToken)
{
if (matches.Count == 0)
return null;
var hash = Sha256(item.Package);
var same = matches.FirstOrDefault(m => string.Equals(m.PackageSha256, hash, StringComparison.OrdinalIgnoreCase));
if (same != null)
return same;
var key = geometryKey(item.Package);
foreach (var match in matches)
{
var stored = await library.GetRevisionPackageAsync(match.Id, match.Revision, cancellationToken);
if (stored == null)
continue;
try
{
if (geometryKey(stored) == key)
return match;
}
catch (InvalidDataException)
{
// An unreadable stored package is not the same geometry; revising replaces it.
}
}
return null;
}
/// <summary>The library changes <see cref="ApplyAsync"/> makes, one line each, for the operator to confirm.</summary>
public string DescribeWrites()
{
var lines = new List<string>();
var created = Entries.Where(e => e.Action == DrawingSetImportAction.Create).ToList();
var revised = Entries.Where(e => e.Action == DrawingSetImportAction.Revise).ToList();
if (created.Count > 0)
{
lines.Add($"{created.Count} new library drawing{(created.Count == 1 ? "" : "s")}:");
lines.AddRange(created.Select(e => "- " + e.Item.Label));
}
if (revised.Count > 0)
{
if (lines.Count > 0)
lines.Add("");
lines.Add($"{revised.Count} new revision{(revised.Count == 1 ? "" : "s")} of library drawings whose geometry changed:");
lines.AddRange(revised.Select(e => $"- {e.Item.Label}: revision {e.Existing!.Revision} to {e.Existing.Revision + 1}"));
}
return string.Join(Environment.NewLine, lines);
}
/// <summary>
/// Stores the planned uploads and revisions, in order, then adds every imported drawing
/// to <paramref name="edit"/> at its quantity. A revision is guarded by the revision it
/// was planned against and keeps that drawing's details, notes and settings. When a
/// write fails, <paramref name="edit"/> is left unchanged and a
/// <see cref="DrawingSetImportException"/> lists what was stored before it.
/// </summary>
public async Task<IReadOnlyList<DrawingLibraryRecord>> ApplyAsync(IDrawingLibraryStore library,
DrawingSetEditSession edit, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(edit);
var written = new List<DrawingLibraryRecord>();
var members = new List<(DrawingLibraryRecord Record, int Quantity)>();
foreach (var entry in Imported)
{
DrawingLibraryRecord record;
try
{
record = entry.Action switch
{
DrawingSetImportAction.Create => await library.UploadAsync(Created(entry), entry.Item.Package, cancellationToken),
DrawingSetImportAction.Revise => await library.UpdatePackageAsync(entry.Existing!.Id,
Revised(entry.Existing, entry.Item.Metadata.SavedBy), entry.Item.Package, entry.Existing.Revision, cancellationToken),
_ => entry.Existing!,
};
}
catch (Exception ex) when (ex is IOException or HttpRequestException or TaskCanceledException)
{
var stored = written.Count == 0
? "Nothing was stored in the library."
: $"Already stored: {string.Join(", ", written.Select(r => r.Name))}. Importing again reuses them.";
throw new DrawingSetImportException(
$"Could not store '{entry.Item.Label}' in the drawing library: {ex.Message}{Environment.NewLine}{stored}",
written, ex);
}
var expectedId = entry.Action == DrawingSetImportAction.Create ? entry.NewId : entry.Existing!.Id;
if (record.Id != expectedId)
throw new DrawingSetImportException(
$"The nest server returned a different library drawing than '{entry.Item.Label}'. Import again to check what was stored.",
written, new IOException("Unexpected drawing id."));
if (entry.Action != DrawingSetImportAction.Reuse)
written.Add(record);
members.Add((record, (int)entry.Item.Quantity));
}
foreach (var (record, quantity) in members)
edit.AddDrawings(new[] { record }, quantity);
return written;
}
private static DrawingLibraryRecord Created(DrawingSetImportEntry entry)
{
var record = Copy(entry.Item.Metadata);
record.Id = entry.NewId;
record.Revision = 0;
record.Thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
record.Archived = false;
return record;
}
// The identity already matches; keep the stored spelling, notes and settings.
private static DrawingLibraryRecord Revised(DrawingLibraryRecord existing, string savedBy)
{
var record = Copy(existing);
record.Revision = 0;
record.SavedBy = savedBy;
return record;
}
private static DrawingLibraryRecord Copy(DrawingLibraryRecord record) => new()
{
Id = record.Id,
Revision = record.Revision,
Name = record.Name,
Customer = record.Customer,
MaterialName = record.MaterialName,
MaterialGrade = record.MaterialGrade,
Thickness = record.Thickness,
Units = record.Units,
CutType = record.CutType,
Notes = record.Notes,
Settings = record.Settings,
Archived = record.Archived,
SavedBy = record.SavedBy,
};
private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
+15
View File
@@ -0,0 +1,15 @@
namespace OpenNest.Data;
/// <summary>One drawing of a set and how many of it the set needs.</summary>
public sealed class DrawingSetMember
{
public Guid DrawingId { get; set; }
public int Quantity { get; set; } = 1;
/// <summary>
/// The drawing's current record, archived or not, joined by the server when it reads
/// a set. Ignored on save.
/// </summary>
public DrawingLibraryRecord? Drawing { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
namespace OpenNest.Data;
/// <summary>One bounded page of drawing set records, without their members.</summary>
public sealed class DrawingSetPage : IBrowsePage
{
/// <summary>At most <see cref="Limit"/> records, in the requested order.</summary>
public IReadOnlyList<DrawingSetRecord> Items { get; init; } = Array.Empty<DrawingSetRecord>();
/// <summary>Number of records matching the request across all pages.</summary>
public int Total { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
int IBrowsePage.ItemCount => Items.Count;
}
+48
View File
@@ -0,0 +1,48 @@
namespace OpenNest.Data;
/// <summary>
/// One bounded page request for browsing drawing sets (<c>GET /api/sets/query</c>).
/// The server filters and pages in SQL; the client and server share these bounds.
/// </summary>
public sealed class DrawingSetQuery
{
public const int DefaultLimit = 100;
public const int MaxLimit = 500;
public const int MaxSearchLength = 200;
/// <summary>
/// Case-insensitive (ASCII) substring of name, customer or notes. Blank means no filter.
/// </summary>
public string Search { get; init; } = "";
/// <summary>Exact customer, compared by <see cref="SharedListNames.Key"/>. Blank means any.</summary>
public string Customer { get; init; } = "";
public bool IncludeArchived { get; init; }
/// <summary>Column to order by; ties are broken by id in the same direction.</summary>
public DrawingSetSortField Sort { get; init; } = DrawingSetSortField.SavedAt;
/// <summary>Largest/newest/Z first when true (the default, newest saved first).</summary>
public bool Descending { get; init; } = true;
public int Offset { get; init; }
public int Limit { get; init; } = DefaultLimit;
/// <summary>The search text as matched: trimmed, never null.</summary>
public string NormalizedSearch => (Search ?? "").Trim();
/// <summary>The first violated bound, or null when the query may be sent.</summary>
public string? GetValidationError()
{
if (!Enum.IsDefined(Sort))
return "sort must be a supported column.";
if (Offset < 0)
return "offset must be zero or greater.";
if (Limit < 1 || Limit > MaxLimit)
return $"limit must be between 1 and {MaxLimit}.";
return DrawingLibraryRules.TextError("search", NormalizedSearch, MaxSearchLength)
?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength);
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace OpenNest.Data;
/// <summary>
/// One revision of a drawing set: a named list of library drawings with per-member
/// quantities (a kit, an assembly, a recurring order). Members reference drawings by id;
/// the set always means each drawing's current revision.
/// </summary>
public sealed class DrawingSetRecord
{
/// <summary>Allocated by the client before the first save; kept by every revision.</summary>
public Guid Id { get; set; }
/// <summary>Immutable revision number, starting at 1. Ignored on save.</summary>
public int Revision { get; set; }
public string Name { get; set; } = "";
public string Customer { get; set; } = "";
public string Notes { get; set; } = "";
/// <summary>Hidden from browsing by default; still readable by id.</summary>
public bool Archived { get; set; }
/// <summary>When the server stored this revision. Server-computed.</summary>
public DateTime SavedAt { get; set; }
/// <summary>Who saved this revision, as reported by the client.</summary>
public string SavedBy { get; set; } = "";
/// <summary>Number of members in this revision. Server-computed.</summary>
public int MemberCount { get; set; }
/// <summary>
/// The members in order, one per drawing. Empty on query and history pages, which
/// carry <see cref="MemberCount"/> only.
/// </summary>
public IReadOnlyList<DrawingSetMember> Members { get; set; } = Array.Empty<DrawingSetMember>();
}
Loaded 100 of 622 files, more files were not shown because too many files have changed in this diff. Show more