571 Commits
Author SHA1 Message Date
aj 99e8dc3d0e feat(avalonia): set the offset and rotation increments
Tools > Set Offset Increment (F5) and Set Rotation Increment (F6) open
the WinForms SetValueForm's dialog: an "Increment :" box shown with
four decimals and a Set button that Enter also presses; Esc or closing
the window changes nothing.

- The offset dialog has the "Calculated distance (selection size +
  part spacing)" check box, which turns the value box off; with it on,
  arrow keys step the selection by its size plus the part spacing.
- The rotation increment goes up to 360 degrees; Shift+wheel turns by
  it and Ctrl+Shift+wheel by a tenth of it.
- A typed value out of range is taken to the nearest limit and an
  emptied box keeps the last value it held, as in WinForms.

Each nest keeps its own increments for the session, starting at 10, as
each WinForms nest window does; they are not saved. With no nest open
both commands are off (WinForms hides them).
2026-11-14 21:05:25 -05:00
aj f061fcacea feat(avalonia): delete, nudge, push, rotate and drag parts on the plate
The plate view edits the selection through the shared PlateEdits and
Select tool, recording each edit in its nest tab's undo history under
the WinForms labels:

- Delete (with any modifiers) removes the selected parts and cut-offs.
- Arrow keys nudge by 10; repeated nudges undo as one Nudge.
- Shift+arrows, X, Shift+X, Shift+Y and Y push to contact (Ctrl+X does
  nothing until Cut comes with the place tool).
- A middle click (moved under 5 px) turns the selection 90 degrees, a
  double-click 180; a middle drag still pans, and a middle double-click
  fits only with nothing selected.
- Shift+wheel turns it by 10 degrees per notch, Ctrl+Shift+wheel by 1,
  away from the user clockwise; with nothing selected the wheel zooms.
- Dragging a selected part moves the selection as one Move; losing the
  pointer mid-drag ends it there, and a middle click during the drag
  turns the selection without ending it.

As in WinForms, each nest keeps a plate manager that keeps one empty
plate at the end inside the edit's own step, so deleting every part on
the last full plate undoes in one step. Each nest's cut-offs regenerate
with its own cut-off settings (saved head lock in the nest's units,
then the machine's cut-off defaults), read again before Undo or Redo
swaps a nest in. The Plates and Drawings lists rebuild after the step
closes when their counts changed, keeping the plate, zoom and
selection. While Auto Nest runs on a nest its plate takes no edits, as
the WinForms modal progress window prevents them.
2026-11-14 20:31:13 -05:00
aj 203945dabf feat(desktop): share part delete, nudge, push, rotate and drag-move edits
PlateEdits ports the WinForms plate view's edits of the selection, with
its undo labels and coalescing: Delete (parts and cut-offs, cut-offs
regenerated once when the plate has any), Nudge by the offset increment
or one repeat step, Push to contact, Rotate about the selection's
centre keeping its lower-left corner (a quarter turn, or by wheel
notches at 10 degrees or a tenth of that), repeated nudges and wheel
turns undoing as one step.

The Select tool drags the selection as one Move step. Cut-offs
regenerate when a drag actually moved parts (WinForms also does it on a
plain click, adding an extra undo step); the regeneration runs before
the step completes, so a failure rolls the drag back, and a view that
loses the pointer mid-drag ends the move as a release would, keeping
the selection.

A plate tool host now has to supply its undo wiring, the nest's cut-off
settings and an edits lock (WinForms EditsLocked): while locked no edit
runs and no press selects or starts a move.
2026-11-14 20:26:01 -05:00
aj e2a27df7ef feat(avalonia): open a new nest on startup and convert the old nest template
Once the main window opens, the Avalonia app runs the shared start-up
steps as the WinForms main window does on load: a legacy nest template
(NestTemplatePath) becomes the nest defaults file, and Tools > Options >
Create a new nest on startup (on unless turned off) opens a new nest,
which until now that checkbox only saved.

A failed template conversion shows the WinForms "Nest Template" warning
through the queue the load warnings use; the new nest opens only once
every queued warning has closed. File > New's "Nest Defaults" warning
now goes through the same queue, so it never stacks on a load warning,
and the queue stops once the window closes. New beside WinForms, which
takes no nest paths on its command line and converts synchronously: no
new nest is added when a nest is already open by then (named on the
command line, or opened or dropped while the template converted), start-
up stops when the window closes (checked again just before the nest
number is taken) or asks about unsaved nests meanwhile, and --snapshot
runs neither step, so an unattended render changes no settings and takes
no nest number.
2026-11-14 19:32:24 -05:00
aj 7bd44ec4c4 feat(desktop): share the start-up template migration and new-nest choice
DesktopStartup holds the WinForms main window's start-up steps in its
order for the Avalonia app: first the one-time conversion of the legacy
nest template into the nest defaults file, then whether Tools > Options >
Create a new nest on startup asks for a new nest.

Run reads the shared user settings but never writes them, so it can run
off the UI thread; it reports whether to clear NestTemplatePath, and
ClearTemplateSetting empties that setting alone on the caller's (UI)
thread, as the window's other settings writers run there.
2026-11-14 19:10:27 -05:00
ajandClaude Fable 5.2 c55187909d feat(storage): pick the nest server from a named saved list
Storage Mode and Tools > Options now share a server picker: a Saved
dropdown of every server this PC has used, shown as "Name — URL", with a
Name box for a note on what each one is for and a Forget button.

storage.json keeps the list as `servers`; setting a server adds or
renames its entry, and an existing install lists its current server
unnamed on first load.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 10:06:55 -05:00
ajandClaude Fable 5.2 8a1babbfb5 fix(shape-library): keep dimensions in place on the part when zooming
The dimension line gap, leader clearance and shoulder, and a dragged
standoff were fixed screen pixels, so zooming moved them relative to the
part. They now scale with the zoom, measured from the fitted view.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 10:00:48 -05:00
ajandClaude Fable 5.2 536f7f7fd7 feat(shapes): keep diameter and radius leaders aimed at the circle center
Dragging a Shape Library leader on a bolt circle, hole or corner arc now
moves its arrow round the curve so the line stays radial. The flange's
bolt circle is drawn dashed in teal instead of dotted orange, which read
as a solid dimension line.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-14 09:59:10 -05:00
ajandClaude Opus 5.5 8e79a416ed fix(avalonia): use the WinForms workspace gray for the empty background
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:07:07 -05:00
ajandClaude Opus 5.5 5ead16bffe feat(avalonia): drop Export Nest Report to match WinForms
WinForms removed the direct-save command in ecd0bba4; the system PDF
viewer that Nest Report opens already saves and prints the report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:41:55 -05:00
aj aea5e9f617 Merge remote-tracking branch 'origin/master' into claude/avalonia-port-next-tasks-f1f6b6 2026-11-13 20:25:34 -05:00
ajandClaude Opus 5.5 267c7e9453 Merge M09 selection into the Avalonia undo work
Combine the Edit menu (Undo, Redo, Select All) and restore the whole
part selection by index after an Undo or Redo, now that the canvas
selects several parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:25:15 -05:00
aj d6a551fdea Merge branch 'worktree-agent-afafee97ace7d9df4' into claude/avalonia-port-next-tasks-f1f6b6 2026-11-13 20:22:37 -05:00
aj e975244263 Merge branch 'worktree-agent-aec6db3a2c226fa2a' into claude/avalonia-port-next-tasks-f1f6b6 2026-11-13 20:22:37 -05:00
ajandClaude Opus 5.5 dc8023f076 feat(avalonia): select parts and cut-offs by click, window and Select All
The plate view routes left and right clicks to the shared Select tool,
draws the selection, the blue/green selection window and selected
cut-offs as WinForms does, pans with a middle drag and handles Escape.
Edit > Select All (Ctrl+A) is added, and the status bar shows the tool
and the selection's bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:30:50 -05:00
ajandClaude Opus 5.5 313b5f57d6 feat(desktop): add a toolkit-neutral plate tool and selection model
PlateToolManager keeps one active tool with the WinForms action rules
(Escape cancels, then returns to Select, then resumes the ended tool) and
status text. SelectTool selects by click, Ctrl+click and window
(contains/intersect), takes common-line groups together and picks
cut-offs by their strokes. PlateHitTester hit-tests the shared display
geometry; PlateSelection reads and sets by part index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:30:47 -05:00
ajandClaude Opus 5.5 d0654d267c feat(avalonia): export plates, view in CAD and nest reports
File > Export Plate... and Export All Plates... write the shown plate (each
plate in turn, shown while its file is chosen; cancel or a failure stops
the rest) as DXF, the view's image, the placement list or SVG, chosen by
the picked file's extension, under the WinForms suggested names. The image
is the plate canvas drawn by its Skia painter at the screen's density, PNG
or JPEG. Plate > View in CAD writes <nest>-P<n>.dxf to the temp folder and
opens it in the system's CAD program. File > Nest Report... writes the PDF
to a private temp folder and opens it in the system's PDF viewer, which
saves and prints it (older viewed reports are removed); File > Export Nest
Report... saves it to a chosen file. Reports refuse while Auto Nest runs on
the nest and are written off the UI thread from a detached snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:28:39 -05:00
ajandClaude Opus 5.5 7792badac3 feat(desktop): report opener failures and drop bitmap plate exports
ExternalFileLauncher.OpenAsync waits for xdg-open or open without blocking
and explains a failed exit code (no program set up for the file type, file
not found), which the shell-less platforms otherwise swallow. File > Export
no longer takes .bmp as an image: no cross-platform encoder writes it, so
the image formats are .png, .jpg and .jpeg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:28:39 -05:00
ajandClaude Opus 5.5 ad6ffa1fda feat(avalonia): undo and redo nest edits from the Edit menu
Edit > Undo/Redo name the next step and follow the active tab's history;
Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z undo the nest unless a text box has focus.
A restore keeps the plate, zoom, pan and selection. Auto Nest's commit is
one undo step and blocks Undo on its nest while it runs. DEBUG builds warn
about edits made outside a change scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:27:07 -05:00
ajandClaude Opus 5.5 098e1d92a7 refactor(desktop): share plate export, file launching and nest report writing
Move the toolkit-neutral parts of File > Export/Export All, Plate > View
in CAD and the nest report commands into OpenNest.Desktop: export formats
and suggested per-plate file names (now free of characters any platform
refuses), the placement list, DXF/SVG writing with the view image left to
the front end, opening a file in its associated program on Windows, macOS
and Linux, the report file name check, capture with catalog density, the
machine's thickness name and saved cut-time settings, and viewed-report
temp folders. WinForms calls the shared code; its behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:27:07 -05:00
ajandClaude Opus 5.5 9c0a543d79 feat(desktop): give each nest tab its own undo history
NestTab starts a NestUndoRecorder once the trailing empty plate is in
place, so opening is not an edit. Modified now follows the history's save
point and unscoped edits (undoing back to the saved state clears it), and
SaveAs records an unscoped edit before marking the save point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:27:07 -05:00
ajandClaude Opus 5.5 ef7ebd2f44 feat(avalonia): add Tools > Options on the shared Options model
Auto-size factor, colour scheme, cut planning effort, clone push
placement, new nest on startup and the fill strategy grid save to the
user settings both apps share and apply at once (open nests take the new
scheme's colours); the saved fill strategies apply at startup. Set /
Change Server writes storage.json keeping the storage mode, and the next
server request uses it; Save Feed URL and Include development builds
save for the updater. MaterialLedger stays in the WinForms app: its API
key is DPAPI-protected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 19:25:15 -05:00
ajandClaude Fable 5.2 07f30c1236 fix(engine): settle best-fit pairs sideways so they close to the part spacing
The pair search slid part 2 in from offsets on a 0.25 step grid, so a pair
that locks in a corner (two interlocking channels) stopped up to a step short
of the tightest fit. Each resting position is now also slid sideways, both
ways, and the new resting positions join the candidates.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 18:25:25 -05:00
ajandClaude Opus 5.5 9663f341dd refactor(desktop): share undo recording and stepping as NestUndoRecorder
Scopes, the fingerprint safety net, Undo/Redo stepping, the unsaved check,
identity carry-over and the Edit menu labels move from EditNestForm to
OpenNest.Desktop. The WinForms window keeps only its rebinding swap-in and
its guards; behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 17:44:39 -05:00
ajandClaude Opus 5.5 fc5c2c9469 refactor(winforms): run the Options form on the shared Options model
Same settings, messages and apply order; the form only maps controls to
Properties.Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 17:43:20 -05:00
ajandClaude Opus 5.5 9a55b00d2c feat(desktop): share the Options settings model with both desktop apps
DesktopOptions reads and writes the Tools > Options values under the
WinForms user-setting names, with the clone placement choices, the fill
strategy grid rows and DisabledStrategies parsing/applying, and the
server URL and update feed URL validation and messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 17:43:20 -05:00
ajandClaude Opus 5.5 09357ca95d feat(avalonia): show the dark OpenNest workspace when no nest is open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 17:33:42 -05:00
ajandClaude Opus 5.5 7f1cff301a feat(avalonia): create new nests from File > New, Ctrl+N, New and +
Move the File > New rules into OpenNest.Desktop (NewNestSource): nest
defaults, the default machine with its clamps and support grid, Made By
and the year's next name, from the nest server in Database mode and the
counter both apps keep in user-settings.json otherwise. The WinForms
factory now delegates to it.

The Avalonia app opens a new nest in a tab with no file (NestTabs.Add),
off the UI thread and one at a time, and its nest view keeps an empty
last plate as the WinForms nest window does, so a new nest shows Plate 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 17:31:18 -05:00
ajandClaude Fable 5.2 9b7082e92f fix(clone): keep Shift+click copies at the part spacing
The coarse bounding-box pass only stopped at obstacles whose box lay in
the push path, so a copy could drop past a neighbor beside the path and
end inside its spacing. A box push now also stops at an obstacle nearer
than the spacing to the side of the path.

A push ignores a part the copy starts on, so a settle could slide out of
that part and stop clear of it yet inside its spacing. SettlePlacement
now prefers a push order that keeps the part spacing, and a named order
that ends too close falls back to the other order.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-13 15:43:47 -05:00
ajandClaude Fable 5.2 32fc3cad97 fix(core): color drawings from the classic palette when a scheme has no part colors
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:54:48 -05:00
ajandClaude Fable 5.2 3b182b8b57 fix(training): send the schema migration as raw SQL again
The formatter rewrote ExecuteSqlRaw to ExecuteSql, which passes the
column name and type as parameters; SQLite rejects that and the bare
catch hid it, so older databases never gained their missing columns.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:54:48 -05:00
ajandClaude Fable 5.2 bc0136e3f7 refactor(core): move cut-offs into OpenNest.CutOffs
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:42:45 -05:00
ajandClaude Fable 5.2 c8e899c493 refactor(core): move common lines into OpenNest.CommonLines
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:40:26 -05:00
ajandClaude Fable 5.2 cd5a400a22 refactor(core): move clamps into OpenNest.Clamps
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:38:20 -05:00
ajandClaude Fable 5.2 70ca4c4be6 refactor(core): split Plate into partial files by concern
Moves whole members verbatim into Plate.CommonLines.cs, Plate.Clamps.cs, Plate.CutOffs.cs, Plate.Transforms.cs and Plate.Sizing.cs. No behavior or API change.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:32:51 -05:00
ajandClaude Fable 5.2 b77b3b7a33 refactor(core): swap an immutable part palette instead of mutating Drawing statics
Drawing.PartColors (a public array aliasing the active scheme's), Drawing.UseGoldenAngleColors and a non-atomic colour counter were process-global display state. PartPalette now holds a copy of the colours and the golden-angle flag; PartColorPalette.Current/Use swap it as a whole, Drawing.GetPartColor/GetNextColor read it, and the counter increments atomically. Colours shown are unchanged.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:20:22 -05:00
ajandClaude Fable 5.2 986167a411 refactor(core): refresh Drawing's derived state through ProgramChanged
Area and the canonical angle were refreshed together only by the Program setter; an in-place program edit needed two separate calls, and UpdateArea kept a stale Area when no material was left. ProgramChanged is now the single entry point (the setter calls it), a program with no material or a null program gives Area 0, and UpdateArea/RecomputeCanonicalAngle are private.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:20:22 -05:00
ajandClaude Fable 5.2 dec685361c refactor(core): make Math and Geometry Core's bottom layers
Move ContourSampler to OpenNest.CNC and DrawingAligner with its
AlignmentResult types to OpenNest.Converters: they were the only Geometry
files importing CNC and Converters. Their tests move with them.

CoreLayeringTests scans the Math and Geometry sources against the type
names reflection finds in Core, so neither can name a higher layer again.
Geometry keeps six root-namespace types on an explicit allowlist, and the
files declaring them may reference only Math and Geometry.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:12:13 -05:00
ajandClaude Fable 5.2 9b893c7a09 refactor(core): remove Plate.HasOverlappingParts
Its last callers were four strategy tests; they now use
PartOverlapChecker.FindOverlappingPairs, which also reports
contained and stacked parts.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:11:37 -05:00
ajandClaude Fable 5.2 e5d4337681 fix(core): report overlapping pairs through PartOverlapChecker
The console overlap check and the MCP check_overlaps tool counted
crossing points, so a part contained in or stacked on another reported
no overlap. Both now list pairs from PartOverlapChecker.FindOverlappingPairs,
which uses the Intersects verdict and skips cut-off parts.

Plate.HasOverlappingParts stays for now: two test files outside this
change still call it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:10:26 -05:00
ajandClaude Fable 5.2 a451cf5ef3 fix(core): narrow the perimeter-build catch to geometry failures
BuildPartPerimeter swallowed every exception. It now guards a missing
program and an outline that forms no shape, and catches only geometry
failures; a null perimeter still makes a cut-off exclude the part's whole
bounding-box span.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:10:26 -05:00
ajandClaude Fable 5.2 32edc18ede fix(core): stop a part turning a program its offset copies share
Part.CloneAtOffset hands the copy the source's Program instance. The copy
cloned before rotating, but the source still owned the program, so rotating
the source turned the program under every tiled copy while their location
and bounds stayed put.

Program now carries a sticky SharedByParts flag that CloneAtOffset sets, and
a part copies its program before rotating when it does not own it or the
program is shared. The flag lives on the program rather than the part so a
restored cutting state, which reinstates ownership field for field, cannot
re-alias a shared program. Unrotated copies keep sharing one instance, which
the fill and overlap caches key on.

Update, ApplyLeadIns and ApplySingleLeadIn install a fresh program and now
own it, saving a needless copy on the next rotate.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:09:28 -05:00
ajandClaude Fable 5.2 febeb76836 refactor(core): remove unused Entity.MoveTo and point-based OffsetEntity
Nothing called MoveTo on a geometry entity or the OffsetEntity(distance,
point) overload. Shape and Arc threw NotImplementedException for them, and
the Line and Polygon versions were wrong and untested. Remove the abstract
declarations and every override; the OffsetSide overload stays.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 19:04:08 -05:00
ajandClaude Opus 5.5 c8e1b97786 fix(ui): sign new nests with Made By instead of on viewing Nest Info
Nest Info filled a blank Made By with the current user every time it
opened, so pressing OK on an existing nest's info (from a tab or Saved
Nests) signed someone else's nest. The author is now stamped once when a
nest is made (File > New, drawing set Make Nests, BOM import), and Nest
Info shows the stored value as is, leaving a blank one blank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 18:21:22 -05:00
ajandClaude Opus 5.5 5d00715948 chore(help): ship opennest-help v0.8.0
v0.8.0 documents the drawing library's Filter dialog and its Size and Holes
columns (b9fc618a), with recaptured library screenshots and a new
library-filter topic. The zip's SHA-256 was checked against the release's
published hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 17:51:23 -05:00
ajandClaude Opus 5.5 e6d438629f feat(cutting): slice slugs the support grid can't hold level
On an enclosed machine a cutout slug that tips once cut free can stand up
above the sheet and catch the enclosure door when the pallet changes. The
head already keeps off cut cutouts, so this is about clearing the door, not
the head.

Cutting Settings > Cutouts > "Slice slugs the support grid can't hold
level" (CuttingParameters.SliceTippingSlugs, off by default, saved per
machine thickness and written only when on) makes Plan Cutting cut each
such slug along the bed's contact lines (grid rows and columns, slat center
lines) before the cutout, so no piece keeps a contact and the pieces drop
between the supports. Each slice stops a kerf short of the cutout, so the
pieces stay attached until the cutout's own kerf frees them together. A
slug the bed holds level, or one on no contact, is left whole; a cutout
holding another part or another of its part's cuts is never sliced.

Slices are break cuts emitted right after the drawing's own break cuts,
pierced on themselves and led straight back to their start. The
released-contour proof now leaves out straight break cuts lying wholly
inside one of the part's closed holes, since the grid cells they free are
scrap; break cuts in material still take part. The plan fingerprint only
changes when the setting is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 17:04:27 -05:00
ajandClaude Opus 5.5 b9fc618a50 feat(library): filter the drawing library by thickness, size, holes, date and customer
Finding a part on the nest server meant scrolling 40k drawings: the library
could only match an exact thickness or customer and knew nothing about a
drawing's size or holes. The library browser gets a Filter dialog with
inclusive ranges, and the query API takes them.

The server never parses packages and stays off IO, so the server worker now
measures each package beside its nest jobs (bounding sides, net area, closed
cutouts inside the outline) and stores the summary keyed by package hash
(schema v19). Identical geometry is measured once, an unreadable package is
recorded once instead of retried forever, and size or hole limits match only
measured drawings. Thickness ranges compare in inches across units and keep
the library's rule that a drawing saved without a thickness suits any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 16:47:51 -05:00
ajandClaude Opus 5.5 1382d00496 chore(help): ship opennest-help v0.7.3
The guides now use the regrouped File menu labels from ecd0bba4 and show a
recaptured File menu. The release also carries the earlier unreleased guide
updates for part printouts, plate list delete/duplicate and clone push
placement, all of which are on master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:15:09 -05:00
ajandClaude Opus 5.5 314644b74b fix(ci): point the PDF acceptance area at the renamed report tests
ecd0bba4 removed File > Export Nest Report..., so its cancel test went
away and three report tests were renamed for the viewer path. The Windows
acceptance manifest still required the old names and failed with "expected
at least 1 cases, found 0" although every test passed. List the current
names, and add the two viewer tests now that the viewer is the only way to
get the PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:11:41 -05:00
ajandClaude Opus 5.5 ecd0bba4eb refactor(file-menu): regroup the export and report commands
The File menu ran six output commands together with no separators, and
"Export" / "Export All" didn't say what they exported. Group them by what
they produce:

- Save Copy as .nest File... (was Export .nest...) moves next to Save As,
  since it writes the nest file rather than exporting a plate.
- Export Plate... / Export All Plates... (were Export / Export All) get their
  own group and the "..." their save dialogs call for.
- Nest Report... / Part Printouts... (were View ...) form a reports group.
- Storage Mode... gets its own separator above Exit.

Export Nest Report... is removed: the report viewer already has Save As
(available even without WebView2), so it was a second path to the same PDF.
Its guard and failure tests now drive the viewer path, and the atomic-write
test calls WriteNestReport directly. The Avalonia prototype's matching
command is renamed too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:41:12 -05:00
ajandClaude Opus 5.5 755f021092 feat(reporting): dimension part printouts like the Shape Library
A part printout showed the part but not its size on the drawing, so the
operator had to read the table to know which way was which. Each page now
draws the overall X extent below the part and the Y extent to its right,
as the Shape Library does: arrows, a boxed label and extension lines that
stop 6 pt short of the outline itself rather than its bounding box.

Each dimension line moves out far enough to keep its label 14 pt clear of
the part (a wide label centered on an 18 pt line nearly touched it), and
the part and its dimensions sit 28 pt inside the drawing border, double
the old padding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:02:09 -05:00
ajandClaude Opus 5.5 72e16139ae feat(reporting): name the thickness on part printouts
The nest report already leads its thickness with the machine config's
Name / Gauge; the part printout's THICKNESS cell now does the same
(16 ga - 0.0598"), from the same lookup in the desktop.

Thickness names are typed into the machine config, so both writers now check
them against the bundled font's Latin-1 limit like other free text, instead
of failing partway through rendering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:51:40 -05:00
ajandClaude Opus 5.5 2f3f105a5e feat(machine-config): name blank standard thicknesses on open
Thicknesses saved before the auto-fill kept a blank Name / Gauge until someone
edited their value, so their nest reports showed only the decimal. Opening
Machine Configuration now names every blank thickness that matches a standard
gauge or plate fraction and marks the form changed, so closing offers to save
them. Thicknesses matching none stay blank, and once saved a reopen finds
nothing to fill and does not ask again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:25:41 -05:00
ajandClaude Opus 5.5 35d77239b9 feat(machine-config): fill a blank thickness name from the standard gauges
Reports now take a thickness's name from the machine config, so a blank
Name / Gauge leaves the report with just the decimal. Machine Configuration
now fills a blank name from the standard steel gauge or plate fraction table
when the thickness value changes, and keeps following the value while the
name is still the old value's standard one; a typed name is never replaced.
A new thickness starts named for its default 0.25 (1/4").

Existing blank names are not filled on open, so viewing a machine never marks
it dirty; editing the value fills them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:25:41 -05:00
ajandClaude Opus 5.5 76e1acbc51 fix(reports): take the thickness name from the machine config
Each machine thickness already has a Name / Gauge field the shop fills in, so
the report now shows that name instead of guessing one from the standard
gauge table. It is matched the way the machine cutting defaults match the
nest's material, grade and thickness; a thickness without a name shows just
the decimal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:25:40 -05:00
ajandClaude Opus 5.5 21c1e0f2c7 feat(reports): drop the Units row from the summary
Every size, thickness and weight on the report already carries its own unit,
so the summary's Units row only repeated them and took a header line from the
part cards. The plate pages' ruler note still names the units, since ruler
numbers are bare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:25:40 -05:00
ajandClaude Opus 5.5 d2da7396ce feat(reports): move generated time to the footer and name standard thicknesses
The generated date sat among the summary's report details and printed a UTC
offset nobody on the shop floor reads. It now goes in every page's footer,
left of "Page X of Y", in the computer's own time zone without the offset.

The thickness now leads with its gauge or plate fraction ("16 ga - 0.0598 in")
when it matches one, as the thickness box lists it. Reporting references Core
only, so the desktop looks the name up in StandardThicknesses and passes it on
the snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:25:40 -05:00
ajandClaude Opus 5.5 696e0bdac6 feat(reporting): print one-page part printouts from the nest
Shops used CutSend to get a part printout (the part drawn to fit a Letter
page over a details table) because OpenNest had no way to make one. File >
View Part Printouts prints every part drawing of the nest, and the drawing
list's View Part Printout prints one; both open the report viewer to save
or print.

Cut length and pierce count walk the program as it is cut, so etch marks,
tab spans and display-only moves add neither, a tabbed cutout counts one
pierce per piece, and breakoff and common-line cuts count. The drawing list
item reaches the main window directly so nests made by BOM import get it
too. Rendering shares the nest report's lock, fonts and Latin-1 limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:10:56 -05:00
ajandClaude Opus 5.5 f146cf63f5 fix(avalonia): keep default view toggles out of settings and copy boxes for drawing
Write PlateViewDrawRapid/PlateViewDrawBounds only when changed from the default
or already saved, as the WinForms settings provider does, so a later default
change still applies. Give the render thread a copy of a broken common-line
part's box, and prune the label-point cache with the part paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:18:14 -05:00
ajandClaude Opus 5.5 8f0bd24534 docs: note the Avalonia canvas draws from the shared plate geometry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:18:14 -05:00
ajandClaude Opus 5.5 af31d783cc feat(avalonia): draw plates with everything the WinForms view shows
The plate canvas now draws from the shared OpenNest.Desktop geometry
(ProgramPaths.cs is gone) and maps through ViewTransform: tabbed cutouts
fill as holes, break cuts stay out of the fill, display-only geometry is
dashed, and etch-only parts select by their marks. It adds the origin
axes, irregular sheets with hatched obstacles and their usable outline,
part numbers at the label point, cut-off lines with their segment
numbers, active and removed clamps, common lines, and the selection box.

View gets Draw Rapids, Pierce Points, Bounds, Offset, Cut Direction and
Support, per nest as in WinForms. Rapids and bounds are read from the
shared user settings (PlateViewDrawRapid/PlateViewDrawBounds) when a
nest opens and written when it closes; --snapshot ignores them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:18:14 -05:00
ajandClaude Opus 5.5 5edebc6144 test(desktop): pin sub-program frame origin and height-limited fit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:18:14 -05:00
ajandClaude Opus 5.5 049b45b394 feat(desktop): share plate-view geometry, view transform and overlays
Port the WinForms GraphicsHelper program walker to a toolkit-neutral
figure list (move/line/arc) with its layer routing: cuts without tabs,
leads, etches, and a material fill without break cuts whose tab-gapped
pieces are bridged. Suppressed moves are skipped and marks never bound
the fill. PartDisplayGeometry picks the fill as LayoutPart does
(common-line members, break cuts, open cuts) and keeps display-only
entities; PartLabels ports the label points.

ViewTransform carries DrawControl's world/screen mapping and zoom rules.
PlateOverlays computes what the view draws around parts (sheet, edge
spacing, irregular usable area, clamps, cut-off strokes, rapids, pierce
points, cut direction, offset outline, support) through the existing
Core code. PlateViewOptions reads and writes the View toggles under the
WinForms PlateViewDrawRapid/PlateViewDrawBounds names.

WinForms still draws with GraphicsHelper; nothing there changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:18:14 -05:00
ajandClaude Opus 5.5 8fe38398e9 feat(avalonia): load posts and machines at startup
The main window loads the post processors in Posts/ beside the app (their
materials registered) as it loads engine plug-ins, and reports any that
fail in the status line. It then reads the machines off the UI thread
through the shared MachineSettingsSource, following storage.json's mode
(the nest server's copy in Database mode), copies legacy clamps onto the
defaults' or only machine as WinForms does, and shows the offline-copy or
clamp warning in the status line. The machines and posts are kept in
DesktopSettings for later screens; nothing uses them yet.

The two posts the WinForms app ships now build with the Avalonia app and
copy to its Posts/ directory, with their settings JSON unless an edited
copy is already deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:18:14 -05:00
ajandClaude Opus 5.5 476bed1a05 refactor(desktop): share machine, nest-default and shared-settings sources
The rules behind the WinForms MachineSettings and SharedSettings move into
OpenNest.Desktop as MachineSettingsSource and SharedSettingsSource, so the
Avalonia app reads machines, clamps and nest defaults the same way: in
Database mode the nest server's copy once it holds one (machines as a
whole, nest defaults and clamps as single documents), otherwise this PC's
files; the copy is re-read after two minutes (one while offline), and while
the server is unreachable the last copy read is used, read-only. Legacy
clamps still move onto the defaults' or the only machine once, asking at
most once per session, and cutting settings edited for a nest still save
onto the machine's thickness, or this PC (stamped for that job when the
machine cannot be saved).

Nothing in the shared code shows UI: it returns the warning texts, takes
the clamp-machine question as a delegate and reads the DefaultUnit and
CuttingParametersJson settings through IMachineUserSettings (with a
user-settings.json implementation under the WinForms names). Cut-off
defaults take CutOffSettings instead of a PlateView. MachineSettings and
SharedSettings stay in the WinForms project as thin wrappers with the same
members, showing the same dialogs and reading Properties.Settings and
NestStorage as before, so their callers are unchanged.

Tests cover File vs Database mode precedence, the offline read-only copy
and its once-per-session warning, staleness and retry timing, uploads,
post settings, legacy clamp migration, the cutting-settings save and the
user-settings file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:18:14 -05:00
ajandClaude Opus 5.5 bfff2a3546 refactor(desktop): move post processor loading into OpenNest.Desktop
PostProcessorLoader and PostProcessorMaterials move unchanged (namespace
OpenNest kept) into the toolkit-neutral library, so the Avalonia app can
load the posts in Posts/ and register their materials as the WinForms app
does. Each DLL still loads on its own and a failing one is reported in the
result instead of stopping the others; the WinForms MainForm shows the
failures as before.

New tests load a copy of the test assembly as a post plug-in beside a
broken DLL: the broken one is reported, the good one's posts load and its
materials are registered once each, ignoring case and blanks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:18:14 -05:00
ajandClaude Opus 5.5 550a460778 chore: let planner and reviewer agents inherit the session model
Pinning a model in shared agent files forces that tier on anyone who
runs them; each user picks their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:35:00 -05:00
ajandClaude Opus 5.5 02e698ed0d chore: add planner and reviewer Claude Code agents
Track .claude/agents/ so every worktree gets the shared agent
definitions; the rest of .claude/ stays ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:28:27 -05:00
ajandClaude Opus 5.5 641e07b766 feat(avalonia): draw plates in the saved colour scheme
The Avalonia app applies the colour scheme saved as ActiveColorScheme in
the shared user-settings file when the main window opens, and recolours a
nest's drawings from it when opening the nest, as WinForms does.

The plate canvas draws the background, sheet, edge spacing, etch, part
outlines and selection from the active scheme through SkiaSchemePalette,
a cache of Skia colours rebuilt when the scheme's colours change, instead
of hard-coded colours. Part outlines follow the scheme's outline colour
or the fill darkened as WinForms darkens it. There is no Options screen
to change the scheme yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:23:48 -05:00
ajandClaude Opus 5.5 cc3711c11c refactor(desktop): move colour schemes into OpenNest.Desktop
ColorScheme, ColorSchemeRegistry, ColorSchemeSerializer and the Schemes
folder move into the toolkit-neutral library, keeping the OpenNest
namespace, so the Avalonia app can share the built-in palettes, the
scheme files and the recolour-on-open rule.

ColorScheme is now pure colour data with a Version that changes with
any colour. Its GDI+ pens and brushes move to GdiSchemeTools in the
WinForms project (still one set per thread, rebuilt after a colour
change), reached through scheme.Gdi(). The legacy part outline uses
ColorShade.Dark, a port of ControlPaint.Dark's HLS arithmetic; a
WinForms test compares the two across a colour sweep on Windows.

The registry no longer walks Application.OpenForms: Apply raises
Applied, and EditNestForm subscribes once to recolour every open nest
window as before. The saved scheme is read under the same
ActiveColorScheme setting (SavedSchemeName/ApplySaved for the shared
user-settings file); WinForms still reads it through Settings.Default.
Schemes/*.json now copies beside both desktop apps from OpenNest.Desktop.

Pure scheme tests move to OpenNest.Desktop.Tests, with new ones for
built-in round trips, scheme files, bad files, the saved name and the
recolour rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:23:48 -05:00
ajandClaude Opus 5.5 a7302d3224 refactor(desktop): share the large browse dialogs' sizing
LargeDialogBounds computes the bounds the large browse dialogs open at:
nine tenths of the owner screen's working area, centered, never below the
dialog's minimum size. WinForms' DialogBounds now calls it with the
screen's working area, with unchanged behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:13:53 -05:00
ajandClaude Opus 5.5 cb5e2a5854 refactor(desktop): share the one-time nest template migration
NestTemplateMigration converts the legacy NestTemplatePath template into
the nest defaults file when the template exists and no defaults file does,
and says whether to clear the setting (converted or nothing to convert)
or the warning to show when the conversion failed, which keeps the
setting. A settings-map overload clears it to the empty text WinForms
stores, so it runs once. WinForms' MainWindowSettings now calls it and
still shows the same warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:13:53 -05:00
ajandClaude Opus 5.5 725e90dada refactor(desktop): share the new cut-off head-lock defaults
SavedCutOffHeadLock reads and writes CutOffLockHeadHeight and
CutOffHeadLockOvercutInches in the WinForms format and converts the
overcut, always stored in inches, to and from each nest's units, using
1.5 in when the saved overcut is not a positive number. The WinForms
CutOffHeadLockDefaults now calls it, with unchanged behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:13:53 -05:00
ajandClaude Opus 5.5 a687e8b193 feat(desktop): shared last cut-time settings in the WinForms format
SavedCutTimeSettings reads and writes the cut-time values last used in
Calculate Cut Time or the simulator under the names and text the WinForms
app already stores (LastFeedrate, LastHoleFeedrate, LastRapidFeedrate,
LastPierceTime as invariant decimals, CutTimeSkipEtch "True"/"False",
CutTimeMachine as a machine id or "none"), with each unreadable value
falling back to its WinForms default. It also holds the rules: never-saved
values use the cut-parameter defaults, no hole feed uses the feed, and the
saved machine falls back to the first by name. The WinForms app's
SavedCutParameters and machine box now call it; their behaviour is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:13:53 -05:00
ajandClaude Opus 5.5 3493c3bd49 feat(avalonia): open dropped .nest files, and exit with Ctrl+Q
Files dropped anywhere on the main window, over the nest view too, are
taken (as a copy) only when one is a .nest file; each opens in its own
tab, or shows its tab if already open, and other files are ignored, as
in the WinForms app. Load warnings follow as for File > Open, and as
File > Open, a drop works while Auto Nest runs.

File > Exit shows and answers Ctrl+Q, asking about unsaved nests as
closing the window does; the window placement is still saved on exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:55:52 -05:00
ajandClaude Opus 5.5 146876b13c feat(desktop): pick the nest files out of a file drop
DroppedNestFiles.Select keeps the dropped paths with the .nest extension
in any case, as the WinForms main window does, ignoring everything else.
It returns full paths in the order dropped, each once however its path is
written, compared as NestTabs compares open files. Any says whether the
drop holds a nest file, so a window accepts it only then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:51:37 -05:00
ajandClaude Opus 5.5 5a627e5cc3 Merge branch 'claude/shared-post-contours'
Master added head-raise markers, common-line shared cuts and remainders to
the CI Fiber's own contour extractor, which this branch replaced with Core's
shared PostContours. Those now live in the shared model: PostContour carries
Raised, IsCommonLine and RemainderWinding, and Classify gives remainders the
perimeter macro on their perimeter's side and shared cuts no kerf side.
ContourWinding uses PortableMath for trig, as master's Core guard requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 19:10:16 -05:00
ajandClaude Opus 5.5 16635b7791 feat(avalonia): save, save as and export nests, and ask to save on close
File > Save (Ctrl+S), Save As and Export .nest, with Save and Save As on
the toolbar. Save writes to the file saved to this session, else asks
for one in the nest's folder; a failed save says why, keeps the nest
unsaved and points to Save As. One save or export runs at a time, a
nest cannot be closed while one is under way, and the nest Auto Nest
is working on is not written, checked again after the file picker.

Closing a nest with unsaved changes asks Save / Don't Save / Cancel, as
the WinForms nest window does; Exit asks about each unsaved nest in
turn, showing it, and reads the list again after each answer, since
Auto Nest can finish while a question is open. Cancel, or a save that
does not happen, keeps the app open; an unsaved nest still being
nested stops Exit with how to stop Auto Nest.

Nest load warnings are shown after opening, one dialog at a time and
scrolling when long; for nests opened from the command line they appear
once the window opens, and --snapshot only notes them in the status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:35:22 -05:00
ajandClaude Opus 5.5 8e87c0881a feat(desktop): save a nest tab to a file or export a copy
NestTabs.SaveAs writes the tab's nest, makes the file its file and name,
and clears its unsaved mark; a write that fails throws and changes
nothing. ExportCopy writes the nest without touching its file, name or
unsaved mark.

Both refuse, before writing anything, a file another open tab shows (so
one file never has two tabs) and a tab that has been closed; Export
also refuses the tab's own file, which only Save may write. Refusals
are a SaveTargetRefusedException whose message says what to do.

SavePath is the file Save writes without asking: only one saved to this
session, as in the WinForms app, so a nest that was only opened asks
for a file on its first Save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 17:32:13 -05:00
ajandClaude Opus 5.5 fa6f664296 fix(posts): pick Cincinnati CL kerf side from the shared contour winding
The CL post wrote the configured default side (G41) on every contour,
so a contour wound against OpenNest's convention was compensated into
the part. It now takes each feature's side from Core's shared
PostContours winding rule, as the CI Fiber post does: the perimeter is
picked across the part's cut features and hole sub-program contours
are always holes. Correctly wound drawings post unchanged.

Remove the "Default kerf side" post setting; saved configs that still
carry it load as before. Expose PostContours.Flatten and Classify so a
post can classify one part's features itself.

Add CL tests for a square with a hole: the exact inline program, cuts
that match the shared contours on the sheet, G41/G42 keeping the part
out of the kerf inline and through part sub-programs, and identical
output from absolute and incremental part and hole programs. The
replay now records the compensation in force on each cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:52:10 -05:00
ajandClaude Opus 5.5 cd623ff1aa refactor(posts): share contour geometry between posts through Core
Move the CI Fiber post's contour extraction and winding into Core as
OpenNest.CNC.Posting.PostContours and ContourWinding, so every post can
take sheet- or part-frame contours with the perimeter and kerf side
already chosen instead of re-deriving them. CI Fiber now writes from
the shared model; its golden output is unchanged.

Scribe marks are never picked as the perimeter and get no kerf. A cut
that resumes after suppressed moves starts a new contour where they
end, so it is no longer cut as a chord across the gap.

Add tests for a square with a hole in sheet coordinates, identical
contours from absolute and incremental part/hole programs, rotation,
and the kerf side for both windings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:36:44 -05:00
ajandClaude Opus 5.5 fa6781d8b1 fix(options): name clone push orders by axis, not direction
The labels said left and down, but every push follows the plate quadrant:
on quadrant 4 "Push left, then down" pushed left, then up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:03:31 -05:00
ajandClaude Opus 5.5 cafb34b510 feat(clone): choose Shift+click push order and add box-only Ctrl+click
Shift+click in Clone Parts always kept whichever axis order landed the
copy nearest the origin, which can park it in a pocket the user didn't
want. Options > Clone push placement now picks Closest to origin (the old
behavior, still the default), Push left then down, or Push down then left.

Ctrl+click settles the copy on bounding boxes alone, so it stops square
to its neighbors' boxes instead of nesting into their shapes. Shift+click
keeps its box pass then geometry settle; both follow the chosen order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:53:24 -05:00
ajandClaude Opus 5.5 beccbae106 perf(fill): skip copy-distance searches whose result is thrown away
One Irregular decode of a small job took seconds because each block in the
catalog is built by the full Fill pipeline (PrivatePlateFill), and inside it
StripeFiller's estimate rows spent about 85% of the CPU in FillLinear's
perpendicular TilePattern. A stripe estimate is exactly one pattern tall, so
the pattern's copy distance, an all-pairs slide search, always exceeded the
room left and the copies were discarded after the search had run in full.

TilePattern now passes FindPatternCopyDistance the largest distance that
could still place a copy: for a whole copy, the room past the pattern; for a
pattern that may tile partially, the most room past any one of its parts. The distance
is the maximum over every pair, so the search stops as soon as it passes that
bound and the tile returns empty, which is what the full search led to. The
public CopyDistance is unchanged. StripeFiller's angle searches also reuse
each rotated pattern's bounding box per angle instead of cloning and
rotating the parts again on every probe.

Output is identical: the layouts of every benchmark job that finishes match
the previous build. Measured A/B on this machine, alternating builds per job
(Benchmark --engines Irregular --parallel 1, benchmark ms):

- N26-2A5: 15.5 s -> 8.8 s (12.6 s -> ~7.2 s in quieter runs)
- 73 of the 80 bench jobs (the other 7 time out at 5 minutes in both
  builds): 1,990 s -> 1,578 s total (-21%), process CPU 6,486 s -> 3,842 s
  (-41%), median job 0.91x. N26-295 now finishes in 242 s with the same
  layout where it hit the 5-minute limit before.
- Problem-nest corpus auto-nest and fill areas: all at baseline.

A parallel herringbone pair build was tried and dropped: it saved 1.2 s on
N26-2A5 but cost 10 s and 45 s more CPU on P241017-01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:08:12 -05:00
ajandClaude Opus 5.5 6867ae2ee4 feat(arrange): let Fix Spacing clear overlaps on packed plates and tilt as a last resort
On a plate packed edge to edge, an overlap left by hand-placing extra
parts could not be fixed: straight push chains ran into the edge and
the relaxation gave up on pairs that share material, which have no
contact normal. The relaxation now pushes such a pair along its
shortest straight way out and lets the rest settle, which cleared a
0.92 sq in overlap on a 38-part plate with every part kept square.

When moves alone still cannot settle, the relaxation runs again as a
rigid one: an off-center push also turns a part, up to 3 degrees, if
its drawing is free to rotate. Moves stay first because a tilt turns
every part the push reaches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:45:54 -05:00
ajandClaude Opus 5.5 821c97e8dc feat(arrange): add Fix Spacing to push apart only flagged parts
Expand Spacing opens up a whole selection; nests that are a hair short
of fitting need the opposite: move only the parts the overlap check
flags, by the shortfall, and leave everything else where it is.

Fix Spacing pushes one part of each flagged pair straight away from the
other and ripples the push into neighbors it crowds, so rows already
locked in at the spacing can make room. When a plate is packed edge to
edge and no straight push fits, a constraint relaxation shares each
shortfall between both parts along the closest-point normal. Every
result is confirmed by the overlap check before any part moves, and is
applied only when it has fewer findings and nothing new.

NativeContours gains a coarse-flattening overload, Translated, Nested
and a closest-points GapTo for the search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:29:10 -05:00
ajandClaude Opus 5.5 9828b9d6bc fix(drawing-list): paint the whole blank area when the side panel resizes
Windows erases a resized window before WinForms updates its cached
ClientSize, so the erase handler filled the blank area below the rows only
out to the old width. Windows then treated the new strip as erased, and it
kept whatever was on screen before. The handler now reads the client rect
from Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:30:58 -05:00
ajandClaude Opus 5.5 9273a44258 feat(engine): squeeze the Rectangles sheet that finishes the job
Auto Nest left N26-2A5 (14 door stops, 14 lock plates on a clamped 26x26
sheet) 8.0 high, while the shop's hand layout holds all 28 under 7.5 and
leaves a half-inch-deeper drop. Every MaxRects fit rule packs into the
whole sheet, so with room to spare it spreads the parts out and never
looks for the shortest strip.

When a rule places all the remaining demand, Rectangles now repacks it
into shorter and narrower bins, every rule and mode per probe, and offers
the tightest complete layout per axis to the stock loop, which still
picks by cost and offcut. Fit is not monotonic in bin size (7.5 packs,
7.62 and 7.74 do not), so probes walk down through stack heights (sums of
spaced part sides from the bin edge or a reached clamp) instead of
bisecting. A placement budget skips sheets finishing ~2000+ parts.

Benchmark, Rectangles on 80 recent production nests with salvage credit:
11 cheaper, 0 worse, all valid, total time 14.7 s -> 13.2 s.

The zero-clamp hash is re-captured (the squeezed layout is narrower);
the far-clamp test now compares poses, since the clamp can reorder an
identical layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:10:06 -05:00
ajandClaude Opus 5.5 7dc471bbf8 fix(engine): keep Strip layouts clear of clamp keep-outs
The Strip whole-job engine laid its strips, settled them and picked its pack
remnant from the bare work area, never adding the plate's clamp keep-outs the
Lattice orchestration uses. On a clamped sheet it put parts over the clamp, the
runner's layout check rejected the candidate, and Strip produced nothing (N26-2A5
rev 5: door stop at (21.09,1.50) over clamp-1).

NestStrips now treats InteractiveClamps.ClearanceRectangles as occupied space for
the iterative shrink strips (through RemnantFiller), settles with the clamp
obstacles when there are zones, and finds the pack remnant around them even
before any strip is placed. Unclamped plates take the old path unchanged.

A FixedStrategyNestingEngine theory runs all four fixed strategies on a clamped
26 x 26 sheet; only Strip failed it before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:59:21 -05:00
ajandClaude Opus 5.5 6894fcfade feat(reporting): drop the generic notes under the plate diagram
The fill/cutoff legend, the part-ID note and the "verify geometry and CNC
output" reminder repeated on every plate page and added clutter without
telling the operator anything plate-specific. Only the clamp legend (on
plates with clamps) and the ruler-units line remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:12:50 -05:00
ajandClaude Opus 5.5 f4fb6e1f3d feat(reporting): show clamps and sheet rulers on the plate layout view
Operators reading a printed nest report could not see where the machine
clamps sit or which ones must be pulled before cutting, and had no way to
measure positions off the layout. Plate diagrams now draw active clamps as
hatched rectangles (labels keep clear of them) and removed rail clamps as
dashed, crossed-out rectangles over the parts, with a legend line only on
plates that have clamps. A horizontal ruler below and a vertical ruler left
of the drawing read the sheet's plate coordinates in job units; the
diagram is fitted in two passes so the ruler bands match their numbers,
and part labels never enter them. Rulers draw as their own content stream
so the diagram stream holds only the sheet and what is on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:02:24 -05:00
ajandClaude Opus 5.5 bffcd52db1 fix(plate-view): recompute part labels after a drawing edit
A part's sequence-number label point was computed once from the drawing's
program and cached forever. Editing a drawing in the converter replaces
that program, sometimes in a different frame, so the labels of every
placed copy stayed at the old frame's point and drifted off the parts.
Keep the cached point only while the drawing program (and its rotation)
is the one it came from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 07:48:35 -05:00
aj b329dd6c6b feat(avalonia): open several nests in tabs along the bottom
Each open nest gets a tab along the bottom of the window, above the
status bar: the shown one is white with a blue line, a dot marks unsaved
Auto Nest results, x closes it and + opens another. Inside a tab, Plates
and Drawings tabs sit at the top of the left panel (Drawings lists each
drawing's nested and required quantity) and the plate header with
first/previous/next/last sits over the plate view. Each nest keeps its
own plate and zoom when you switch.

File > Open takes several files, and a file already open switches to its
tab. File > Close and Close All, View > Next/Previous Nest and
Ctrl+Tab/Ctrl+Shift+Tab switch or close nests. This version cannot save,
so closing a nest with Auto Nest results, or exiting with any, asks first
(Cancel is the default). Other nests can be opened and viewed while Auto
Nest runs; the nest being nested cannot be closed until it finishes.
Several nest paths on the command line open in tabs.
2026-11-09 22:30:12 -05:00
aj c32f3e5df3 feat(desktop): track the open nests as tabs
NestTabs holds the nests open in the desktop app and which one is shown.
Opening a file that is already open switches to its tab instead of
loading it again, and a file that fails to load adds no tab. Closing the
shown tab shows the one to its right, else the one to its left. Each tab
records whether it has unsaved changes, so the host can ask before
closing it or exiting.
2026-11-09 22:30:12 -05:00
aj 0d7207e58e feat(avalonia): remember the main window's size, position and state
The Avalonia main window restores its last size, position and maximized
state from the shared user settings and saves them when it closes, keeping
the normal bounds while maximized. On Windows it reads what the WinForms
app saved: WinForms stores the outer window and Avalonia sets the client
area, so the size differs by the frame between the two apps. --snapshot
skips it, so its image never depends on the user's settings.
2026-11-09 22:05:09 -05:00
aj 53e5b78e3c feat(desktop): shared window placement in the WinForms settings format
WindowPlacement reads and writes a window's position, size and maximized
state under the names the WinForms app already stores (MainWindowLocation
"x, y", MainWindowSize "width, height", MainWindowState), in screen pixels.
A minimized window restores normal. FitTo drops a position that is on no
current screen and a size too big for its screen, as the WinForms main
window does when it restores.
2026-11-09 22:05:09 -05:00
aj b92d079e77 feat(data): read and write the desktop user settings outside WinForms
UserSettingsFile in OpenNest.Data reads and writes
%APPDATA%\OpenNest\user-settings.json (~/.config/OpenNest on Linux) so the
Avalonia app can share the settings the WinForms app keeps there.
AppDataSettingsProvider now delegates its file reads and writes to it, so
there is one implementation of the format.

A missing, unreadable or corrupt file reads as empty without touching it,
and Update keeps entries the caller does not change. One difference for
WinForms: an entry whose value is not text is now skipped instead of the
whole file reading as empty.
2026-11-09 22:05:09 -05:00
aj 3beaf53942 docs(agents): call PortableMath for trig in layout projects 2026-11-09 20:25:14 -05:00
aj da13801d25 ci: run the Linux unit tests in Release
The unit-test matrix was the only test job still built in Debug, and the
Engine suite was taking 26-27 of its 30 minutes. In Debug the JIT does not
optimize PortableMath (64 ns per Sin against 17 ns for the runtime's), so
after 49603edd the suite reached the 30-minute limit and was cancelled
(run 42728099456). Every other test job (windows-desktop, the Windows
release build, the server image) already runs Release, which is what
ships; no production project has Debug-only code. The Windows release
workflow still runs OpenNest.Tests in Debug.
2026-11-09 19:58:50 -05:00
aj 9627bb9625 perf(math): fully optimize PortableMath from its first call
Without tiering, PortableMath's Sin took 25.5 ns against the runtime's
15.0 ns in Release; marked AggressiveOptimization it takes 17.2 ns (Atan2
18.1 against 17.0). Results are unchanged: the cross-platform digest test
passes in Release and Debug on Linux and in Release on Windows.
2026-11-09 19:58:50 -05:00
aj 8e9946aa47 docs(engines): keep layouts the same across platforms with PortableMath 2026-11-09 19:24:25 -05:00
aj 49603eddf9 fix(geometry): nest the same on Windows and Linux
Geometry, nesting and import code now call PortableMath instead of
System.Math for Sin, Cos, Tan, Atan, Atan2, Asin and Acos (Core, Engine,
IO, Data; 269 calls in 64 files). A test fails if those projects call the
runtime's trig again.

With the runtime's trig, Windows and Linux disagreed in the last bit at
non-quarter angles and the same job could nest differently: the
learned-angle nests (a cross fixture) placed 23 parts in Strip on Windows
and 28 on Linux. Now every Engine.Tests pin gives the same layout on both
(3600 passing on Linux and on DesktopPC), Linux layouts are unchanged, and
the learned-angle rows are re-pinned to the shared layouts, which pass the
overlap and work-area checks, and run on every platform again.

Fixes Project Memory 518f3b58.
2026-11-09 19:18:00 -05:00
aj 921d100aa0 feat(math): add trig that returns the same bits on Windows and Linux
System.Math's Sin, Cos and Atan2 call the C runtime, and Windows' UCRT and
Linux's glibc differ in the last bit at many angles that are not quarter
turns. Nesting turns those bits into different tie and tolerance decisions,
so the same job nests differently on the desktop and on Linux (Project
Memory 518f3b58).

PortableMath ports fdlibm's Sin, Cos, Atan and Atan2, which use only IEEE
exact operations, and builds Tan, Asin and Acos on them with Sqrt. Results
stay within one ulp of the runtime (a few for the derived functions) and
hash to the same digest on Linux and on Windows (DesktopPC). Nothing calls
it yet.
2026-11-09 19:08:07 -05:00
aj 071531dbfc test(engine): run the learned-angle fill pins only on Windows
Nest_LearnedAngleItems_PinnedLayout has failed on every Linux CI run since
735c076a re-pinned it: the pins were captured on Windows, where they still
pass, and Linux lays the same nest out differently (Strip 28 instead of 23;
the other rows keep their count with different poses).

The split comes from the platforms' trig differing in the last bit at
non-quarter angles, which the fill stack turns into different decisions:
FillLinear starts a copy's slide exactly at the contact snap tolerance, and
equal-score candidates are ordered by raw doubles. Fixing the start gap moves
about 190 bit-exact FillLinear and layout pins and still leaves Strip and both
Remnant rows split, so the rows run on Windows only until the cause is fixed
(Project Memory 518f3b58). Nest_LearnedAngleItems_DependsOnLearnedAngles
still runs everywhere.
2026-11-09 18:40:26 -05:00
ajandClaude Opus 5.5 5668952e6b fix(import): read source drawings that are open in another program
AutoCAD keeps an open DWG/DXF locked for writing, and ACadSharp's path
constructors (and File.OpenRead / XmlReader.Create) deny write sharing,
so the CAD Converter could not import a part while it was open in
AutoCAD. Open source files read-only with read/write sharing instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:01:56 -05:00
ajandClaude Opus 5.5 e58e8b85c4 feat(report): add sheet weight to the plates table
The shop needs each sheet's weight for handling, so the summary's plates
table gains a Sheet weight column: one full sheet from its area, the nest
thickness and the nest (or catalog) density. A drawing's own material is
not used, since it doesn't say what the stock is; with no density the
column is left out.

Plates with copies now put the total cut time (and total weight) on a
second line instead of after a comma, which wrapped mid-phrase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:59:31 -05:00
ajandClaude Opus 5.5 ae8e7c6e42 feat(report): lay the summary header out in two key/value columns
The one-column list took nine rows before the Plates table, pushing part
cards onto later pages. Job and material now sit on the left and the
report's totals on the right, five rows at most.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:46:15 -05:00
ajandClaude Opus 5.5 9c3a8a5063 test(irregular): run the seeded-search test on a job the seeds still improve
Irregular builds its blocks with Fill, which now nests the ten ells as
herringbone pairs; the normal search's plan rose from 550 to 684 compactness
offcut, and the seeded search no longer beats it within twelve plans' sheets.
Twelve ells nest identically with or without herringbone pairs, and the
seeded search still improves on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:41:23 -05:00
ajandClaude Opus 5.5 735c076afb feat(fill): tile herringbone pairs at the part's hull angles
Fill paired a 9" angle bracket at 90/270 degrees (75 on a 48 x 96 plate)
while a hand-laid herringbone at 315/135 holds 84. Its pair touches only at
the legs' ends, so it ranked ~888th by pair box area, and pairs are tiled
at the pair's own hull angles, never the part's 45-degree edge.

The pairs phase now also builds herringbone pairs: the part turned so a
hull edge lies along a sheet axis, its 180-degree copy slid onto the row
the part makes along that axis, tiled and remnant-filled like best-fit
pairs. Fill now finds the 84.

The chevron in the learned-angle fixture became a herringbone whatever
angles were learned, so a cross replaces it; the learned-angle and Strip
mixed nests are re-pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:39:11 -05:00
ajandClaude Opus 5.5 f62f73533e feat(fill): tile pair grids into narrow leftover strips
Fill tiled 8" gussets on a 16.85 x 60 strip as seven pairs in a 10.35"
column (14) and left a strip too narrow for a gusset square on. Sliding
each pair along its hypotenuse so six span the height narrows the column
to 8.75" and leaves a strip that holds eight more turned 45 degrees (20),
as laid out by hand.

- The pair remnant filler tries strips down to the part's narrowest hull
  width and tiles single best-fit pairs as a grid there, taking the grid
  only when it places more than the rows of pairs.
- After the pair filler's early exit it also tries skipped candidates
  whose grid plus an optimistic leftover-strip estimate could still win,
  keeping one only when it places more parts.
- The optimistic remnant bound counts parts by true area, not bounding
  box, so interlocking parts no longer prune winning candidates.

The mixed Fill nest pin now places 34 instead of 33; the mixed Strip nest
keeps 35 with its L-shapes laid out differently. Both pass the layout check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:24:49 -05:00
ajandClaude Opus 5.5 5adcddc748 feat(fill): refill the outer row's end to fit one more part
After the fill strategies, the winning layout's outermost row on each side
is pushed into the edge slack, slid toward one end, and copies of its end
part are slid in from the freed end. Lifting a staggered hexagon row straight
over the one below opens a notch where one more nests: a 7" hex cover on a
60 x 120 sheet now fills 181 instead of 180. The policy's comparer must
prefer the result and the requested quantity caps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:46:03 -05:00
ajandClaude Opus 5.5 3c561566cd fix(clamps): drop the origin rapid from the keep-out outline
The exact clamp check built each keep-out from a program whose opening
rapid from (0, 0) joined the outline, stretching every keep-out into a
wedge back to the origin. Parts clear of a clamp but inside that wedge
were dropped from fills (two L's on a clamped sheet). Build the
rectangle from four lines instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 20:11:35 -05:00
ajandClaude Opus 5.5 74937b7952 feat(cutting): start the owner's outline on its common line
The part cut first no longer makes a separate uncompensated centerline
cut with its own lead-in and lead-out, then re-pierces for its
remainder. It cuts its outside compensated, which puts its shared edge
on the kerf centerline, starting on the common line: a straight lead-in
from the scrap along the shared edge, then the line and on round its
perimeter with no lead-out. A member with spans already cut stops in
that kerf and cuts its last stretch from it, ending beside the part it
is cut before. Partners still cut only their remainders.

Replay's tabbed-ring accounting now accepts pieces that tile each span
exactly once, and the pre-post review counts a span as cut by a
member's compensated edge (or an older centerline cut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:57:35 -05:00
ajandClaude Opus 5.5 bff11dbc3c feat(cutting): cut Z and L common lines once as one shared path
Straight spans of one pair that meet at a corner chain into one shared
path (CommonLineGeometry.Paths). Plan Cutting cuts it as one open
centerline cut through its corners, pierced and left in scrap along its
end legs; each member's spans run on to its own vertex at a corner, so
the path is one tab run. Join Common Line slides along the main edge so
the step of a Z or L also lands one kerf apart, and the plate draws the
path through its corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 15:15:29 -05:00
ajandClaude Opus 5.5 757d366e4a feat(ui): pan the Shape Library preview and pin slid labels between the arrows
The preview's middle-drag pan was switched off by the form; turn it on and
let a left drag on empty space pan too. A panned view is kept while editing.

A label slid along its dimension line kept a fixed pixel offset, so zooming
out drifted it off center and past the arrows. Scale that slide with the
drawing; the standoff from the drawing stays in pixels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:34:32 -05:00
ajandClaude Opus 5.5 aa8616ea84 feat(ui): run Shape Library extension lines to 6px from the part; call out the bolt circle from outside
Extension lines now walk in from the dimension line until they come 6px from
the drawing's outline, as on the CAD Converter, passing measured points that
are off the outline (a rounded rectangle's sharp corners). The flange bolt
circle is a leader landing on the dotted circle from outside, between holes,
instead of a linear dimension whose extension lines crossed the flange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:59:11 -05:00
ajandClaude Opus 5.5 d7f9f25265 feat(ui): aim the flange hole callout through its center and dot the bolt circle
The Shape Library's hole diameter leader now crosses its hole through the
center to the far edge, arrows pointing out onto both edges, wherever the
label is dragged. The hole pattern dimension measures a dotted bolt circle
through the hole centers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:26:30 -05:00
ajandClaude Opus 5.5 ea540253bb feat(ui): delete plates from the Plates list's right-click menu
Add Delete Plate to the plate row menu beside Duplicate Plate, enabled
when Remove Selected Plate is. Drop the Plates tab's add-plate button
and the Plate menu's Add and Remove items: the list's trailing empty
plate already provides new plates, and removal stays on the tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:22:08 -05:00
ajandClaude Opus 5.5 c8f070ea64 feat(ui): list open windows under the Window menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:19:48 -05:00
ajandClaude Opus 5.5 a4417af763 test(ci): track renamed Shape Library invalid-entry test
The dimension-editing rewrite replaced the invalid-input test with
InvalidEntryStaysOpenInRedAndKeepsTheLastShape, so the Windows
acceptance gate found zero cases for the old name and failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:58:45 -05:00
ajandClaude Opus 5.5 bcbcf1e611 perf(fill): cut a cold Lattice fill from ~300 s to ~160 s with identical layouts
The first fill of a new drawing was blamed on the best-fit pair search, but on the
reported nest that search takes ~3.4 s. The time goes to FillLinear.FillGrid, run
thousands of times by the Pairs, Row/Column and Linear strategies: pattern copy-distance
slides and the grid overlap check. Every change keeps the output bit for bit (part
poses compared on the benchmark nest; best-fit results unchanged).

- ConvexDecomposition.Triangulate: unlink clipped vertices from a circular list instead
  of List.RemoveAt, and remember why a vertex failed the ear test (same neighbors, same
  blocking vertex still present) instead of retesting it after every ear. Compared
  bitwise against a frozen pre-change copy, including the blocker-clipped case.
- Collision: skip triangles whose box misses the other triangulation's union box (exact:
  min/max and monotone subtraction), reuse each triangle's open vertices, and let the
  overlap-only path stop at its first region when there are no holes. Check still
  returns every region.
- SpatialQuery.ExtractEntityVertices presizes its set; FillLinear extracts each entity
  list's vertices once per copy-distance search rather than once per pair.
- StripeFiller converges its stripe angles and tiles its grids in parallel. Dedup claims,
  cached remnant fills, ranking and progress stay in candidate order, and grids are tiled
  only for configurations a claim can still win (GridDedup.Contains), so results and
  reports do not depend on the thread count.
- LinearFillStrategy fills its angles in parallel through
  FillHelpers.BestOverAnglesInParallel; angle results, ranking and progress stay in order.

Benchmark (one drawing, 60x120, 0.25 spacing, cleared plate, interleaved runs):
FillItem Lattice 291/317 s -> 164/153 s, 132 parts both; PairFiller alone 48 s -> 38-42 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:34:31 -05:00
ajandClaude Opus 5.5 331234d6f3 feat(ui): drag Shape Library dimensions and zoom the preview
Dragging a dimension's label moves the dimension: a linear one slides
out from the drawing (never onto it) and its label along the line, an
inline one slides its label, a leader's label goes anywhere and the
leader follows. A click without dragging still opens the editor.

The preview zooms with the wheel, pans with the middle button and fits
again with F or a middle double-click. Once zoomed or panned by hand,
parameter edits keep the view; a new shape or configuration fits it and
puts dragged dimensions back. An open editor follows its label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:25:43 -05:00
ajandClaude Opus 5.5 6be8a7b41c feat(ui): edit Shape Library parameters as dimensions on the drawing
The Shape Library no longer has a side panel of inputs. Each length is a
dimension line or leader on the preview; click its label to type a new
value (Enter applies, Esc cancels, Tab moves to the next dimension), as
Edit Clamps does on the plate. Invalid values keep the editor open in
red and the last good shape on screen. Settings with no place on the
drawing (sides, pipe size, clearance, blind) are clickable notes along
the bottom. The flange hole leader carries the count beside the size,
such as "Ø7/8" x 8 holes".

Shapes declare their dimensions through ShapeDefinition.GetDimensions;
lengths show as fractions when exact to 1/64 (Fraction.Format). Edit
Clamps' inline text box moves to a shared DimensionBox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:25:43 -05:00
ajandClaude Opus 5.5 9b643aaec9 feat(engine): refill the pair grid's leftover strip with rows of pairs
The pair filler topped its grid off with single parts at 0 or 90 degrees,
so a narrow strip beside the grid held half of what it could: on a 60x120
sheet of N26-29X parts the plate fill gave 132, while deleting the last
column and filling the strip gave 136. The strip is now also laid out the
way the Row strategy lays one, rows of best-fit pairs turned so whole pairs
span it, and that layout replaces the singles when it places more.

Each strip size is laid out once per fill and skipped when the singles
already fill it by area. On the benchmark nest the plate fill now reaches
136 with the hand-made layout's rotations, about 17% slower (156 s vs
133 s with best fits cached). The pinned L-shape fills gain a part (52 to
53). The auto-nest problem-nest corpus keeps its baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:15:36 -05:00
ajandClaude Opus 5.5 a9fc9f0afb feat(ui): duplicate a plate by Ctrl+dragging it or from its right-click menu
Copying a layout meant recreating the plate and copying its parts by hand.
Ctrl+drag a row in the Plates list drops a copy at that slot; the row's
right-click menu has Duplicate Plate, which inserts the copy after it.

Plate.CopyLayout copies the sheet (size, outline, spacing, quadrant, grain,
clamps, support bed), the parts with their programs and lead-in state,
common-line groups and cut-off definitions, regenerated in their places in
the sequence, as a save and reload would. The copy is one sheet with no
stock item codes, since those name particular physical sheets. The desktop
gives it its own cutting parameters and records it as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:07:52 -05:00
ajandClaude Opus 5.5 872cd8e4b8 fix(ui): show the plate size on the new-plate header
The empty new-plate header read only "New plate (empty)", so the sheet
size of the plate about to be nested was hidden until parts were added.
FormatHeader now appends the size (and quantity above one) there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:48:52 -05:00
ajandClaude Opus 5.5 e6e2e5e0ad test(corpus): guard Auto Nest problems in the problem-nest corpus
The corpus is meant to hold every nest that ever caused a problem, tagged with
what it guards, but it could only plan cutting paths. Nests tagged
`checks: auto-nest` now run the Default engine on their demand and fail when the
layout is invalid, leaves parts unplaced, or uses more sheets than their
`auto-nest: N sheets` line; planner runs skip them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:17:57 -05:00
ajandClaude Opus 5.5 863ce4afde feat(engine): drop a sheet by packing edge parts first and turning the key part by its contacts
The Irregular engine grows each sheet from one side, so parts that fit only
when the outer ones hug opposite edges never met: ten one-off brackets
(four tapered 112-inch bars, four gussets, two small plates) stacked three bars
on one sheet and took three 60 x 120 sheets where two hold them.

After the search, a sheet-reduction pass splits a complete single-stock plan
over one sheet fewer and packs each sheet edge first: large parts to the
corners (flush, most outline on the edges, thinnest across the sheet), small
parts into the gaps last, and one key part into the pocket between. Clamped
sides move in past the keep-outs so edge parts line up on one straight edge.
When no sampled orientation fits the key, ContactFit turns it by its contacts
(each overlap a linear constraint on dx, dy and rotation) instead of sampling
more angles; the exact no-fit free region then confirms the angle, so spacing
and validation are unchanged. That job now nests on two sheets, the middle bar
at 4.4 degrees; the repository benchmark jobs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:17:56 -05:00
ajandClaude Opus 5.5 b1430bf5ba fix(ui): keep the Drawing Sets window in front of the main window
File > Drawing Library opened the window unowned, so clicking the main
window buried it. It is now owned by the main window: still modeless,
so nests stay editable, but it always stays on top of it. It keeps its
taskbar button and minimize, which nesting during a release relies on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:44:23 -05:00
ajandClaude Opus 5.5 535219b073 fix(release): keep released tabs saved so a later merge release records
Make Nests bound each tab it nested to its saved record but left the
tab's history off its save point, so the tab showed " *" and closing it
asked to save. Yes uploaded a second revision, and a release that had
already looked up that draft (the panel asks the operator to save and
close an open draft) then merged with If-Match on the old revision: the
server refused it, that group failed, no nest after it was saved and the
release was not recorded. End() then closed the leftover tabs through
the unsaved prompt, whose Yes the release lockout refused.

- Binding a released tab marks its save point (EditNestForm.MarkSaved).
- Make Nests re-reads each merge target and merges at the revision now on
  the server; one saved while the release runs is still refused.
- End() closes the tabs it did not save without the prompt
  (CloseDiscardingChanges), after taking them off the release list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:50:22 -05:00
ajandClaude Opus 5.5 a437162039 chore(help): pin help v0.7.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:34:26 -05:00
ajandClaude Opus 5.5 1caf9b68bb fix(ui): fit the Allow common line hint on one line
The hint wrapped to a second line that Drawing Properties clipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:27:14 -05:00
ajandClaude Opus 5.5 4aa035af2f feat(diagnostics): mark plate-edge findings on the plate and say "outside the work area"
The Layout label counted parts off the plate or inside the edge spacing, but
nothing showed which part or where, so the user had to hunt for it. Each edge
finding now gets a canvas marker like the overlap and spacing ones: a red
crosshair where a part leaves the sheet and an orange ring at the gap to the
edge, in every visible display mode, with hover text that carries the finding's
message. Markers are retained across edits like the other overlay findings
(only a changed part's marker drops), and a sheet size, edge spacing or outline
change clears them. Validate Nest already located these findings through
PostVerificationFinding.Location.

"Near edge" / "inside the edge spacing" read as a spacing rule rather than a
placement problem, so the label, overlay messages, PlateEdgeReview findings
and the Validate Nest category now say the part is outside the work area. Off
the plate keeps its wording. The PostVerificationKind.EdgeSpacing name stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:28:52 -05:00
ajandClaude Opus 5.5 7cc0e6f126 fix(ui): count parts off the plate in the Layout status label
The status bar's "Layout: OK" ran its own snapshot check of overlaps and
part spacing, separate from Validate Nest and posting, so a part dragged
off the sheet still read OK.

The plate capture now owns the sheet frame (sheet box, work area,
outline), and the worker runs PlateEdgeReview's material check on it:
"N off plate" and "N near edge" join the label. Snapshots hold no leads,
so leads past the sheet stay a Validate Nest/posting finding. The
geometry stamp now includes edge spacing and outline so changing them
marks the result out of date. Bare-part captures (Plan Cutting) have no
frame and are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:38:02 -05:00
ajandClaude Opus 5.5 7d498f2b41 fix(common-line): keep copied joined parts joined when pasted
Copy and Cut paste through ActionClone, which added each clone to the plate
but never recorded the groups the sources were in, so a pasted common-line
pair came out as separate parts held to the part spacing. ActionClone now
reads the copied parts' groups when it starts (before Cut removes the
sources and their groups) and joins each paste's clones the same way, at the
source group's kerf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:27:39 -05:00
ajandClaude Opus 5.5 f09a9ad705 feat(common-line): move joined parts as one and add a part right-click menu
Moving one member of a common-line group used to break the group, so a drag
or nudge silently undid a join. Selecting a member, by click or window, now
selects its whole group, so drag, nudge, push, rotate and align carry the
joined parts together and keep their shared spans.

Right-clicking a part now offers Join Common Line and Remove Common Line,
running the Plate > Arrange commands on the selection. A right-click on a part
keeps its selection (or selects it with its group) instead of clearing it, so
the menu has something to act on; a right-click on empty plate still clears.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:52:54 -05:00
ajandClaude Opus 5.5 78a5ad88e2 feat(drawings): explain an empty list that Hide Fully Nested Drawings emptied
With the filter on and every drawing nested, the Drawings tab was a blank
list with no hint why. It now shows a note that turns the filter off when
clicked. The filter is never turned off on its own: it is the user's
choice and would stay off when new drawings arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:21:27 -05:00
ajandClaude Opus 5.5 5eb7fd920b fix(slide): let a part slide along a neighbor touching it through PEP rounding noise
Six-decimal incremental PEP programs round arc centers, so an arc's end
tangent misses its neighbor's by about gap / radius (1.6e-5 rad on the
offset 5/32" fillet in the reported nest). Two parts resting one spacing
apart then gave the slide contact a Minkowski cone a hair wider than a
half-plane, which the classifier read as the whole plane: every direction
blocked, so the top part could not be pushed sideways along the part below.

SlideContact now reads a cone up to TangentSlack (1e-4 rad) past a
half-plane as a noisy half-plane and widens its boundary and concave-ray
comparisons by the excess, so near-tangential slides read as tangential.
A direction admitted that way can enter material by at most travel x
excess at that contact, and every other contact is still classified on its
own; wider cones remain real overlap and block. Raising JointTolerance was
not needed for this case and is left at 1e-6.

Whole-job benchmarks (synthetic nests plus the PEP job at 3 and 60 parts,
--parallel 1) are identical before and after for every engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:13:54 -05:00
ajandClaude Opus 5.5 dd0c31271a fix(push): let a part push away from a neighbor it touches at the spacing
A part placed exactly one part spacing from its neighbor touches the
neighbor's offset contour. PEP drawings round arc centers to six decimals,
so their offset contours carry hairline kinks and joint gaps where the parts
touch; the slide kernel then classifies that contact as blocking in every
direction, and Push moved the part 0 even straight away from the neighbor.

An obstacle whose bounds end at or behind the moving part's trailing side
can only fall further behind, so Push and PushBoundingBox now skip it
(within Tolerance.Epsilon) instead of only when strictly behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:37:53 -05:00
ajandClaude Opus 5.5 f88b805e68 fix(cut-off): select head-locked cut-offs by click and window
Clicking and window selection paired the cut-off program's codes as
rapid/cut at even positions. A head-locked far-edge segment carries lock
comments (and, cut from the far edge, a rapid return) between its cuts,
so no pair matched and the line could not be selected, though it drew.

CutOff.CutStrokes now walks the motions once for the renderer, click
hit test, window selection and the Sheet Cut-Off preview, so what is
selectable matches what is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:36:58 -05:00
ajandClaude Opus 5.5 2b23150801 fix(reporting): open the report viewer centered on the main form
FormStartPosition.CenterParent is not applied to modeless windows, so the
viewer opened wherever Windows placed it. Center it on its owner in OnLoad,
shrunk to fit and kept inside that screen's working area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:58:36 -05:00
ajandClaude Opus 5.5 b026bd19d2 feat(common-line): place parts on common lines automatically at a chosen level
Slice 3 of common-line cutting. On costly plate (N26-295: 3/16 RA310S at about
$2,500 a sheet) pairing parts one kerf apart fits the part the spacing cannot:
the last plate's 38 parts fit on one 48x120 with Auto Nest only with pairs.

- Drawing Properties gains "Allow common line" (Drawing.AllowCommonLine, off by
  default because customer edge-quality needs vary). The nest's CommonLineUsage
  (Off / When it adds parts / Whenever available) is set in Auto Nest and seeded
  from Tools > Nest Defaults; both persist (nest.json, defaults.json).
- NestPipeline runs the ordinary solve, then, with a level on, trials where
  eligible parts are offered as kerf-gap pair compounds. Placed pairs expand into
  parts plus explicit NestJobCommonLine groups, are validated again, and
  CommonLineRanking keeps a trial only by the level: count first, then sheets;
  "when it adds parts" keeps the plain layout on a tie, "whenever" also takes a
  shorter cut or less material, never at the cost of a part. Engines, FillScore
  and their comparers are unchanged; Off asks the engine exactly what it did.
- NestLayoutCheck exempts members only through a sheet's explicit group list and
  only when the job allows common lines; materializing and committing create
  Plate.CommonLines.
- Server nesting writes common-line jobs as version 3 and replays the recorded
  trial answers, so the desktop binds the same groups.
- Fill Area honors the level through a PlateFillService overload.
- The benchmark gains --common-lines <level> --kerf <k>.

Benchmark (synthetic nests + N26-295, --parallel 1): with no flag and with
--common-lines off every engine matches the base commit; with a level on no
layout becomes invalid and three runs save a sheet at equal nested area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:38:00 -05:00
ajandClaude Opus 5.5 f9b2713420 feat(common-line): cut each shared line once through Plan Cutting and the CI Fiber post
N26-295's PT42 pairs saved spacing in slice 1 but every shared edge was
still cut twice, costing a pass per edge and risking a freed part
tipping under the head on the second pass.

Plan Cutting now cuts a plate's common lines once unless the plate is set
to cut them twice. Each group is cut member by member, owner before
partner (a captured dependency that search and replay enforce, so a kept
order cutting the partner first is a constraint conflict): the member's
holes, then the shared cuts it owns as open CommonLine centerline cuts
entered through the notch on the span's axis, then its remainder pieces,
each ramped in from the kerf the shared cut opened. Capture marks the
shared spans as tabs on the owned clean copy, so replay's contour
accounting proves each perimeter is cut exactly once; a lead that fits
at neither end refuses as UnsupportedGeometry naming the span, never
shortened. Material still comes from the drawing, so utilization,
quantity and overlap results do not change. Members are never
support-tabbed; locked or excluded members and broken groups refuse.

The CI Fiber post writes shared cuts in the cut-off form with a lead-in
and no G41/G42, and remainders in the contour form on their perimeter's
compensation side, named by a remainder comment. Cincinnati CL (whose
PreApplied mode offsets nothing) and Gravograph refuse such nests, as do
the desktop and console for any post without ICommonLinePostProcessor;
nothing falls back to a silent double cut. Plate > Arrange > Cut Common
Lines Twice keeps slice 1's behavior, persists with the plate and makes
an open plan stale.

Pre-post review now checks each span is cut once, on its centerline, by
the member cut first, and the rapid check treats a finished member's
remainders as an obstacle. Compensation start in void and cancel in
kerf are unconfirmed on the CI FIBER 4020; release waits on a 3/16
stainless coupon trial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:51:42 -05:00
aj b630b50b7b fix(ui): label the server jobs button Stop Job
The job it acts on is shown as Stopping and then Stopped, and the rest of the
app talks about stopping server jobs, so the button now says Stop Job too.
2026-11-05 15:00:15 -05:00
ajandClaude Opus 5.5 1d87d212de feat(common-line): place parts one kerf apart to share an edge
Expensive alloy jobs (N26-295, 310 stainless) could not fit their last
PT42 at full part spacing; the plate was squeezed to 0.2 spacing with a
part 0.14 from the edge. Parts whose straight edges face each other one
kerf apart are cut on the same centerline, saving the spacing along each
shared edge.

Plate.CommonLines holds groups of parts and the kerf they were joined
at. Shared spans are derived from the placed clean perimeters, never
stored, so a moved member breaks its group instead of leaving a stale
span. Plate > Arrange > Join Common Line slides the selected parts
together at the plate's kerf; Remove Common Line ungroups them. The
plate draws shared spans as a dashed centerline.

Both spacing checks hold group members to the kerf instead of the part
spacing, and a new pre-post Common lines check reports broken groups, a
changed kerf, and leads that run into a partner. Groups persist as part
positions in an optional commonLines array that older readers ignore.

This first slice still cuts every shared edge twice; single-pass cutting
through Plan Cutting and the posts comes next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:16:13 -05:00
ajandClaude Opus 5.5 924763493d feat(report): place part IDs wherever they fit best, holes included
Ring-shaped parts (a plate with a large center hole) were left unlabeled:
the material at their pole is too narrow for the ID, and the label was
never tried anywhere else. Group IDs were likewise only ever placed beside
the dotted outline, so a cramped cluster lost its ID or straddled the
sheet edge.

A part whose pole spot is too tight now takes the roomiest clear spot
within its own extents, such as its empty hole. A group ID takes the
roomiest spot inside its outline or beside it; with none clear it may
cover the group's own parts near the center, since operators read part
detail from the parts list. No ID sits across the sheet outline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:01:39 -05:00
ajandClaude Opus 5.5 46adbfd6f6 fix(ui): refresh the drawing list on the UI thread, never from the timer thread
EditNestForm's debounce timer is a System.Timers.Timer whose Elapsed ran
on a pool thread, checked drawingListBox1.IsHandleCreated, then called
drawingListBox1.Invoke. Invoke reads Handle, so a handle destroyed
between the check and the call was re-created on the pool thread (the
cross-thread check is off outside the debugger) while the UI thread was
disposing the window, which threw "Dispose() cannot be called while
doing CreateHandle()". The timer starts enabled at 50 ms, so short
WinForms tests such as LedgerCodeRecheckTests.NoLedgerOrNoCodes_ChecksNothing
hit it intermittently on CI.

Elapsed now posts to the window's UI synchronization context and the
refresh checks IsDisposed there; the timer is disposed with the form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:55:14 -05:00
ajandClaude Opus 5.5 fbe82d198a fix(report): keep IDs on neighboring part groups one spacing apart
Each dotted group outline was padded 1 pt beyond its parts. At overview scale
a 0.25 in part spacing is only ~1.3 pt, so the outlines of two adjacent
clusters (e.g. the six small bars and the two holed plates on N26-27Y plate 1)
overlapped, and the later group - with its ID - was silently dropped.

Pad outlines by under half the part spacing instead, so neighboring clusters
both get their outline and ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:49:02 -05:00
ajandClaude Opus 5.5 a994bae76f feat(open): list materials for several saved nests
The Database-mode Open dialog now allows multi-row selection and a Get
Materials command (button and context menu) that downloads the chosen
nests, or every nest the current search matches across all pages, and
lists the sheets they use by material, grade, thickness and size with
the quantity and the nests using each. Needed to order stock for a whole
job's worth of nests at once instead of opening them one by one.

Material, grade and thickness come from each nest's server record;
sizes and duplicates from its plates, skipping plates with no parts.
Copy All and Save CSV export the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:57:43 -05:00
ajandClaude Opus 5.5 f9abab0c6d feat(verify): warn when parts leave the plate or its edge spacing
Validate Nest and the pre-post review checked overlaps and part-to-part
spacing but never the sheet itself, so a part dragged past the plate edge,
a lead-in piercing off the sheet, or a part inside the edge spacing could
post without a warning. Only Auto Nest's own layout check held parts to
the work area.

PlateEdgeReview adds two finding kinds:
- OffPlate: any cut, lead or scribe motion past the sheet, or clean
  material outside an outlined sheet or over one of its obstacles.
- EdgeSpacing: clean material (leads excluded) outside the work area, or
  closer than the largest edge spacing to an outline or obstacle - the
  bound Auto Nest's layout check applies, with SpacingSlack, so layouts it
  accepted stay clear.

Cut-off parts are exempt. Routing-replay test fixtures sit at the sheet
origin with leads hanging off it; their route assertions now ignore the
sheet-bound kinds, and the post-contract fixture moves onto the sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 08:59:23 -05:00
ajandClaude Opus 5.5 3cd7729d38 feat(cutting): keep cutting settings per machine material and thickness
Tabs suit thin plate, but on anything thicker than 0.25 in the parts
are too hard to break out of the sheet, and the Cutting Parameters
dialog was one global set for every job. Its settings now live on the
nest's machine thickness: leads on the existing Outside/Inside Cuts
tabs, and lead-in angle rounding, tabs and pierce on a new Tabs &
Pierce tab (ThicknessConfig.Cutting). In Database mode every
workstation gets the same settings through the shared machine.

The dialog and the Place Lead-in panel say what they edit, and save
back to that thickness (MachineCuttingDefaults.WriteTo, converted to
the machine's units). A thickness with no Tabs & Pierce of its own, or
a nest the machine has no thickness for, keeps this PC's settings as
before. If the machine can't be saved (server offline, a conflict),
the edit is kept on this PC for that job and the user is told.
Machine Configuration can copy one thickness's Tabs & Pierce to
thinner or thicker ones, so the 0.25 in rule takes a few clicks.

An older desktop saving a shared machine would silently erase these
settings, since it doesn't know them. Machine configuration classes
now keep unknown fields, and the server refuses a machine save that
leaves out a field the stored machine has a value for, telling the
user to update OpenNest.

The Plan Cutting status line now reports tabs from SupportTabs; since
"Tab every part" was retired it always said off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 08:24:39 -05:00
ajandClaude Opus 5.5 6de23fb831 feat(tabs): replace "Tab every part" with tabbing parts by size
Nobody tabs every part, but tabbing by size is useful even on a plate
that has a support grid. Cutting Settings > Tabs now offers "Only tab
parts the support grid can't hold level" or "Tab parts by size"
(CuttingParameters.TabBySize), which ignores the bed and tabs each part
whose minimum-area bounding rectangle's narrow side is in the range.
The support grid choice still falls back to that range on plates with
no bed, as before.

The range fields become TabSizeMinWidth/TabSizeMaxWidth; their JSON
keys stay noBedTabMinWidth/noBedTabMaxWidth so saved settings and nests
read unchanged. TabsEnabled stays as the per-contour flag Plan Cutting,
Place Lead-in and drawing pins set on the parts they tab.

Saved user settings with "Tab every part" read as tabs by size from 0
to 9999, which tabs the same parts. Only the user's settings migrate;
a plate's recorded parameters and pins replay as planned.

Plan Cutting, Place Lead-in and the pre-post review all go through
SupportTabSettings, so they agree on which parts need a tab. A
size-tabbed part is held by any clear tab, so it is never a rapid
obstacle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:46:24 -05:00
ajandClaude Opus 5.5 ae14458444 fix(ui): open View in CAD through the shell instead of crashing
.NET 8 defaults Process.Start to UseShellExecute=false, so handing it the
temp DXF tried to run the file as an executable and threw an unhandled
Win32Exception that took the app down. Open it through the shell so the
.dxf association is used, report write/launch failures in a message box,
and replace characters a file name cannot hold in the nest name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:21:33 -05:00
ajandClaude Opus 5.5 0e4f4f5109 fix(cutoffs): move a clamp-blocked tail separator past the clamp
A tail separator that landed on a front-edge clamp was skipped outright, so
a plate lost its whole remnant (19.5 in on a 48 x 120 sheet with the part
ending at a rail clamp) over a 1.5-in clamp. The separator now steps to the
clamp's far edge plus its clearance, repeating for any clamp it then meets,
and reports ClampMovedTailSeparator. The kept tail is shorter by that
distance and must still meet the minimum tail length; a separator pushed off
the sheet still reports NoSafeTailSeparator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:15:24 -05:00
aj cf483c1a2e fix(ui): say Stopped for server jobs the operator stopped
Every server path into NestJobState.Cancelled requires cancelRequested (Cancel Job), and the running state already reads Stopping.
2026-11-04 23:03:59 -05:00
aj e8215004ab chore: tidy cut-off spelling in new text and a stale comment 2026-11-04 23:02:58 -05:00
aj e290254a18 fix(ui): give console and MCP users a hint they can follow on outlined sheets 2026-11-04 23:02:21 -05:00
aj f31cd1c797 fix(posts): name the post settings fields in head-raise and head-lock refusals 2026-11-04 23:01:58 -05:00
aj 014051c121 fix(ui): describe the update source the way updates are actually found 2026-11-04 22:59:41 -05:00
aj 1179210e46 docs: correct outline, schema and internal-label wording 2026-11-04 22:58:23 -05:00
ajandClaude Opus 5.5 dc559a6c66 test(release): smoke the Lattice engine by its current name
The release smoke instantiated the multi-phase fill engine through its
old Fill alias, so it never proved the current name. It now creates
Lattice and checks that Fill still resolves to Lattice, as it does for
the Opus55NestingEngine rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:56:38 -05:00
ajandClaude Opus 5.5 4f272176b3 docs: use the Lattice engine name throughout
The multi-phase fill engine and strategy is named Lattice; the docs still
called it Fill in several places. Generic fill passes stay lowercase and
the renamed-engines alias table is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:55:51 -05:00
ajandClaude Opus 5.5 227a561d59 fix(ui): call the default fill strategy Lattice in console help and MCP descriptions
The multi-phase fill strategy has been named Lattice since the engine
rename, but the console's --engine help and the MCP tool descriptions
still offered it as Fill. Fill keeps working as an alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:54:51 -05:00
ajandClaude Opus 5.5 0547af2ff6 fix(nudge): always move the full calculated step
Interlocked rows, such as staggered triangles, cannot slide straight past
their neighbors, so capping the step with a push left them unable to
pattern down. The calculated step now moves the full distance like a fixed
nudge, even off the plate or onto a part; the layout check reports either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:42:53 -05:00
ajandClaude Opus 5.5 d98bee87ec feat(nudge): step the selection by a calculated distance
Set Offset Increment gains a "Calculated distance" option. With it on, the
arrow keys move the selection by its own size plus the part spacing,
measured on its geometry like a fill's copy distance, so it lands tight
without a push back. The move stops early at an edge, clamp or part, and a
clone preview steps clear of the copy just placed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:21:56 -05:00
ajandClaude Opus 5.5 b5bc03bea1 refactor(circle-fill): merge FillEndOdd and FillEndEven into StaggeredFill
The two stripe layouts differ only in how far odd stripes shift, so they are
now one StaggeredFill with StaggerMode.HalfPitch (was FillEndOdd: half-pitch
shift, odd stripes one disc fewer) and StaggerMode.SlackShift (was
FillEndEven: shift by the stripe's leftover, every stripe full). The unused
FillEngine base and its throwing Fill(item, maxCount) go away. Layouts and
orientation tie-breaks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:02:03 -05:00
ajandClaude Opus 5.5 dffb06a9e3 feat(circle-fill): end staggered rows with straight rows when that fits more
The Circle fill strategy's stripe layouts (FillEndOdd, FillEndEven) now try
switching from staggered to straight stripes, a diameter apart over the last
staggered stripe, at every stripe, all staggered and all straight included,
and keep the switch that places the most; a tie stays staggered. A 9" circle
on a 60x120 sheet at 0.2 spacing now fills 89 (five staggered rows, two
straight) instead of 88.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:02:03 -05:00
ajandClaude Opus 5.5 8700ae187a feat(expand-spacing): keep rows that share a pitch but stack unevenly
A single-drawing selection that fits no one lattice, such as staggered rows
topped by straight ones, now counts as a pattern when its rows share one
pitch along X (or Y). Each row stretches rigidly instead of the selection
falling back to the slow pair search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:44:12 -05:00
ajandClaude Opus 5.5 7f718f222c feat(expand-spacing): stretch repeating patterns instead of pushing pairs apart
A selection that repeats on a lattice (a fill) now has its lattice scaled
along X, then Y, as far as the work area and clearance floors allow, so the
pattern survives. Multi-part motifs stay rigid, no selected gap closes below
the smallest entry gap, and parts never pass through fixed material. Other
selections keep the pair search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:12:22 -05:00
ajandClaude Opus 5.5 440fe767f1 fix(ui): keep the drawing library dialogs above the main window
The library dialogs were owned by the nest tab (an MDI child) or by the
library view, both child windows. Windows only keeps a dialog above its
owner, so the main window could cover them. Own them by the top-level
window instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:47:59 -05:00
ajandClaude Opus 5.5 4dae54c629 feat(plates): resize plate to fit parts at any size
Resize to Fit snapped layouts larger than 48x48 to a hardcoded list of
inch mill sheets, so a plate already on a full sheet could not shrink to
its parts, and the cutoff ignored nest units and the machine's stock
sizes. It now rounds the parts' extent up to the auto-size plate factor
at every size through Plate.AutoSize.

Removes Plate.SnapToStandardSize and PlateSizes.Recommend with its
options; the sheet list and label lookup stay for Machine Config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:45:33 -05:00
ajandClaude Opus 5.5 7ea21f3afe fix(reporting): weigh parts with the machine catalog's material density
Nest reports showed "-" for every part weight on N26-295 because the nest and
its drawings store Stainless Steel 310 with density 0: materials are picked by
name and grade, and nothing copies a density onto them. The densities live in
the machine material defaults, and for this nest not even on its own machine
(CL-980 lists only 304; 310 is on CI FIBER 4020).

The desktop now resolves a catalog density before capturing the report: the
nest machine's exact name and grade, any machine's exact name and grade, then
the same material name. A density stored on the nest still wins, and the
drawing's own material stays the last fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:51:23 -05:00
ajandClaude Opus 5.5 90a1a087f0 feat(plates): reorder plates by drag and drop in the Plates list
Operators want plates of the same size grouped together so material
doesn't need sorting when it arrives; there was no way to change plate
order. Rows can now be dragged to a new position (with a drop line and
edge auto-scroll), or moved with Alt+Up/Alt+Down. Each move is one
undoable "Move Plate" step and only reorders: quantities are untouched.

The trailing empty new-plate sentinel stays last. An empty plate moved
to the end becomes the sentinel, as the sentinel rule already does on
reload, so the order shown is the order saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:41:23 -05:00
ajandClaude Opus 5.5 890cbc486b feat(status-bar): show the material grade in the material label
The status bar showed only the material name and thickness, so nests
of the same material in different grades looked identical there. The
grade now follows the name, e.g. "Material: Steel A36 · 0.25 in".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:35:31 -05:00
ajandClaude Opus 5.5 698b91d256 feat(reporting): show job parts as cards with size and weight
The summary's parts table squeezed each thumbnail into a 62 pt column and
had no room for the size and weight the shop asks for. Each drawing is now
a card, four to a row: ID, a larger thumbnail, name, bounding-box size,
weight each and for all nested copies, required/nested counts and plates.
A shortage or extra is a badge beside the ID (SHORT in red) so it stays
easy to spot without a column to scan.

Weight is net area x nest thickness x material density, read as lb/in^3
like the machine material defaults; millimeter jobs report kilograms. A
nest without thickness or density shows "-" and a note saying what is
missing, never a zero weight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:24:30 -05:00
ajandClaude Opus 5.5 9d4d024d60 feat(reporting): view nest reports in a window to save or print
Export Nest Report only wrote a file, so checking or printing a report meant
finding it and opening another program. View Nest Report renders the same
snapshot to a private temp folder and opens it in a WebView2 viewer with
Save As and Print; closing the window deletes the temp copy. Export and View
share the capture and write guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:02:42 -05:00
aj 54cea0c325 Merge branch 'fix/report-group-ids' 2026-11-04 09:38:39 -05:00
ajandClaude Opus 5.5 c60694b947 fix(reporting): label groups of small parts nested in a larger part's notch
Group outlines and their shared ID were rejected whenever they touched
another part's bounding box. A large irregular part's box can cover most
of the sheet, so small repeated parts sitting in its notch or cutout
(e.g. test squares) were left with no ID at all. Test clearance against
real contours instead, as individual labels already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:38:30 -05:00
ajandClaude Opus 5.5 032854895d fix(reporting): fill parts whose arc ends carry post roundoff
The nest report joined contour segments only within 1e-12, but converted
arcs rebuild their end from the start radius and end angle. Rounded or
incremental programs (end radius a few 1e-7 off the start radius, which
the report's own validator accepts up to Tolerance.Epsilon) left every
arc a hair short of the next move, so outlines split into open pieces
and N26-27K's parts printed unshaded as if they had tab gaps.

Join within Tolerance.Epsilon instead, still far below ShapeBuilder's
welding tolerance and real tab gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 03:09:15 -05:00
aj be5fef6875 fix(release): enforce one finish per commit and keep real unplaced reasons
Delta review fixes:

- ReleaseRenest throws when a commit does not call finish, or calls it
  twice: either would leave ledger plates uncoded or count a sheet twice
  for later nests of the run. The ledger sheet count is the codes given.
- A stage whose layout is not kept now lists the copies its layout
  missed with their own reasons (a part no sheet fits stays "does not
  fit"); only the copies it placed are "the proposal cannot be kept".
- A valid ledger layout that places nothing says "Auto Nest placed
  nothing on it" rather than claiming no part fits.
- ReleaseLedgerOffer rejects null rows; docs list every ledger note.
2026-11-03 18:03:24 -05:00
aj 9efeb4e5b6 test(release): check the ledger step's own undo state keeps its code 2026-11-03 17:55:16 -05:00
aj 3e35821ab1 docs(release): note bundle sharing, merge drafts and every ledger note 2026-11-03 17:53:26 -05:00
aj c5b6e680cb fix(release): keep ledger codes in the undo step and share bundles per sheet
Review fixes for ledger-first Make Nests:

- The release renest (offer, staged run, codes, note) moves into
  OpenNest.Desktop.ReleaseRenest, shared by the headless panel, the
  live main window and the tests. Codes are given inside each commit, so
  the live tab's Auto Nest undo step records them; before, undo and redo
  dropped every ledger code.
- A bundle repeats one code per sheet: a later nest of the run lost the
  whole bundle once an earlier nest took one sheet. Taken sheets are now
  counted per code (LedgerCodeFilter.ExcludeCopies).
- A stage whose layout is not kept lists every copy it asked for as not
  placed (the proposal cannot be kept), and a rejected ledger layout
  says "MaterialLedger not used: Auto Nest could not keep a layout on
  its stock" instead of a bare "No MaterialLedger stock used".
- "no free stock" is said only when the ledger had none; otherwise the
  note counts what was left out (on other nests, taken by earlier nests
  of this release, too small, unknown shape).
- The ledger is not read for a nest with nothing to nest, the live read
  runs under the tab's stop token, a reason's own full stop is not
  doubled, and the sheet count is the codes given.
2026-11-03 17:53:26 -05:00
aj 31ee77914f docs(release): describe ledger-first nesting in Make Nests 2026-11-03 17:38:07 -05:00
aj 3b5cf92af2 feat(desktop): Make Nests nests each group on its MaterialLedger stock
With Nest after release ticked, one ReleaseLedgerStock is shared by the
whole run and passed to the renest step, headless or live in the main
window. The live tab runs one Auto Nest per stage (ledger stock, then
the Nest Defaults sheet), records the ledger locations for the plate
tooltips, and the group's note says how many sheets came from the
ledger or why it was not used.
2026-11-03 17:38:07 -05:00
aj 2939fe0802 feat(release): nest released groups on MaterialLedger stock first
NestRenest gains a staged run: a nest is nested on finite first stock
alone, then whatever is left on its plate defaults and plate-size
options. Each stage keeps only a valid layout, and the result lists the
stock id each committed plate was solved on.

ReleaseLedgerStock is the ledger stock of one Make Nests run: each nest
is offered the free sheets and drops Auto Nest's ledger load would tick
for its material, grade and thickness (substitutes included), minus
codes on other open server nests (read once per run) and codes earlier
nests of the same run took, which are not on the server until the
release uploads them. Committed ledger plates get their codes as Auto
Nest gives them. An unusable ledger nests on the plate defaults and the
note says why the ledger was not used.
2026-11-03 17:38:07 -05:00
aj 072e96c08e fix(ci): expect the role-aware image healthcheck in the server smoke
The image HEALTHCHECK became /app/healthcheck.sh when the worker role was
added (54ad7ee8), but the container smoke still required the old inline curl
probe, so every Server image validation since then failed before publishing.
Expect the script form and describe it in the storage docs.
2026-11-03 16:19:28 -05:00
ajandClaude Opus 5.5 53d46303cd fix(planning): raise the head out of a cutout's slug after its lead-out
A cutout's lead-out ends in its slug, so the rapid after it always crossed
the cutout just cut and the strict search refused every plan with cutout
lead-outs. The slug is free and may drop or tip, so emission now marks that
rapid to raise the head, and the rapid check accepts a raised rapid that
starts at the lead-out's end and leaves its cutout exactly once. Cutouts
without a lead-out, or with an overcut, still leave from the cut head down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:45:12 -05:00
ajandClaude Opus 5.5 6c4ebe49b3 fix(planning): cut the remnant cut-offs last
The last strip line stops at the remnant's full-length horizontal
separator, so it never counted as separating the sheet and the planner
cut it wherever the route passed it, often first (N26-264). That frees
the X-tail remnant early and loses the mass that absorbs heat while the
parts are cut.

A continuous line from a sheet edge or separator to another now counts
as a separator too (CutOff.Separators, found in rounds), so Plan Cutting
and Part Sequencing cut it after every part, just before the separator
it stops on. The problem-nest harness now adds scrap cut-offs with
KeepHorizontalRemnant, as both desktop entry points do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:50:33 -05:00
ajandClaude Opus 5.5 5de628760f feat(ui): fill a blank Nest Info Made By with the current user
A blank Made By now starts from the name the user last typed there, or
their Windows user name when they never typed one. Changing the box on OK
remembers the new name in user settings; clearing it falls back to the
Windows name again. A nest's own author is shown unchanged and is not
remembered unless the user edits it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:23:24 -05:00
ajandClaude Opus 5.5 d7fe7fe33c feat(ui): mark unsaved nests and ask before closing them
A nest window gave no sign that its nest had changed since the last
save, and closing it (or the app) discarded the edits silently. The
undo history already tracks a save point, so the title now ends in
" *" while the nest is off it (or holds an edit no scope recorded),
and closing asks to save, discard or cancel. Yes saves through the
storage in use; a database save cancels the close and closes the
window once the upload succeeds.

Saves now commit any unrecorded edit first so the save point covers
what was written. The window's cleanup moves from OnClosing to
OnFormClosed: another window can still cancel closing the app, and a
nest window must not lose its plate manager when that happens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:09:50 -05:00
ajandClaude Opus 5.5 fbc23d6b64 feat(nesting): nest higher priority numbers first
Priority worked like a rank (lower first) with a default of 0, and the
Edit Drawing form stopped at 0, so moving one part ahead of the rest
meant renumbering every other drawing. Higher-first lets a user raise a
single part to 1 and leave everything else at the default.

Every engine, filler and candidate comparer now serves the highest tier
first. Stored values keep their order:
- .nest files add `nestPriority` and keep writing the negated value under
  `priority`, so older readers nest in the same order; files without
  `nestPriority` are negated on read. Drawing packages strip both, so
  package hashes are unchanged.
- Library settings send both keys the same way for older clients.
- Server schema v18 negates stored drawing priorities (an older server
  refuses the upgraded database).
- Job documents move to version 2; version 1 jobs are converted, and an
  older worker refuses version 2 instead of reversing the order.

The Edit Drawing form now accepts negative priorities and says higher
numbers nest first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:02:07 -05:00
ajandClaude Opus 5.5 2721375e8b fix(ui): dash a cut-off's head-locked overcut past the sheet edge
The cut-off line renderer paired codes as rapid/cut at even indices. A
head-locked far-edge segment carries lock and unlock comments between its
cuts, so its overcut was never dashed and later segments could fall out of
step. Past the sheet only the dark part outline showed, which read as the
line having lost its orange-red color. Walk the motions instead and draw
each cut from the previous point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:03:13 -05:00
ajandClaude Opus 5.5 d133f6879b fix(geometry): leave leads out of push and collision material
A part with lead-ins applied could not be pushed away once another part
touched it. PartGeometry kept LEADIN/LEADOUT entities as material, so the
leads chained onto the perimeter as open spurs and the spacing offset no
longer closed. The slide-contact classifier cannot tell the material side
of an unclosed boundary and blocks every contact, so parts resting against
it could not move up or right off it. Leads are cut into scrap, and the
spacing review, overlap analyzer and support checks already drop them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:44:43 -05:00
ajandClaude Opus 5.5 322a66c339 fix(planning): take back a pair swap that leaves the search stuck
The "cut those two the other way round" rule is a guess. On an interlocked
holed-parts plate it swapped a pair whose swapped order then blocked on that
same pair; no further rule could be learned, the full search ran the strict
budget out, and Plan Cutting fell back to the safest route (0 strict
expansions), failing ContourRecoveryFileTests' interlocked rows since
16bd9256.

When a round teaches nothing and a swap was learned, the newest swap is now
taken back, never re-learned, and the attempt that taught it learns the plain
"cut this part before those" rule instead, backing up from that attempt as
any learned rule does. The swap is chosen apart from the plain rules, so a
"departed part first" rule with the same pair is never taken for a swap.

Both interlocked rows plan Ready within budget again. The problem-nest corpus
keeps every status and expansion count; a 4000-layout random sweep of 3-5
locked-lead rectangles keeps every status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:39:52 -05:00
ajandClaude Opus 5.5 52e421dcbe fix(cutting): center straight leads on a corner a tab wraps
A tab entered at a fillet joint trims back around the corner, so the cut
stops on one edge and starts on the other. Each straight lead took its own
edge's normal and the pair sat 150 degrees apart, reading as one bent line
through the corner. Both now take the bisector of the two ends' normals and
form the same V they form on a straight edge, falling back to their own
normals when either lead would not clear the part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:08:18 -05:00
ajandClaude Opus 5.5 6ee81a3501 fix(cutting): keep rapids off cut parts no single tab can steady
A support-tabbed part that no single tab can hold level may tip once cut,
yet its tabbed outside was crossable like any retained part, so Plan
Cutting could rapid back across a row of long strips. Its outside is now a
rapid obstacle, closed across its tab gap, in the search, replay, cut-off
insertion and the safest route.

Its fallback entries are ranked toward the next cut rather than steadiest
first, so a long strip can be left toward the next part, and the "cut those
two the other way round" rule applies only when the approach crosses no
other part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:07:28 -05:00
ajandClaude Opus 5.5 a0001f04ac chore(help): pin help v0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:29:13 -05:00
ajandClaude Opus 5.5 91089820ee feat(engine): group like parts in Irregular's grouped variants
After its six variants and tail re-plan, Irregular decodes the job again
with each type of three or more copies packed as one group, in a searched
order, holding back up to four copies per group that would start a ragged
row and filling them into the gaps afterward. When everything fits, the
best candidates are re-packed in a strip ending just short of the front.

The pass runs after the ungrouped search, which is unchanged, and replaces
its result only by beating it. The help tutorial's parts now end at 34.9"
instead of 38.1" on 60 x 120.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:41:33 -05:00
ajandClaude Opus 5.5 a2966b9f86 test(cutting): post the tutorial nest with a clean CL review
With the CL post declaring placed order for nests it writes as planned,
the help tutorial's nest must reach posting with no findings at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:29:57 -05:00
aj 5da1e35e88 Merge branch 'refs/heads/claude/objective-yalow-6f6bde' into claude/help-docs-update-list-760b9c 2026-11-02 17:26:44 -05:00
ajandClaude Opus 5.5 72039a238b feat(post): declare placed order for CL nests that post in it
The Cincinnati CL post always drew an "incomplete final rapid sequence"
warning because it never declared IPostVerificationSupport. It writes etch
features before cuts, inserts or drops rapids, and with part sub-programs
rapids to each part's corner, so it cannot declare it unconditionally.

IPostVerificationSupport gains a nest-aware PlacedProgramOrderChange(nest)
(null = preserved; otherwise the reason, shown in the warning). The CL post
answers it from its own writer: part sub-programs and smart rapids off, and
every part's and hole's posted motions exactly equal to its placed ones.
Plan Cutting cuts scribes first, so planned nests qualify. CNC output is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:21:25 -05:00
ajandClaude Opus 5.5 6771533e03 fix(cutting): leave the tab open on a round perimeter
A tabbed outside cut is trimmed short of its entry, but the trim only
handled lines and arcs: a round part's outline is one circle, so it was
cut whole. Plan Cutting counted it as tabbed and called the plan ready,
then the pre-post review found no tab and warned that the part may tip.
Cut a tabbed circle as an arc that stops the tab's chord short of its
entry, travelling the circle's own way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:20:23 -05:00
ajandClaude Opus 5.5 c243830641 test(cutting): plan the help tutorial's first nest ready
The "Your first nest" layout (the help's sample parts, Auto Nested on a
60 x 120 sheet over a 2 inch support grid) must plan without warnings,
with support tabs on and off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:54:59 -05:00
ajandClaude Opus 5.5 a0d6c7212b fix(cutting): raise the head for rapids into hole sub-programs
Round holes are cut as sub-programs, which are never marked because they
may be shared, so the safest-route fallback could not raise the head for
the rapid into a hole: on interlocked layouts every such crossing stayed
a finding and the plan stayed unverified. A HEAD RAISE marker may now sit
directly before a sub-program call, raising the call's arrival rapid.

- Motion reader: a marked call raises its first rapid; each sub-program
  motion records the part's own call that runs it.
- RaisedRapids marks the call when the crossing rapid is its arrival.
- Cincinnati CL keeps the marker with its hole call and writes M47 before
  it; part sub-program dedupe now tells marked programs apart.
- CI Fiber raises before the hole's first G0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:54:59 -05:00
ajandClaude Opus 5.5 8a282d53cf feat(cutting): let a tab alone hold parts up to 6 in new settings
Small parts on a coarse support grid (the help tutorial's 5 x 5 gussets
on a 2 inch grid) rest on one or two points, so no single tab could
steady them and every plan came back unverified. New cutting settings
now start "Tab alone holds parts up to" at 6; settings saved without the
field still read as 0, so existing settings keep their meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:54:59 -05:00
ajandClaude Opus 5.5 bfc36d166c fix(support): measure a part the same however it is turned
A 5 x 5 right triangle fits both its legs' square and its hypotenuse's
7.07 x 3.54 box, each of area 25, and rotating calipers kept whichever
rounding found first, so the same gusset measured 5 long at one rotation
and 7.07 at another. Of rectangles tied for least area, measure the one
with the shorter long side, so length, width and the tab tie-break no
longer depend on placement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:54:58 -05:00
ajandClaude Fable 5.1 ceddd8cf93 test(cutting): add an opt-in sweep over a folder of production nests
Plans every occupied plate of every .nest file in a local folder the way
Plan Cutting does after clearing lead-ins and cut-offs, and writes one
JSON line per plate with its outcome, warnings, fallbacks, expansions,
time and spacing pairs. It is how the reliability sweep found its
failure classes over 1,260 production plates; the nests and the report
stay outside the repository, and the test skips without the folder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 14:31:46 -05:00
ajandClaude Fable 5.1 84a0fedac0 fix(cutting): sweep a part's holes even after its searched entries run out
Contour recovery tries each outside entry's hole-tour search first for a
part with at most 16 holes, and the reading-order sweeps after them; once
the searched entries had spent the recovery allowance it jumped straight
to the widened tries and never swept. A frame cut from inside its own
cutout, after the strips nested there, is often routed only by a sweep,
which leaves the cutout on its far side toward the outside entry beyond
it, so such plates fell back to the safest route with head raises or to
no plan at all. The sweeps now still get their few expansions per hole,
bounded by the whole budget. On the production corpus six plates went
from a safest-route or head-raise fallback to a strict plan, five kept
fewer raises, one blocked plate gained a safest route, and none got
worse; the problem-nest corpus is unchanged in every outcome and share.
No synthetic plate reproduced the exhausted allowance yet, so the corpus
guards this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 14:31:45 -05:00
ajandClaude Fable 5.1 96db154d9e perf(cutting): remember each contour's pierce-zone resolution per preparation
With a pierce diameter set, every internal contour whose configured
pierce is inside the zone is resolved by flattening the cutout, finding
its pole and eroding it, and the search emits a part's whole chain again
on every expansion, so on parts with dozens of such holes almost all of
the planning time went there: three 1,600-motion parts took 41 minutes
and timed out in production. The resolution is a pure function of the
hole, the entry, the configured lead-in, the zone and the turn, so each
preparation now keeps it by contour ordinal and reuses it, unchanged,
across its emissions and copies. The same plate plans in 148 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 14:31:45 -05:00
ajandClaude Fable 5.1 c122e64edd fix(verify): resolve adjacent curves before any native query runs
A fillet whose circle is tangent inside the arc it continues, as a
keyhole's slot ends are, gives the supporting-circle query of the pair
no real root: the circles' distance is the radii's difference up to
rounding, so the intersection comes back NaN, and the unfiltered check
that runs before any adjacency handling refused the whole part as
"nonfinite program coordinates". On the production corpus 17 plates
failed this way. Adjacent curves are now resolved from their shared
vertex before the native query would run, so no query runs for them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 14:31:45 -05:00
ajandClaude Fable 5.1 60d235c8ce fix(verify): meet adjacent curves at their shared vertex exactly
Two curves that meet end to end share that vertex, but every contact
check asked the native intersection query about them, and it is
ill-conditioned there: two circles meeting at a shallow angle (a fillet
continuing into a larger arc) or a line meeting its fillet put their root
up to 1e-5 off the joint when the DXF arcs were rounded apart from their
neighbors by 1e-9. Material validation then refused the part as
self-touching, and the open-cut proof read a tabbed perimeter's fillet
joint as a loop, so with support tabs on every such part lost its
retention proof and its plate came back blocked. On a corpus of 1260
production plates this was the largest planner failure class: 61 of 67
NoSolutionWithinBudget plates, all planning once the joint is exact.

Contacts now report an adjacent pair's shared vertex itself and derive
the pair's other intersection from it analytically (two circles meet
again at the vertex's mirror image across the line through their
centers; a line meets a circle again at the far end of its chord),
keeping it only on both finite spans. No tolerance grows: a genuine
second crossing beside the joint is still reported, and overlapping
spans still refuse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 14:31:44 -05:00
ajandClaude Opus 5.5 81ec6a8125 feat(server): list, reopen and keep server nest jobs once
A server Auto Nest result could only be reviewed in the session that sent it,
and nothing stopped two desktops from keeping the same result. Phase 3 adds
My Server Jobs, so results that finished while OpenNest was closed are easy
to find, and records each review on the server.

- Server: GET /api/nest-jobs lists jobs newest first with state, engine,
  submitter, times, queue position, review and the worker's summary (parts,
  sheets, utilization, unplaced). It filters to active, finished or
  unreviewed jobs and to a submitter (a bare user name matches that user on
  any workstation). An ETag turns unchanged polls into 304s. Schema v17 adds
  nestId, review and summary columns, only where missing.
- Keep once: POST .../keep and .../discard take If-Match on the job revision
  and change an unreviewed result only once. A second desktop gets 409 with
  who kept it. DELETE removes finished jobs. A succeeded job keeps its
  package until it is reviewed, so a later session can rebuild the job.
- Retention: OPENNEST_JOB_RETENTION_DAYS (default 7) deletes finished jobs
  with their results. It runs on claims and list reads. A malformed value
  fails startup instead of deleting results early.
- Worker: reports the result's summary with the transcript. A job cancelled
  while its result arrived keeps no summary (found in the Docker smoke run).
- Desktop: Nest > My Server Jobs polls the list every 3 s; polling rather
  than per-job SSE because the list must show other desktops' jobs and
  removals. Open rebuilds the job from job.json against the open nest's own
  drawings. New per-drawing geometry fingerprints make the freshness check
  work across sessions. Cancel Job and Remove act on the selected job. A
  status-bar item counts this user's results waiting for review; there is
  no popup. Auto Nest's review now records Keep on the server before it
  commits, and records Discard too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:11 -05:00
ajandClaude Opus 5.5 b0cdbb3e81 feat(server): stream server nest progress and notify when a job ends
A server Auto Nest showed only "Nesting on the nest server" for however long
the job took, and Stop could take a whole 20-second heartbeat to reach the
worker. Now the desktop shows the same Best plan so far strip a local run
does, and a job that ends while OpenNest is in the background says so.

- Worker: RecordingNestingEngine taps each solve's progress. NestJobReporter
  stores only the latest stage and plan, at most once every two seconds, so a
  chatty engine cannot flood SQLite. Each round also reads the cancel flag,
  so Stop reaches the worker within one interval (about 2 s in the Docker
  smoke test).
- Server: schema v16 adds per-job revision, progress, plan and planRevision
  columns, which the upgrade adds only where missing. PUT .../progress and
  .../plan renew the lease like a heartbeat. GET .../events is Server-Sent
  Events, woken by an in-process change signal: plan and job events with
  revision ids, keep-alives, Last-Event-ID resume, and the final state always
  sent before the stream ends. GET .../plan serves the polling fallback.
  Progress goes over HTTP because the worker never opens the database
  (another host, NFS).
- Desktop: NestJobWatch follows the stream. When the stream drops it polls
  once and reconnects, and it polls throughout when the server has no stream.
  RemoteNestPlanPreview draws worker plans from the client's own request,
  including cutout-trial frame proxies. ServerNestNotice shows a tray balloon
  (a toast on Windows 10/11) when the job ends while OpenNest is not in
  front. A real toast would need an AUMID shortcut that Velopack does not
  promise. Clicking the balloon brings the review forward; nothing is kept
  without Keep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:10 -05:00
ajandClaude Opus 5.5 54ad7ee84d feat(server): nest Auto Nest jobs on the server's worker
Large nests tie up the workstation that runs them. In Database mode, Auto Nest
can now queue the job on the nest server, where a worker container solves one
job at a time under its own CPU and memory limits. The job has no time limit,
so a throttled worker finds the same nest a fast one does, only later.

- Server: /api/nest-jobs queue (schema v15). The worker holds a job on a
  heartbeat lease. When the lease lapses, the job goes back to the queue,
  or fails after three attempts. Only one job runs at a time. Packages and
  results are opaque to the HTTP server, which stays off Engine and IO.
- Worker: OpenNest.Server.Worker in the same image (OPENNEST_ROLE=worker),
  reaching the server over HTTP only. It records each engine answer with
  RecordingNestingEngine and gives its job back on shutdown.
- Desktop: "Nest on the server" in the Auto Nest dialog, for built-in
  engines only. The client replays the recorded answers with
  RemoteNestSubmission, so validation, the review and Keep run locally
  against the client's own drawings, exactly as after a local run. A
  drawing edited after sending refuses the result. If the server cannot
  take the job, the user may nest locally instead.
- compose.server.yaml adds the throttled worker service. The Dockerfile,
  .dockerignore, CI paths and the deploy script now include the worker's
  projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:09 -05:00
ajandClaude Opus 5.5 d29f919aa2 fix(io): save nests safely and reopen them with their cut-off settings
Reopening a nest regenerated its cut-offs with built-in defaults (no
overtravel, a different minimum segment) rather than the machine's
settings it was made with, so a nest saved, reopened and posted could
cut scrap that never separates. Plates now record the settings their
cut-offs were last generated with, and files carry them; older files
still load with the defaults.

Saving over a nest file truncated it before writing, so any failure
part way lost the only good copy, and the error reached the user as an
unhandled exception after the save path had already changed. Nest and
settings files are now written beside the target and swapped in, and a
failed save is reported with the old file left intact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:51:11 -05:00
ajandClaude Opus 5.5 e8c52a354d fix(posts): refuse wrong-process output and keep settings across updates
Ahead of the new laser schedule, three ways a post could silently cut
with the wrong process, and one way its settings silently reset:

- Cincinnati CL wrote no G89 when no cut library matched the nest's
  material, thickness and gas (or no etch library for etch features),
  so the controller cut with whatever library it loaded last. It now
  refuses, unless the new "Require libraries" setting is turned off.
- Cincinnati CL declared G20/G21 from its own setting without comparing
  the nest's units, so a metric nest posted 25.4x too large. It now
  refuses a mismatch.
- CI Fiber posted any material missing from its table as the default
  code (MSN), so stainless or aluminum ran with mild-steel technology.
  A named, unmapped material is now refused; the default code covers
  only nests with no material.
- Posts saved their settings beside their DLL, inside the install
  folder a Velopack update replaces, so every update reset them to the
  shipped defaults. Saves now go to %APPDATA%\OpenNest\Posts and the
  shipped JSON only seeds them; constructing a post no longer writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:51:11 -05:00
ajandClaude Opus 5.5 82d54aec87 fix(post-review): list part-support warnings in the post review
PartSupport findings counted toward the headline and the Post Anyway
checkbox but had no tile or list group, so an operator saw "1 warning"
with nothing to read and was likely to tick through a part that may tip
once cut free. A test now fails if any finding kind is left unlisted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:51:10 -05:00
ajandClaude Opus 5.5 b2cc796e4a fix(cutting): let a part no tab can steady leave from another entry
A support-tabbed part that no single tab can hold level was limited to its
one steadiest entry. Two strips stacked in a frame's cutout then blocked
the plate: from the upper strip's only entry, every route to the lower one
crossed holes already cut. Its other entries clear of inside corners now
follow the steadiest, in the search and in contour recovery; the part is
still tabbed and still warned about. Parts a tab can hold keep their
steadying entries.

The unverified headline also named fixed lead-ins when the only warning
was a part that may tip; it now says what needs review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:34:00 -05:00
ajandClaude Opus 5.5 e0bc97a719 fix(cutting): plan support-tabbed perimeters that have a lead-out
Every part the support grid could not hold level got a tabbed perimeter
with the outside lead-out after its tab, and planning refused any lead-out
after its own open cut, so with a lead-out configured no such part could
be planned. Pre-post review already proves such cuts release nothing (the
lead-out stays out of the tab gap); planning now runs the same proof, so
planning and review agree and a lead-out that reaches into the tab is
still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:33:57 -05:00
ajandClaude Opus 5.5 af9886c355 chore(ui): take Drawing Library Grid off the File menu
The grid was added to check the whole library for drawings that freeze
a preview; it is a testing tool, not an operator feature. The form and
its test stay for future library checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 08:45:50 -05:00
ajandClaude Opus 5.5 3d728b000e fix(bestfit): never hold a pair to a tighter aspect than one part
The best-fit filter rejected pairs over a 5:1 aspect (or the plate's)
unless 75% utilized. A long part such as U251-DET3 (27.4 x 3.5, aspect
7.7) is past that alone, so its tightest interlocked pairs (5.1:1) were
all dropped even though they are less elongated than the part itself.
On the 60 x 120 sheet that cost 12 parts: 66 placed instead of 78.

The limit now widens to the part's own bounding-box aspect, so
side-by-side pairs survive while end-to-end pairs of compact parts are
still rejected.

The learned-angle characterization nests re-captured: their 46 x 3 bar
now nests as an interlocked pair, which left the Fill nest identical to
a full sweep, so the notched quantity drops from 6 to 4 to keep the
fixture sensitive to learned angles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:58:31 -05:00
ajandClaude Opus 5.5 1a46d9896f perf(geometry): speed up labels and shape chaining on many-hole parts
The Drawing Library Grid check found seven drawings with 1,000-1,700
holes (90-6138-26/27, Reeves bakery vent pans, 4938 A15) that took
0.5-3.6 s to preview, all of it in the part label point.

- PolyLabel skips a hole's containment test outside its bounds and its
  edge distances when its bounds are farther than the nearest edge so
  far. Both skips leave the result exactly as before; label points on
  all seven drawings are unchanged and the search is 4-35x faster.
- ShapeBuilder.GetShapes rescanned every remaining entity at each
  chaining step. An endpoint grid now finds the candidates, tested in
  list order with the same checks, so chosen entities, reversals and
  shapes are identical (compared against the old loop on the library
  drawings and 300 random entity soups). 90-6138: 700 ms -> 40 ms.
- The label builds its shapes once: ShapeProfile.FromShapes reuses the
  ones it already chained instead of chaining them again.

Previews of all seven now take 57-166 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:50:06 -05:00
ajandClaude Opus 5.5 b703abe8dc feat(library): add a Drawing Library Grid that checks every drawing
File > Drawing Library Grid shows a page of library drawings side by
side. Check All Pages walks every page, downloading each drawing, timing
its label search without the paint's cell budget on a worker with a
10 s limit, then timing its paint, and lists drawings that fail, run
slow or would freeze a preview. It was added to confirm no other library
drawing hangs on load the way the twin-outline drawing did: a run over
all 40,757 drawings on the work server found no hangs or failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:50:06 -05:00
ajandClaude Opus 5.5 f9b718e890 fix(preview): stop part labels hanging on twin-outline drawings
A drawing of two equal, separate outlines (RC-204441A23 PT13) froze the
drawing library preview. ShapeProfile takes one outline as the perimeter
and the other as a cutout, so the net area is rounding noise and
ThicknessPrecision fell to 1e-9. A rectangle's pole is a whole ridge, so
the label search refined it into billions of cells on the UI thread.

Net area negligible next to the outline now falls back to the outline's
own width, and the paint-time label search has a cell budget so no shape
can stall painting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:50:06 -05:00
ajandClaude Opus 5.5 d4e622271a feat(ui): show each drawing's count on the current plate
The drawing list only showed nest-wide totals, so seeing how many of a
drawing a layout holds meant counting parts by eye. Prefix the nested
line with the current plate's count, e.g. "[78] 144 of 1 nested".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:28:44 -05:00
ajandClaude Opus 5.5 f597b06024 fix(console): autonest each drawing's required quantity by default
--autonest without --quantity nested one copy of each drawing, so a
118-part job of 67 drawings solved as 67 parts and reported success.
Default to the required quantity less copies on other plates, which the
run keeps; drawings without one are left out, as on the desktop, unless
no drawing has one. --quantity still overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:43:04 -05:00
ajandClaude Opus 5.5 3ca23e9474 test(cutouts): allow float rounding at the exact wall clearance
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:26 -05:00
ajandClaude Opus 5.5 b1a03c1796 feat(engine): solve rectangular cutouts as a knapsack of boxes
Search insert selections as boxes with a counted beam search over maximal
free rectangles, pair low-density parts with their half turn, then gap-fill
with the frontier packer. The router takes it on seated outline area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:26 -05:00
ajandClaude Opus 5.5 62e5410cdb fix(cutouts): maximize material seated in cutouts, not copies
Compare cutout proposals, frame angles and bundles by insert outline area,
and add bounded largest-first packer candidates so big parts get the first
move in an empty hole. On the real job the four frames go from 54-70% to
77-81% filled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:26 -05:00
ajandClaude Opus 5.5 12819198f9 feat(fill): search every spiral combination in rectangle best fit
The spiral used only the least-waste rows and columns. Those often make
one pair of blocks the larger on both axes, so it overlapped itself and
placed nothing, leaving Fill at two blocks. It now also tries every
four-block combination and takes one that holds more parts than both the
least-waste spiral and the two-block layout, so ties keep today's layout.

A 9 x 6.5 part on a 60 x 120 sheet goes from 109 to 112.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:25 -05:00
ajandClaude Opus 5.5 763e2a1d4a fix(ui): scale the fill time in the status bar
Long fills read as thousands of milliseconds. Keep milliseconds under
a second, then show seconds and minutes so the time stays readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:25 -05:00
ajandClaude Opus 5.5 0a0da50006 feat(ui): show the layout check in the status bar
An empty canvas corner gave no sign of whether a check was still running,
which matters on large nests. A Layout section beside utilization now
follows the automatic check: checking, OK, or findings in red, with the
full wording in its tooltip. It has its own label so rechecks never
replace the last command's status text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:25 -05:00
ajandClaude Opus 5.5 ac7ee41801 feat(ui): leave the overlap corner empty unless something is wrong
Drop the check mark: a clear result, and an automatic recheck after one,
now draw nothing. Findings and failed, canceled or incomplete checks keep
their text, and views without automatic rechecks keep their pending text.
The status bar still reports a manual check's result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:25 -05:00
ajandClaude Opus 5.5 fd37cbffef feat(ui): keep the overlap corner quiet during rechecks
A pending or running recheck no longer swaps the green check for status
text. A clear result stays as a gray check, and earlier findings stay as
dimmed text, neither of which claims anything about the edited layout.
Views without automatic rechecks still ask for a manual check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:25 -05:00
ajandClaude Opus 5.5 c0cd7c6aff feat(ui): validate part spacing in the plate view overlay
The overlap check now runs the pre-post part spacing scan on its snapshot,
so the canvas label and posting agree. Too-close pairs are counted in the
label and marked with an orange ring showing the gap, with hover details.
A fully checked plate with no findings draws a green check instead of
text; the status bar keeps the wording. Changing the plate's part spacing
now stales the report, and repeated parts share one offset outline so
automatic rechecks stay fast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:42:25 -05:00
ajandClaude Opus 5.5 c8883bbd1e fix(ci): follow the Lattice test rename in Windows acceptance
The Fill engine rename changed McpFillToolsWithoutAnEngineUseFill to
...UseLattice, so the acceptance check failed on a missing test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:24:44 -05:00
ajandClaude Opus 5.5 44372d129e feat(ui): rename the Fill engine to Lattice and describe engines
"Fill" said nothing about what the engine does, since every engine fills.
It is now Lattice, and Fill stays an alias, so saved selections, scripts
and API requests keep working. Engine descriptions are rewritten in plain
language and shown beside each name in the desktop engine lists; the main
toolbar's tooltip also says when Fill Area falls back to Lattice.

Docs also cover the leftover placement from the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:17:16 -05:00
ajandClaude Opus 5.5 4b2aba83bb feat(fill): keep interlocked pairs whole and place leftovers as pairs
A quantity-limited fill trimmed the parts farthest along the trim axis,
which could take one half from each of two pairs. TrimToCount now finds
interlocked pairs and keeps or drops them whole (from d1d68a91), so the
spare space stays one usable cell.

After a fill, leftovers now run in two orders, pack-first and pairs-first,
and the strategy's comparer keeps the better whole layout, so its ranking
of this step cannot get worse. Even quantities go in as best-fit pairs,
may turn a quarter when unrestricted, are tried in every remnant and are
ranked with the layout around them. Each placed pair slides left and down
against true outlines to take the space its bounding box could not.

On compare.nest Vertical Remnant goes from 6665.1 to 5862.6 and Lattice
from 7200 to 5915.8. Nine characterization pins change from the trim with
counts unchanged; the fill pins now also check layout validity. Vertical
Remnant's golden layout shortens from 28 to 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:17:15 -05:00
ajandClaude Opus 5.5 be4886f876 fix(ui): remove the F1 hotkey for help
F1 opening help kept taking over from placing a part. Help stays on
the Help menu and F1 belongs to the Clone tool alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:25:56 -05:00
ajandClaude Opus 5.5 e7499f14c2 fix(ui): return to Select after one Zoom To Area
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:17:49 -05:00
ajandClaude Opus 5.5 10a4128187 fix(engine): keep full-sheet fills on clamped jobs
Whole-job fills treated clamps as occupied space, so a clamped edge cost
a row: 8.25in circles stopped at 96 on a sheet the plate fill packs with
102. When one drawing still has unmet demand, also fill the sheet as if
the clamps were absent, drop the parts on them and keep the better
layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:13:37 -05:00
aj 4e92d0e8f3 fix(planning): route a cut-off from its start to its far end
A cut-off always runs away from its fixed edge: the origin side, the only
edge guaranteed to be a real sheet edge. The part order is proposed as a
traveling-salesman path over one point per placement, and a cut-off's
point was the middle of its line. The route did not know the tool must
arrive at the line's start and leaves at its far end, so the parts
before a sheet-splitting line finished level with its middle, and the
rapid down to its start crossed parts already cut.

Each placement now has an entry and an exit point; they differ only for
cut-offs (start and departure). Nearest neighbor and Or-opt go on from
each part's exit, and 2-opt costs a reversed run along its own directed
legs; with no cut-off on the plate the exits are not passed and the
symmetric route is unchanged. The part cut before a cut-off faces its
start.

Cut-off direction is not chosen here: the order reads each cut-off's start
and end from its program as given, and the joint search may still reverse a
head-locked far-edge segment.
2026-11-01 16:51:09 -05:00
aj 277719a1d6 fix(planning): re-plan the last kept part when a learned rule changes what follows it
When a blocked approach teaches an order rule, the search backs up to the
earliest part that approach crossed, keeps the parts before it and
re-plans the rest. The last kept part was planned facing the part that
followed it in the old order. If the new order puts another part after
it, its entry and exit still face the old one, so the plan ends with a
stale exit that a kept-order re-plan of the same order would fix.

Now that part is planned again, in the same place, facing its new
successor; when its successor is unchanged nothing is re-planned.

The locked-among-regenerated fixture's plan changes: rapid 67.60 ->
60.22, expansions 528 -> 268 (digest updated). Learning "cut the
cutoff first" now takes 40 expansions rather than 22, because B is
planned again to face the cutoff; retrying every entry of B took 60.
2026-11-01 16:51:09 -05:00
ajandClaude Opus 5.5 d57678ecee Disable toolbar buttons that cannot be used
Plate commands need an open nest and no running job; Trim/Extend Cut-Off
needs a cut-off on the plate and Sequence by Path needs two parts. The
Plate menu's Trim/Extend item follows the same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:33:36 -05:00
aj a89adc7911 fix(engine): stop waiting for the block fill's pair search on cancel
Irregular's block catalog fills a private plate per part type, and that
fill searched best-fit pairs through the overload that ignores the
token. On one CPU the search can take minutes, so a time limit waited
for it: the converted Nestbolt m-public-gear-1 job under a 60 s limit
ran past 180 s. PrivatePlateFill now passes its token, as PairCatalog
does since 9431006e; the search still finishes in the background and
fills the shared cache.

gear-1, Irregular, one CPU, 60 s limit: killed after 180 s -> stops at
60.0 s (no layout yet; its no-fit polygons are the next bottleneck).
2026-11-01 16:28:42 -05:00
ajandClaude Opus 5.5 8a5f16069d fix(engine): check materialized plates against the stock's clamp rail
CreatePlate copied a rail-backed stock's zones but not its rail, so the
materialized plate fell back to the bottom edge spacing as its clamp
clearance while every engine packed to the rail's own. With a rail
clearance below the edge spacing, valid layouts from all engines were
rejected as clamp violations. Carry the rail and any removed clamps
across, sharing the rule the commit path already used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:20:34 -05:00
aj b28df6efa0 Lighten the zoom icons and clarify Fit and Area
A visual review of the toolbar set found the magnifiers heavier than the
cut icons' outlines, Zoom To Fit's small square ambiguous and Zoom To
Area crowded at 24 px. Thin the ring and handle, let Fit's sheet fill the
glass edge to edge, and give Area longer dashes and a smaller corner lens.
2026-11-01 15:12:02 -05:00
aj 5e2606f679 Add a Zoom To Area button to the plate header
The header gets a fourth zoom button that starts the existing Zoom Window
action, with a matching dashed-window icon also shown on View > Zoom To >
Area. It stays out of the inspection's live zoom controls: inspection owns
Escape, so a zoom window could not be cancelled there.
2026-11-01 14:07:48 -05:00
aj eec6ed0125 Match zoom icons to the cutting toolbar
Redraw Zoom In, Zoom Out and Zoom To Fit in the cut planner and cut-off
palette: slate magnifier, pale glass, the card's 1px shadow, orange +/-
and a part-blue sheet for Fit.
2026-11-01 14:07:48 -05:00
ajandClaude Opus 5.5 9dd70ef779 fix(plateview): delete selected parts during clone
Clone's Delete only removed its source parts, so parts picked with
Select All stayed on the plate until Escape ended the action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:20:16 -05:00
ajandClaude Opus 5.5 8cc8d04b64 feat(engine): stagger round parts in plate fills
FillEndOdd and FillEndEven had no production callers, so round parts
only got stripe layouts. A Circle strategy now runs them in the fill
pipeline: 8in circles on a 60x120 go from 98 to 112.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:44:00 -05:00
ajandClaude Opus 5.5 ec8fd6f8ed fix(packing): correct circle fill axes and centers
FillEndOdd and FillEndEven read Box.Width as the X span, though it is
the Y span, so non-square sheets got the wrong pitch. They also started
centers at the bin corner, leaving circles a radius outside it.

FillEndEven now tries both stripe directions, as FillEndOdd does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:44:00 -05:00
ajandClaude Opus 5.5 45f5ce6954 fix(plateview): keep nudged clones through dialogs
Windows reposts a mouse move when a dialog opens or closes, so Set
Offset Increment threw away arrow-key nudges by snapping the previews
back to the cursor. Only a real cursor move now moves them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:42:33 -05:00
ajandClaude Opus 5.5 bbdd51fabc feat(cad-converter): show the source file's raw geometry as Original
Show Original drew nothing until Simplify ran, and then showed entities that
had already been through import cleanup. The importers now capture the
entities before any optimization and the overlay draws those from load, while
Simplify keeps starting from the cleaned entities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:07:06 -05:00
ajandClaude Opus 5.5 8076ddbd78 feat(io): join arcs covering a full circle into a circle on import
CAM exports often draw a hole as two arcs that overshoot the seam, plus a
short chord back to the start. The Hole Table rejected these contours for
sweeping more than 360 degrees, so drawings like the HT EX flange listed no
round holes.

CadImporter now replaces coradial arcs on one material layer that cover the
whole circle with a Circle, and drops a chord lying wholly on the overlap.
Arcs that leave a gap or overlap by more than MaxSeamOverlap stay as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:07:06 -05:00
ajandClaude Opus 5.5 1d310b373e test(cutting): share the lead-in tests' point and profile helpers
Three lead-in test classes kept identical AssertPoint and Profile
helpers. They now import LeadInTestHelpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:27:45 -05:00
ajandClaude Opus 5.5 c027793077 test(desktop): share form pumping and reflection helpers
Desktop test classes each kept identical PumpUntil, Show, Mouse, All,
PressKey, Raise, SetBackingField and Field helpers. They now import
FormTesting and TestReflection; the two Field variants that read a base
type's field or unwrap DrawingLibraryForm keep a one-line wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:24:46 -05:00
ajandClaude Opus 5.5 658b3552c1 test(frontend): share the in-process console runner
Three front-end test classes ran NestConsole.Run through their own
reflection helper. They now import ConsoleHost.RunConsole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:23:01 -05:00
ajandClaude Opus 5.5 9d6d5fa4b2 test(io): write test archives through NestArchiveJson
Nine persistence test classes turned a nest or a single drawing into
archive bytes with their own Write helpers, and one kept a private copy
of NestJson. They now use NestArchiveJson.ArchiveBytes and NestJson.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:23:01 -05:00
ajandClaude Opus 5.5 a72e9eb900 test(server): share the raw SQLite helpers
Nine server test classes opened the test database with their own
identical Execute and Scalar helpers. They now import TestDatabase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:23:01 -05:00
ajandClaude Opus 5.5 e5d9e12df9 test(desktop): share the square and rectangle part programs
Ten desktop test classes drew the same squares and the same 4 x 2
rectangle in private helpers, in two equivalent styles. They now use
TestPrograms.Square, ClockwiseSquare and Rectangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:53:24 -05:00
ajandClaude Opus 5.5 06ec8b1c43 test(io): share the square drawing fixture
NestSnapshotTests and PartCuttingParametersPersistenceTests built the
same 4 x 4 square drawing. Both now use TestDrawings.Square.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:53:24 -05:00
ajandClaude Opus 5.5 f0d84e041c test: share the circle, square, rectangle and contour fixtures
Cutting-planning, diagnostics and fill tests drew the same circles,
squares, clockwise rectangles, closed polygons, rounded rectangles and
right-midpoint rectangle contours in private helpers. They now use
TestHelpers; files whose helper had its own default size keep a
one-line wrapper for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:50:50 -05:00
ajandClaude Opus 5.5 a5e219008e test(engine): share the rectangle, L-shape and arc-part fixtures
Engine tests drew the same rectangle, square, L-shape and arc part in
private helpers across the Expander, golden-layout, fill-stack and
fixed-strategy tests. They now use TestDrawingFactory; files whose
helper had its own default size keep a one-line wrapper for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:44:36 -05:00
ajandClaude Opus 5.5 e271580f62 refactor(desktop): sort paged server lists from the header in BrowsePager
The drawing library, drawing sets, saved nests, add-release and edit-set
grids each repeated the left-click header check that sorts on the
server. BrowsePager.SortByHeaderAsync now does it for the grid and
session it already drives. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:21:27 -05:00
ajandClaude Opus 5.5 7bff9baa23 refactor(desktop): share code-built tab pages and settings grids
MachineConfigForm and SavedNestsForm built their tab pages, and
CuttingPanel and LeadsEditor their label / input / hint grids, with
identical private helpers. Both now use ControlFactory. No behavior
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:21:27 -05:00
ajandClaude Opus 5.5 bdd6625713 test: share the rectangle drawing fixture
Thirteen test classes built the same origin-anchored rectangle drawing in
their own private helper (MakeRectDrawing, MakeRect or Rect). They now
call TestHelpers.MakeRectDrawing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:21:27 -05:00
ajandClaude Opus 5.5 24169a5f8f refactor(desktop): share the separator line controls' pens and painting
HorizontalLine and VerticalLine had identical constructors, Dispose and
resize handling. They now derive from SeparatorLine and only say which
way the line runs. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:26:53 -05:00
ajandClaude Opus 5.5 e231e44f72 refactor(geometry): share box containment, union, rotation and cutout range checks
Private copies of tolerant box containment, box union, vector rotation and
the cutout proposals' coordinate-range check now use one helper each:
BoundingBox.IsWithin and BoundingBox.Union, Vector.Rotate, and
CutoutCoordinates.WithinRange. Tests pin the helpers bit-for-bit against
frozen copies of the removed bodies. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:25:27 -05:00
ajandClaude Opus 5.5 082a50bbcd refactor: remove duplicated client, geometry and desktop code
Share the nest server client setup and page validation, entity endpoints,
post marker checks and the repeated desktop list, cut-time and busy-warning
code. Persistence tests use NestArchiveJson. No behavior change intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:13:26 -04:00
ajandClaude Opus 5.5 31525ba342 feat(cad): list keyed holes in the hole table
A closed contour around its center with at least 270 degrees of arc on
one center and radius now counts as a keyed hole, in its own row after
round holes of that diameter. Curved slots stay out: their long arcs wrap
around solid material, so the center is outside the contour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:44:59 -04:00
ajandClaude Opus 5.5 970d2442d5 fix(expand): prune clearance checks and let wide gaps close to the target
Expand Spacing measured every ring pair of every part pair for each trial
move, so a packed layout of parts with holes took minutes per spacing
probe. Skip ring pairs, far part pairs and off-path parts whose bounds
already settle the answer.

Every pair was also held at its full starting gap, so parts could not
move toward anything and most layouts stayed at their entry spacing.
Part pairs may now close to the spacing being tried, never below a
smaller starting gap. Cut-offs and clamps keep their full entry gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:59:33 -04:00
ajandClaude Opus 5.5 01e2238258 feat(library): fill a blank nest from its first library drawing
A nest with no material, grade or thickness now takes them from the
library record instead of warning that the drawing differs and dropping
its cut pins. The machine's spacing and cut-off settings follow. Later
drawings still get the usual difference warning, and drawings from files
carry no thickness, so they leave the nest blank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:45:39 -04:00
ajandClaude Opus 5.5 59d4c8d727 feat(machines): set spacing per thickness and save defaults from a nest
Plate > Set as Default and Nest > Edit's Defaults tab only changed the
open nest, so new nests never picked up the values. Set as Default now
also saves the new-nest defaults, and the Defaults tab gains an opt-in
"Use these defaults for new nests" check.

Machine thicknesses can now set part and edge spacing. A nest takes them
when its material, grade, thickness or machine changes and when a release
creates it; BOM import seeds its groups from them. Spacing edited in the
same dialog stays, and only an untouched trailing sheet follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:39:48 -04:00
aj 817c4482eb test(engine): scale the Default time-limit test's limit from the unlimited solve
DefaultUnderATimeLimitReturnsAValidPlanNoWorseThanUnlimited failed on CI
(runs 41219924107 and 41224809174, Engine.Tests job): the fixed 5 s limit
assumed the unlimited Default solve takes well under a second, but it takes
about 4 s locally and longer on a loaded runner. A limit shorter than the
normal Irregular search correctly returns an earlier, worse plan, so the
assertion only holds when the limit covers that search. Use the larger of
5 s and three times the measured unlimited solve.

Red: the old test fails on one CPU shared with three busy loops; the new
one passes there. Full Engine.Tests: 2780 passed, 2 skipped.
2026-10-31 17:31:46 -04:00
aj 561f5be06c feat(engine): choose which edge strips count as offcut
Equal-cost plans break ties by the largest edge offcut, from any of the
four edges. A caller that wants the leftover off one way, such as a
shop that keeps only end drops, or a benchmark scoring the used sheet
height, had no way to say so. On the converted Nestbolt jobs the
extended Irregular search then found plans whose larger offcut is a
strip down the side, which Nestbolt scores as worse: b-blaz1-strip
3.601 -> 3.867, b-shirts-strip 2.674 -> 2.995.

NestJobOptions.OffcutCut (a CutOffAxis, default null) counts only the
strips one cut along that axis frees, for salvage credit and the
compactness tie-break alike: Horizontal the full-width bottom and top
strips, Vertical the full-length sides. PlateRemnant.LargestOffcut takes
the axis; NestJobCost, the time limit and clamp-removal recovery pass
it on. Unset, nothing changes.

Benchmark manifests accept "offcutCut": "horizontal" or "vertical".
With horizontal, one CPU, 20 s limit, Default:
  b-blaz1-strip  3.934 -> 3.333
  b-shirts-strip 2.995 -> 2.610
  m-box-deep     1.918 -> 1.276 (Nestbolt 1.251)

Manifests accept only the words horizontal or vertical (any case); a
list such as "horizontal,vertical" is rejected. PlateRemnant rejects an
undefined axis. Fixed strategies keep their own no-salvage tie-break
and ignore the option; desktop, Console and MCP do not offer it yet.

Mutation reds: 14/14 (offcut-mutations.py).
2026-10-31 13:29:50 -04:00
aj 63bcd89d35 feat(engine): Irregular searches until a time limit
Under a time limit, Irregular often finished in about a second and left
the rest unused: on the converted Nestbolt b-jakobs1 job it used five
sheets where four fit.

NestJobOptions.SearchUntilCancelled asks an engine to keep searching
after its normal search until the token is cancelled.
DeadlineNestingEngine sets it. Irregular then re-runs its six variants
with seeded per-type weights on its placement rule (seeds 1, 2, 3, ...,
each weight within e^+-0.4), reports each plan that beats its best,
then re-plans that plan's tail and reports any gain. It skips this when
the token cannot be cancelled, nobody listens for progress, nothing is
placeable, or the job has one part type, so unlimited solves are
unchanged.

Default now runs Irregular last (Rectangles, Circles, Irregular) so
Circles still runs, and chooses only after every candidate has run, in
tie-break order, so the run order never changes the choice. Unlimited
choices match the old run order {Rectangles, Irregular, Circles} except
when three candidates are near-tied within the 1e-9 comparison
tolerance without being transitively ordered (only on all-round jobs,
where Circles bids).

b-jakobs1 on one CPU, 10 s limit: 5 sheets (4.500) -> 4 (3.751).
2026-10-31 12:29:19 -04:00
aj 8df4c73093 fix(benchmark): make manifest quarter turns exact
"rotations": [0, 90, 180, 270] mapped to automatic rotation, which
allows any angle: Rectangles placed parts at minimum-area angles such
as 279.8 degrees in jobs that allow only quarter turns. Quarter turns
are now a 90 degree sweep from 0 to 270. Without "rotations", a part
still rotates automatically (any angle), as before 2dab9232; the doc and
comment that called that the quarter-turn default are corrected.
2026-10-31 10:54:52 -04:00
aj 7dc8cbb22d fix(engine): fire time limits from a dedicated thread
DeadlineNestingEngine used CancelAfter, a thread-pool timer. A CPU-bound
Parallel.For keeps pool threads on their own local work, so the timer
callback can run seconds late; pinned to one CPU it waited for the
whole best-fit search (m-sign-3 under a 10 s limit returned at 64.5 s).
A dedicated background thread now waits for the limit, or for the
solve to finish, and cancels; the solve joins it before returning.

A test runs a CPU-bound Parallel.For from a pool thread under a 200 ms
limit and requires the solve to end within 3 s; with CancelAfter it
took about 7 s.
2026-10-31 10:38:55 -04:00
aj 9431006edd fix(irregular): stop waiting for the best-fit search on cancellation
PairCatalog waited for BestFitCache's search without the solve's token,
so a cancelled or time-limited Irregular solve kept running until the
search finished: minutes on one CPU for the many-segment gear, sign and
garment parts of the Nestbolt benchmark (m-public-gear-1 ran 312 s under
a 60 s limit, m-garment-teagan-1 45 min). It now uses the cancellable
overload, which stops the wait and lets the search finish in the
background and fill the cache. The drawing is private to the call or to
the caller, as that overload requires.

A test blocks the pair evaluator and checks Build throws within 5 s of
a 200 ms cancellation; with the old call it waited the full 30 s.
2026-10-31 10:38:55 -04:00
aj 2dab923259 feat(benchmark): accept rotation sets in manifests
A manifest part may list "rotations" in degrees: [0] locks it, [0, 180]
locks it at 0 with the half turn allowed (Allow180Equivalent), and
[0, 90, 180, 270] is the automatic quarter-turn default. Any other set,
or "rotations" together with "allowRotation": false, is an error naming
the part. Without it, "allowRotation" behaves as before.

Lets converted external benchmark jobs (Nestbolt's 0/180 garment and
box jobs) keep their own rotation rules instead of being widened to
quarter turns.
2026-10-31 08:52:17 -04:00
aj dd473b6d38 feat(benchmark): add --time-limit
Wraps each solve in DeadlineNestingEngine so a solve stopped at the
limit scores the best valid plan its engine reported; with none it is
reported as timed out. The harness's own five-minute abandon timeout
grows to the limit plus a minute so the engine's limit always fires
first. Values outside 0.001 s to the timer's range, or a missing value,
fail with exit 1.

On the converted Nestbolt m-sign-1 job, Default with --time-limit 60
returns 96/96 parts on one valid sheet in 60.6 s wall, where an
unlimited run took over 300 s.
2026-10-31 08:49:27 -04:00
aj 23d5362aa1 feat(engine): return the best valid plan at a time limit
DeadlineNestingEngine wraps any engine with a wall-clock limit (any positive time up to
the cancellation timer's ~49.7 days). An engine that finishes in time
keeps its result, as does a result it returns after the limit. When the
limit cancels it, the best whole-job plan it reported (PlanImproved,
ranked like Default ranks candidates, a tie going to the later plan) is
returned if it passes the layout check; with none the solve fails with
TimeoutException. The caller's own cancellation is never turned into a
result. Engine plate commits are dropped and the returned result's
commits reported once it is known; plans are forwarded only when they
beat every plan shown so far.

Default now runs Rectangles, then Irregular, then Circles, so a search
stopped early already holds a valid plan. Ties still break by candidate
order (Irregular, Rectangles, Circles), so for these three the choice is
unchanged, and when every candidate throws, the first in that order is
still the one rethrown. A test seam takes a run order. Desktop, Console
and MCP do not apply a limit yet; Console can solve a job twice (cutout
trial and plain), which needs one shared deadline.

Mutations killed (10/10): no fallback at the limit, invalid plan
accepted, caller cancellation becomes a result, engine commits
forwarded, a worse later plan replaces the best, a tie keeps the earlier
plan, no upper limit bound, failure rethrown in run order, built-in run
order unchanged, run order ignored.
2026-10-31 08:49:27 -04:00
aj 8fb95e57b5 docs(geometry): correct slide band index comments
Reach is 2*sqrt(eps)*(1 + separation), the curve bound is an upper bound
on what the tests accept, and a curve extent is its circle +-|r| with the
slack carried by the reach. Comment-only; from the 55910d1f review.
2026-10-31 08:27:52 -04:00
aj 55910d1f9d perf(geometry): bucket slide entities by axis band
The band test still visited every entity pair. For boundaries of 24 or
more entities, EntitySlideEvents now builds bucket indexes over each
entity's perpendicular extent per enumeration (both sides for vertex
rays, the line-only side for arc-to-line, the stationary curves for
tangency) and visits only the bucket members, ascending, before applying
the same exact band tests. Range queries merge the ascending bucket
lists without repeats. Each extent is the band test's own acceptance
bound widened by a reach of 2*sqrt(QuadraticSlack)*(1 + largest
along-axis separation) for the quadratic tests plus a rounding term;
bucket numbers are monotone in the coordinate, so a query returns a
superset of the accepted entities in their original order and the
emitted events are unchanged. Geometry with any coordinate that is
nonfinite or beyond the band's coordinate limit is not indexed.

SlideBandTests adds large random boundaries (24-160 entities) to the
differential comparison against the frozen pre-filter enumeration,
reruns the band-edge sweeps with decoy entities so they go through the
index (including a far circle reached through the moving offset), and
checks index queries directly. Mutations killed: zero reach, line extent
without span slack, repeats kept by the merge or the sorted path, last
bucket skipped, points just above the minimum treated as empty, curves
in the line index, arc and tangency queries ignoring the radius, moving
index or measurement without the moving offset. Not pinned by a test:
the reach's rounding term (headroom).

Measured on converted Nestbolt jobs (Default engine, hermes, quiet
machine, one run each), before -> after: m-box-tool 47.1 -> 19.2 s,
m-mechanical-mounting-kit 45.9 -> 27.3 s, m-mechanical-cog-washers
74.7 -> 44.2 s, m-mechanical-small-brackets 23.0 -> 16.6 s. The nine
completed jobs keep identical placements.
2026-10-31 08:16:03 -04:00
aj a28b543634 perf(geometry): skip slide pairs outside the axis band
An axis-aligned slide can only meet an entity whose extent across the push
direction holds the ray. EntitySlideEvents.Enumerate now tests that band
before each vertex ray, arc-to-line pair and curve-tangency pair, and skips
only pairs whose kernels cannot emit. Margins cover RayLine's segment
tolerance (twice Eps of the span) and the rounding of SolveRayCircle's
discriminant. A pair with any coordinate or radius that is NaN, infinite or
beyond 1e150 is always kept, because the kernels' products can then turn
NaN and still emit. Surviving pairs run in their original order, so the
emitted events, and every slide distance, are unchanged.

SlideBandTests compares the full event sequence and FirstBlocking result
bit for bit against a frozen copy of the pre-change enumeration: random
line/arc/circle boundaries at four coordinate scales with offsets landing
on or beside the tolerance band, an arc swept across a nearly parallel
line's band edge, and nonfinite or overflowing coordinates along the push.
Mutations killed: zero span slack, zero quadratic slack, circle-line test
ignoring the radius, with zero margin or a shrunk radius, tangency using
the difference of radii, no coordinate guard, no vertex guard, moving
offset ignored, axes swapped. Not pinned by a test: the span slack's
factor of two and the quadratic slack's absolute floor (rounding headroom).

Measured on converted Nestbolt benchmark jobs (Default engine, hermes,
one job at a time): m-box-tool 70.7 -> 40.7 s, m-mechanical-cog-washers
115.1 -> 70.0 s, m-mechanical-mounting-kit 72.7 -> 44.9 s. The nine jobs
that completed (five M jobs and four B jobs; m-sign-1 still exceeds
300 s) produced identical placements before and after.
2026-10-31 08:06:03 -04:00
aj ad5f2a9041 feat(cad-converter): add a Hole Table window that counts and colors holes by size 2026-10-31 05:50:22 -04:00
aj 45ab837c8c feat(cad-converter): drag a dimension by its value
Pressing a dimension's value text now grabs it like its line does. The
label keeps its offset under the pointer, so a radius or diameter value,
which sits past its leader's end, no longer jumps when grabbed. Hit boxes
are measured the way painting draws them, at the control's DPI.
2026-10-31 05:40:26 -04:00
aj a392cc0863 fix(cad-converter): skip the Dimensions fill during FilterPanel construction
AutoScroll runs a layout inside the constructor before dimensionsPanel
exists, so every CAD Converter form threw NullReferenceException.
2026-10-31 05:28:58 -04:00
aj e628345947 fix(cad-converter): stretch the Dimensions list to the bottom of the side panel 2026-10-31 05:23:22 -04:00
aj 08d646413a build(help): ship help v0.6.3 with dimensions, SVG, sheet outlines and cut-off gaps 2026-10-30 23:09:33 -04:00
ajandClaude Opus 5.5 ca693285bb feat(cad): add radius and diameter dimensions
Holes and radii need sizes on the drawing as well as the overall extents. Select
circles or arcs, right-click and choose Add Radius Dimension or Add Diameter
Dimension. The value is measured live from the curve, found by its entity ID, so
it follows edits; only the leader angle and label offset are saved. A radius on
an arc stays on its sweep, and labels are kept clear of the curve and center.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:47:56 -04:00
ajandClaude Opus 5.5 05d5c56ac0 fix(cad): run extension lines to the offset part profile
Extension lines started at the extents' corners, which float in empty space on
angled or irregular parts. They now run from the dimension line toward the part
until they first meet the profile offset by a fixed screen gap, so they reach
the feature they measure without touching it at any angle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:34:31 -04:00
ajandClaude Opus 5.5 abced0a7ec feat(cad): add overall dimensions to the CAD Converter
Shops need a part's overall size on the drawing while converting it. Width and
height are measured live from the geometry that will be cut, so they follow
filters, exclusions, simplifying, breakoffs and tabbed cutouts instead of going
stale. Only the placement is saved; dimensions never touch the program.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:34:31 -04:00
ajandClaude Opus 5.5 e947c3ad86 feat(cutoffs): keep meeting lines on a dragged cut-off
Limits are plain coordinates, so moving a cut-off left the lines trimmed
to it stopping where it used to be. A drag now takes along perpendicular
lines with a limit or gap edge on its center, extending or trimming them
to keep meeting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:09:01 -04:00
ajandClaude Opus 5.5 3a8994c8fe feat(io): import and export SVG drawings
Customers and design tools hand over SVGs, and getting them in meant a
trip through another CAD tool. CadImporter now reads them like any other
drawing, and plates, programs and converter entities can be written out.

An SVG's numbers are not drawing units, so import converts to the nest's
units and never guesses: only an absolute width or height counts as a
stated size, since px means 1/96 in to browsers but 1/72 in to
Illustrator. Without one the desktop asks, showing the size each choice
gives, and headless callers must pass the scale or the import fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:50:41 -04:00
ajandClaude Opus 5.5 4e3be68c11 chore: remove stray Python bytecode and ignore it
A bytecode cache from running the script tests was committed by accident
in the spelling cleanup; nothing ignored it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:12:55 -04:00
ajandClaude Opus 5.5 4d55e11318 style: use one spelling across the codebase
The repo mixed variants such as center/centre and color/colour, so a
name could be spelled either way and searches missed half the hits.
Standardize on the US spelling, matching .NET's own APIs (Color, Center).

Wire values owned by other systems keep their spelling: the material
ledger's "Cancelled" hold status and GitHub's "cancelled" job status.

No behavior change beyond the spelling of a few status messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:11:30 -04:00
ajandClaude Opus 5.5 2a128c5a11 fix(planning): stop cutout leads at the feature centre line
Small features got leads that ignored their shape: full 0.15 leads at a
shallow angle along letter strokes, stubs at the pierce zone's edge in
round holes that could be pierced in the middle, and a lead aimed at a
letter's far pole that ran longer than configured.

Plan Cutting now limits a lead to the feature's depth at its entry, where
a second wall becomes as near. The pierce-zone fallback goes there first
and otherwise aims beside the entry rather than at the whole cutout's
pole. Pinned contours keep their leads, and wide cutouts are unchanged.

Centred pierces exposed the line/circle tangent test: its absolute
discriminant tolerance called a radial line through a hole under about
0.056 radius tangent, reporting a false contact at the centre. Tangent
now also requires the line to stay at the rim, which keeps true grazes
as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:31:27 -04:00
ajandClaude Opus 5.5 667beb4334 fix(cutting): start planning from the user's settings
Edits in the Cutting Parameters dialog only reached user settings, while a
plate's stored parameters took priority, so a change was lost on any
planned plate unless Apply succeeded. User settings now win; a plate's own
are the fallback when none are saved or readable.

WinForms tests saved to the real user-settings.json, and with user settings
winning a test's lead-ins would feed real planning, so they now use a temp
file and skip the legacy settings carry-over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:06:11 -04:00
ajandClaude Opus 5.5 bb6bdc8213 test: run dotnet test on a hidden desktop
WinForms tests open real windows that take focus from the user's work.
Test-Hidden.ps1 runs them on a separate Win32 desktop in the same window
station, so forms keep real handles and message loops while nothing appears.

A job object ends the whole tree on any exit, and a timeout stops runs stuck
on a modal dialog nobody can dismiss there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:16:47 -04:00
ajandClaude Opus 5.5 b550640ef0 refactor: merge duplicated geometry, settings-file and form helpers
Roslyn Bridge flagged these as copy-paste clusters; each pair now shares
one implementation so a fix lands everywhere at once:

- Shape.OffsetInward/OffsetOutward differed only in the target winding.
- BestFitFinder kept its own copy of SpatialQuery's arc-extreme points.
- SplitDrawingForm rebuilt DrawingSplitter's clip-region grid for preview.
- GeometryOptimizer's collinear/coradial finders (one misspelled
  GetCoradialArs) become one generic FindRelated.
- Five settings files repeated the write-with-retry loop, and two also
  repeated the unit parser and unreadable-file filter (SettingsFile).
- ClampsForm and SupportGridForm repeated their row/box builders.
- Vertical/HorizontalRemnantComparer share a RemnantComparer base.

Behavior is unchanged except that Engine/NestStorage settings loads now
also tolerate NotSupported/Security exceptions, matching the other
settings files' never-throw contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:59:47 -04:00
ajandClaude Opus 5.5 4028dafb26 feat(cutoffs): trim a middle piece into a gap in the line
Trim refused the piece between two crossing cut-offs because a cut-off had
one start and one end, so a line could not be stopped short around a pocket
of parts without deleting it and drawing two. A cut-off now carries Gaps,
stretches between its limits left uncut: segment generation excludes them
like parts, so the line splits into segments that each take their own place
in the cut sequence. Trimming a middle piece adds a gap from one crossing
centre to the other (merged with any it touches), Extend near a gap's edge
closes it, and Swap Through-Cut refuses a junction a gap touches rather
than guess which side of the gap should run through.

Every reader of the limits treats a gap as uncut, so nothing waits for,
warns about or credits a cut that never happens: segment spans stop at a
gap, the cutting dependencies skip a part inside one, the clamp check
ignores a clamp inside one, the cutting-state and overlap stamps go stale
when gaps change, and a gapped line is never full span for the expected
remnant or automatic cut-offs. Gaps persist as an optional gaps array;
older versions ignore it and would cut straight through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:59:46 -04:00
ajandClaude Opus 5.5 7d9f900633 feat(posts): add the Cincinnati lower pallet function (M43/M44)
Lasers with the Lower Pallet Special Function option need M43 on before
the program's first move. It is written before GOTO1 so restarting at a
later sheet still runs in lower pallet mode, and is off by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:54:57 -04:00
ajandClaude Opus 5.5 27665c0ded test(planning): report each plate's budget headroom in the corpus
Fixes to the cut planner kept reopening nests fixed earlier. The budget
counts expansions, so outcomes repeat, but the corpus only ranked the
outcome and could not show a plate passing just inside its budget, the
kind a reordered search tips into NoSolutionWithinBudget. Each plate now
reports the share of its budget its strict search spent, and the run
ends with the plates at 80% or more, to compare with master's run.

part-order and entries reorder the search on every plate, so naming
either in OPENNEST_PROBLEM_NESTS_CHECKS now plans every nest instead of
only those tagged with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:54:56 -04:00
ajandClaude Opus 5.5 2c1a8eef42 refactor(reporting): split NestReportBuilder.Capture into focused helpers
Capture had grown to one 120-line method mixing plate validation, part
placement counting and drawing reconciliation, with a local function
holding the ID state. Separate those steps and move ID assignment into a
small registry so each rule can be read on its own. Validation order and
the snapshot are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:44:39 -04:00
ajandClaude Opus 5.5 47f9593a41 fix(planning): pierce arc text where a fitting lead-in cannot route
A plate of tags with arc-outline digits found no plan, although each
cutout had a clear entry. Eroded pierces were offered only when no
configured lead fit, so a letter with one fitting lead on the wrong
side stayed stuck with it, and an uncertain lead check skipped erosion
altogether. Recovery also spent its allowance on the outside entries
and accepted the first it reached, leaving a part on a side the next
rapid had to cross it from.

Count uncertain leads as no fit. Give recovery a last, separately
funded try that adds eroded pierces for the outside entries ranked
ahead of the first reached, and hold back routes that end facing away
from the next cut until then. Drop a lead that touches only in the
whole emission, where rounding moves it, rather than rebuilding the
same chain. Plans reached from their best entry keep their entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:38:32 -04:00
ajandClaude Opus 5.5 e76864b489 fix(engine): nest alike on every machine by fixing PairFiller's batches
FillItem_WithClampZones has flipped between two digests because each was
correct for the machine that pinned it. PairFiller sized its parallel
batches from Environment.ProcessorCount, and a batch shares the best count
and work area from before it, so core count decides which candidates are
tried. With 10+ logical processors candidate 9 runs before the area shrinks
and its 46-part layout wins for every comparer (9BF5FC7FC47364A9); with 8 or
fewer the area has shrunk, it can only reach 45, and Fill/Strip and Vertical
Remnant settle on different 45-part ties. 40 of the parts are kept either
way. So the same nest differed between shop PCs, and CI failed whenever the
digests were pinned on a 12-thread desktop.

Fix the batch at 8, which keeps the pins CI has always produced. Also claim
GridDedup keys in candidate order before the parallel loop: the claim was a
race inside a batch, seen as one candidate returning 42 parts and then 33
in the same process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:16:05 -04:00
aj 7a7b671408 fix(tests): select the library row before Add/Edit in DrawingLibraryFormTests
The drawing library tests relied on DataGridView picking a current row by
itself when the page is populated. It only does that when the first row is
already laid out as displayed, which the hosted Windows runner does not
guarantee. Without a current row SelectedRecord() is null, Add/Edit return
silently, and the test fails later:

- run 41057280460: EditSettings_SavesCutFirstAsANewRevision_AndTheAddAppliesIt
  "Timed out waiting for the reloaded page."
- run 40312036013: MismatchWarning_IsShownBeforeTheAdd_AndCancelLeavesTheNestAlone
  expected 2 messages, got 0.

Make the first row current explicitly after each page load (and after the
edit reload that precedes an Add), and make Run() rethrow a faulted task so
a failed step reports its own error rather than a 15 s timeout.
2026-10-30 09:17:13 -04:00
ajandClaude Opus 5.5 d80002e832 chore(help): ship help v0.6.2 with the set wording
Help v0.6.2 counts release quantities in sets and recaptures the release
screens, so the bundled guides match the release panel again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:58:15 -04:00
ajandClaude Opus 5.5 508b7db0df fix(engine): stop rejecting arc pairs placed at the part spacing
On a plate with clamps, MaterialsTooClose measured gaps on polygons faceted
at 0.001, which understate an arc gap by more than the 0.0005 slack. A pair
interlocked exactly at the spacing read as too close, so FillGroup discarded
every copy and the fill silently placed nothing. Confirm the gap on
NativeContours, as the layout check and pre-post review already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:41:22 -04:00
ajandClaude Opus 5.5 4ca8b0debf fix(ui): call set quantities sets in the release panel
The release panel was the only place that counted a set in kits;
everywhere else in the drawing library calls them sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:25:23 -04:00
ajandClaude Opus 5.5 889e33d179 feat(ui): stop new sheet cut-offs at the first crossing
A manual cut-off ran the full sheet even when another cut-off crossed it,
leaving a Trim/Extend pass to clean up what Automatic Scrap Cutoffs does
on its own. New lines now run from their start edge, set by the cut
direction, and stop at the first crossing line. Tab switches to full
length for the lines that should run through, and the choice lasts until
the nest is closed.

Suggested-by: alexvyperus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:20:56 -04:00
ajandClaude Opus 5.5 1c2bf16b3e feat(data): give the Generic Laser real grades, leads and stock sizes
Mild steel splits into CR, HR and HR P&O, stainless into 304 and 316,
and aluminum into 5052 and 6061, so no thickness carries a grade it is
not sold in. Gauge thicknesses use MaterialLedger's decimals, which
also corrects stainless gauges that used carbon-steel values.

Every thickness gets a 60 degree line lead-in (0.15", longer from
1/4" up) and a 0.1" lead-out, with no lead-out on inside cuts, a 0.75
cut-off overtravel and every catalog plate size up to 72x144.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:09:46 -04:00
ajandClaude Opus 5.5 6ca9a1243c fix(ui): enlarge the app icon's glyph so it matches taskbar neighbors
The sheet glyph filled only about half of a near-black tile that vanished
on the dark taskbar, so the icon read much smaller than other apps'.
Scale the glyph 1.3x, lighten the tile and give it a pale edge, then
re-render every .ico frame (16-256 px) directly from the SVG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:33:40 -04:00
aj c313d9699e test(engine): restore the clamped fill pins the code produces
0719476 re-pinned the Fill, Strip and Vertical Remnant clamp-zone
characterization digests to 9BF5FC7FC47364A9, but that layout never
reproduced: CI and local Linux runs at that commit and since give the
previous digests unchanged, so Cross-platform tests has failed on every
push since. Clamp clearance defaults to the bottom edge spacing, which
these plates already used, so their layouts were never expected to move.

Checked the produced layouts before restoring them: all four strategies
place 40 parts inside the work area with no NestLayoutCheck violations
against the clamp clearance.
2026-10-29 19:45:49 -04:00
aj 73a02cf309 feat(avalonia): run Auto Nest from the Nest menu and toolbar
Auto Nest nests the remaining quantities on sheets like the shown plate
with the saved engine selection (plug-ins loaded as in the desktop app),
solving off the UI thread with shared progress text in the status bar.
The button becomes Stop, which discards the whole proposal; Open is
refused while it runs. A refused result changes nothing and says why.

--auto-nest runs it on the opened nest before --snapshot renders, and
the snapshot now writes the status line to <png>.txt for headless checks.
2026-10-29 19:14:55 -04:00
aj cce2585d79 feat(desktop): Auto Nest remaining quantities without the dialog
AutoNestJob builds the desktop dialog's default job, each drawing's
remaining quantity (cut-offs excluded) on stock like a template plate,
and commits a NestPipeline result only when it is valid, keepable and
places something, onto the nest's empty plates first. RefusalReason
names why nothing was committed instead of NestRenest's release-flow
note.
2026-10-29 19:14:55 -04:00
aj 2db8f5f841 refactor(desktop): move EngineSelection and JobEngineNest into OpenNest.Desktop
The engine selection and the whole-job progress adapter use only Engine
and Data, so they move into the toolkit-neutral library for the Avalonia
front end to share. The namespace stays OpenNest.Forms, so WinForms
callers are unchanged.

Linux tests now cover the adapter's stage routing, one-based plate
numbers and descriptions, and the fill-strategy mapping; the saved
selection is left out because it reads and writes the real per-user file.
2026-10-29 19:14:55 -04:00
ajandClaude Opus 5.5 047174432f chore(help): ship help v0.6.1
Adds the Edit a plate by hand, Cut order and simulation and Nest settings
pages, the machine's Outside/Inside Cuts tabs, the Arc lead-in sweep, and
a recaptured Edit Drawing dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:13:05 -04:00
ajandClaude Opus 5.5 bb21ffdf35 feat(machines): split machine leads into outside and inside cuts
Machine Configuration held one lead-in and lead-out per thickness and
applied it to every contour. Each thickness now has Outside Cuts and
Inside Cuts tabs; inside cuts follow the outside leads until "Same as
outside cuts" is cleared, and configurations without inside leads keep
applying one pair to both.

The lead type lists, per-type fields and preview from Cutting Settings
are now a reusable LeadsEditor control that both dialogs use. The
machine tabs hide what the configuration cannot hold: the arc sweep,
the clean-hole kerf (taken from the thickness) and Line + Line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:51:42 -04:00
ajandClaude Opus 5.5 7ec11993de feat(data): ship a Generic Laser default machine in place of CL-980
New installs now start with a generic fiber laser: mild steel, 304
stainless and 5052 aluminum, gauge-named thicknesses, fiber-sized
kerfs and arc lead-ins, and no lead-out. It has its own id, so
uploading local machines never replaces an existing CL-980. A test
checks that the shipped values grow sensibly with thickness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:51:42 -04:00
ajandClaude Opus 5.5 16d4f706b2 feat(machines): name thickness gauges and keep the config tab
Thicknesses take an optional name or gauge shown beside the value in
the tree. The Machine Configuration form keeps the selected tab while
moving between thicknesses or machines, and opens fully expanded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:51:42 -04:00
aj 42755c9422 feat(avalonia): open nests through the shared Document 2026-10-29 18:40:49 -04:00
aj 38de7ab3a3 refactor(desktop): move Document and undo recording into OpenNest.Desktop
Document and Undo/ (ChangeScope, NestFingerprint, NestUndoState) use
only Core, Data, IO and Engine, so they move into the toolkit-neutral
library both desktop front ends share. Namespaces are unchanged, so the
WinForms app compiles as before; it keeps using the internal scope
factories and fingerprint through InternalsVisibleTo.

The scope, fingerprint and SaveAs/save-point rules now have Linux tests
in OpenNest.Desktop.Tests; before, only the Windows-only WinForms suite
covered them.
2026-10-29 18:40:49 -04:00
ajandClaude Opus 5.5 0719476618 feat(clamps): give clamps their own clearance from parts
A clamp rail can now carry its own Clearance, the gap parts keep from
every clamp footprint, independent of part spacing. Unset, it keeps the
sheet's bottom edge spacing as before. Clamp blocks drawn under the
sheet can use close to zero so the laser only stays off them.

Plate.ClampClearance and NestPlateStock.ClampClearance resolve it once;
the engines, interactive fill/pack/push, the layout check, Expander walls
and automatic cut-off placement all read it. The interactive push now
holds clamps at their square clearance keep-out (the layout check's
shape) instead of the part spacing, so parts no longer tuck into its
corners; the clamped fill characterization digests are re-pinned after
checking the new layouts validate.

Persisted as an optional `clearance` on the .nest clamp rail and the
machine clamp settings; edited in Nest > Clamps and the machine's
Clamps tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:25:27 -04:00
ajandClaude Opus 5.5 afdf196754 fix(help): let F1 set a placed part instead of opening help
The Help menu's F1 shortcut took the key before the plate view, so F1
in Clone/Add Part opened help rather than setting the part in place as
it always has. Actions can now claim F1; Clone does, and MainForm lets
the key through to the plate view while it is active.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:57:45 -04:00
aj 19be5a5ece ci(server-image): publish to GHCR only by manual dispatch from master
Publishing a GitHub Release, stable or pre-release, now only validates the
server image. A desktop release and a server image release are separate
owner decisions, so a stable desktop release cannot publish an image that
has not been accepted yet.
2026-10-27 10:18:44 -04:00
aj 242a0d2a1b fix(ui): match operator messages to the commands that exist
- archived library drawings: say they can't be edited or released; no
  front end can restore a library drawing yet
- library filter hint names the Show all materials checkbox
- undo labels Support Bed and Automatic Scrap Cutoffs match their menus
- display-outline posting hints name both Plate > Plan Cutting... and
  --plan-cutting, since the console prints them too
- MaterialLedger options are saved per Windows user, not per machine
2026-10-27 09:47:03 -04:00
aj f26fc0d596 docs: correct undo, Nest > Edit and job-number references for v0.6.0
- undo-redo: describe the heaviest nests by size instead of job numbers;
  name the Nest > Edit dialog rather than the internal 'Nest Info'
- cutting-planner: clearing lead-ins and cut-offs is now undoable
- nest-storage: drop the internal D4 label and the nonexistent
  Nest > Nest Info menu path
2026-10-27 09:47:03 -04:00
ajandClaude Opus 5.5 ac886d05b8 fix(ui): pre-release dialog fixes
- Save to Drawing Library: the stretching row was Nesting instead of Notes,
  leaving Notes one line high between empty bands.
- Nest Defaults: a read-only server copy disabled Save but left the fields
  editable, so edits were silently lost; lock the fields too.
- Support Bed: name the Cutting Settings option by its actual label,
  "Only tab parts the support grid can't hold level".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 09:35:51 -04:00
ajandClaude Opus 5.5 3e1df45c7f feat(machines): keep the clamp rail on each machine
Clamps are physical fixtures that differ between machines, but one global
clamps.json served every machine, so a plasma table and a fiber laser could
not have different rails.

- MachineConfig.Clamps holds each machine's rail in the machine's units,
  edited on a new Clamps tab in Tools > Machine Configuration.
- New and BOM import give a nest the rail of the machine they assign;
  Nest > Clamps loads and saves the nest's machine's clamps. Each nest keeps
  its own copy, so machine edits never move existing nests' clamps.
- At startup, while no machine has clamps, the legacy clamps.json (or the
  server's shared copy) is copied once into the default or only machine;
  with several machines and no default the user picks one.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:33:20 -04:00
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
1501 changed files with 149620 additions and 15915 deletions

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
---
name: planner
description: Designs an implementation plan for an OpenNest change before any code is written. Use for features, behavior changes or refactors that touch more than a file or two. Read-only; returns a plan, never edits.
tools: Read, Grep, Glob, Bash, WebFetch
---
You plan changes to OpenNest, a .NET 8 CNC-nesting application. You do not edit
files; you return a plan the main session will implement.
Before planning:
- Read `AGENTS.md`. Its project map, boundaries and geometry/ownership safeguards
are binding constraints on the plan.
- Read every `docs/` contract that `AGENTS.md` links for the area you touch, and
plan within it. If the change must alter a contract, say so and list the doc
edits.
- Find the existing code paths, tests and helpers the change should reuse.
Bash is for read-only inspection only (`git log`, `git grep`, `dotnet` listing
commands); never build, test, write or change git state.
Return, concisely:
1. **Goal and approach**: the design in a few sentences, and why over the main
alternative if there is a real one.
2. **Steps**: ordered, each naming the files/types it changes. Mark steps that
are independent of each other.
3. **Boundaries**: which project owns each piece (Core/Engine/IO/Desktop/
WinForms/Server), and any boundary the change comes near (IO depends on Core
only; Desktop never references WinForms, System.Drawing.Common or Avalonia;
Server stays off Engine/IO; WinForms tests stay out of OpenNest.Tests).
4. **Safeguards at risk**: the specific `AGENTS.md` rules the change could break
(tolerances, `PortableMath`, `SpecialLayers.IsMaterial`, undo recording,
irregular stock, cutoffs, common lines, posting checks) and how the plan
keeps them.
5. **Verification**: the exact `dotnet test` projects and filters to run; new
tests to add; whether the problem-nest corpus applies and with which
`OPENNEST_PROBLEM_NESTS_CHECKS` areas (Plan Cutting and engine changes only,
never fills, UI, posts or file formats; only the maintainer has it, so when
`OPENNEST_PROBLEM_NESTS_SERVER` is unset mark it a maintainer run before
merge); whether a nesting engine benchmark
applies; whether user help in `ajisaacs/opennest-help` needs updating.
6. **Open questions**: decisions that are genuinely the user's.
Cite code as `path:line`. Keep the plan to what was asked: no speculative
extras. Plans are not committed; if one should be saved, it goes under
`.hermes/plans/`.
+46
View File
@@ -0,0 +1,46 @@
---
name: reviewer
description: Independently reviews an OpenNest diff for correctness bugs and broken project safeguards. Use after implementing a change, before merging. Give it the branch or commit range, not your conclusions. Read-only; reports findings, never fixes them.
tools: Read, Grep, Glob, Bash
---
You review changes to OpenNest, a .NET 8 CNC-nesting application, with fresh
eyes. You did not write this code; do not assume it is right.
Scope: the diff you are given (default `git diff master...HEAD` plus uncommitted
changes). Read surrounding code as needed to judge it. Bash is for inspection
(`git diff`, `git log`, `git grep`) and, when useful, building or running the
relevant tests; never edit files, commit, stash or switch branches. On Linux,
build Windows projects with `-p:EnableWindowsTargeting=true` and say that this
is not Windows runtime verification.
Check, in order:
1. **Correctness**: logic errors, wrong edge cases, null/empty handling,
cancellation and threading, broken invariants, units (angles are radians).
2. **AGENTS.md safeguards**: read `AGENTS.md` and the `docs/` contracts it links
for the touched area, then check the diff against them. Common misses:
geometry compared without `Tolerance.Epsilon`; `System.Math` trig in
Core/Engine/IO/Data instead of `PortableMath`; marks treated as material
instead of `SpecialLayers.IsMaterial`; desktop nest edits without
`BeginChange`/`RecordChange`; double quantity accounting around
`ObservableList`; anything placed on an irregular (`SheetOutline`) plate
outside the Irregular fill; cutoffs applied as preview parts; posting
without checks or with a persisted warning bypass; loosened tolerances that
hide failures; caches shared across calls or threads.
3. **Boundaries**: project references (IO -> Core only; Desktop free of
WinForms/System.Drawing.Common/Avalonia; Server off Engine/IO; no WinForms in
OpenNest.Tests); plug-in engine projects kept out of the solution.
4. **Tests and docs**: behavior changes have tests that would fail without the
change; affected `docs/`, `AGENTS.md` and user help are updated; no design
specs, plans, progress notes or benchmark reports are committed; no vendor
manual text; no competitor product names.
5. **Verification owed**: whether the change needs the problem-nest corpus
(Plan Cutting and engine changes, limited by area) or an engine benchmark,
and whether that was evidently run. Only the maintainer has the corpus: when
`OPENNEST_PROBLEM_NESTS_SERVER` is unset, list the needed areas as a
maintainer run before merge, not as a finding against the change.
Report only real problems, most severe first. For each: `path:line`, what is
wrong, a concrete input or state that produces the failure, and how sure you are
(confirmed by reading/running vs. plausible). Skip style nits that
`dotnet format` or `.editorconfig` would settle. If nothing survives scrutiny,
say so plainly; do not pad the list.
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"vpk": {
"version": "1.2.0",
"commands": [
"vpk"
],
"rollForward": false
}
}
}
+8
View File
@@ -5,6 +5,14 @@
!OpenNest.Data/** !OpenNest.Data/**
!OpenNest.Core/ !OpenNest.Core/
!OpenNest.Core/** !OpenNest.Core/**
!OpenNest.Server.Worker/
!OpenNest.Server.Worker/**
!OpenNest.Api/
!OpenNest.Api/**
!OpenNest.Engine/
!OpenNest.Engine/**
!OpenNest.IO/
!OpenNest.IO/**
**/bin/** **/bin/**
**/obj/** **/obj/**
**/data/** **/data/**
+3
View File
@@ -20,3 +20,6 @@
*.PDF diff=astextplain *.PDF diff=astextplain
*.rtf diff=astextplain *.rtf diff=astextplain
*.RTF diff=astextplain *.RTF diff=astextplain
# Container scripts run in Linux images.
*.sh text eol=lf
+4 -2
View File
@@ -19,9 +19,11 @@ jobs:
matrix: matrix:
project: project:
- OpenNest.Tests - OpenNest.Tests
- OpenNest.Desktop.Tests
- OpenNest.Engine.Tests - OpenNest.Engine.Tests
- OpenNest.IO.Tests - OpenNest.IO.Tests
- OpenNest.Server.Tests - OpenNest.Server.Tests
- OpenNest.FrontEnd.Tests
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: with:
@@ -30,7 +32,7 @@ jobs:
with: with:
dotnet-version: '8.0.x' dotnet-version: '8.0.x'
- name: Run ${{ matrix.project }} - name: Run ${{ matrix.project }}
run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj" run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj" -c Release
synthetic-nests: synthetic-nests:
name: Six synthetic Irregular nests name: Six synthetic Irregular nests
@@ -70,7 +72,7 @@ jobs:
windows-desktop: windows-desktop:
# WinForms tests need a Windows desktop runtime; the Linux job can only # WinForms tests need a Windows desktop runtime; the Linux job can only
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop. # cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
runs-on: windows-2022 runs-on: windows-2022
timeout-minutes: 20 timeout-minutes: 20
defaults: defaults:
+128
View File
@@ -0,0 +1,128 @@
name: Development build
# Every master push whose cross-platform tests pass becomes the rolling
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
# newest release tag and N the commit count. Installed copies take it only
# with Tools > Options > Include development builds.
on:
workflow_run:
workflows: ['Cross-platform tests']
types: [completed]
branches: [master]
permissions:
contents: read
concurrency:
group: dev-build
cancel-in-progress: true
jobs:
package:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: windows-2022
timeout-minutes: 30
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Resolve development version
id: version
run: |
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
$latest = @($releases)[-1]
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
Write-Host "Development version $version (after v$latest)"
"version=$version" >> $env:GITHUB_OUTPUT
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
- name: Upload development build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: OpenNest-dev-build
path: |
artifacts/*.zip
artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error
retention-days: 7
compression-level: 0
publish:
needs: package
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: read
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
VERSION: ${{ needs.package.outputs.version }}
steps:
- name: Skip if master has moved on
id: current
run: |
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
if [ "$head" != "$SOURCE_SHA" ]; then
echo "master is at $head; its own run publishes the newer build."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- name: Download development build
if: steps.current.outputs.skip != 'true'
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
- name: Verify checksums
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
sha256sum -c ./*.zip.sha256
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
- name: Replace the dev-build pre-release
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
if gh release view dev-build >/dev/null 2>&1; then
gh release delete dev-build --cleanup-tag --yes
fi
cat > notes.md <<EOF
Automatic development build of master at $SOURCE_SHA.
It may contain unfinished features. Installed copies receive it only with
**Tools > Options > Include development builds**; full releases are unaffected.
EOF
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
--target "$SOURCE_SHA" --prerelease --latest=false \
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
- name: Read back the pre-release
if: steps.current.outputs.skip != 'true'
run: |
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
grep -qx 'prerelease=true draft=false assets=8' readback.txt
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
+9 -2
View File
@@ -6,6 +6,10 @@ on:
- 'OpenNest.Server/**' - 'OpenNest.Server/**'
- 'OpenNest.Data/**' - 'OpenNest.Data/**'
- 'OpenNest.Core/**' - 'OpenNest.Core/**'
- 'OpenNest.Server.Worker/**'
- 'OpenNest.Api/**'
- 'OpenNest.Engine/**'
- 'OpenNest.IO/**'
- 'OpenNest.Server.Tests/**' - 'OpenNest.Server.Tests/**'
- 'scripts/Server.Tests/**' - 'scripts/Server.Tests/**'
- 'scripts/Test-ServerContainer.sh' - 'scripts/Test-ServerContainer.sh'
@@ -21,6 +25,10 @@ on:
- 'OpenNest.Server/**' - 'OpenNest.Server/**'
- 'OpenNest.Data/**' - 'OpenNest.Data/**'
- 'OpenNest.Core/**' - 'OpenNest.Core/**'
- 'OpenNest.Server.Worker/**'
- 'OpenNest.Api/**'
- 'OpenNest.Engine/**'
- 'OpenNest.IO/**'
- 'OpenNest.Server.Tests/**' - 'OpenNest.Server.Tests/**'
- 'scripts/Server.Tests/**' - 'scripts/Server.Tests/**'
- 'scripts/Test-ServerContainer.sh' - 'scripts/Test-ServerContainer.sh'
@@ -114,8 +122,7 @@ jobs:
publish: publish:
if: >- if: >-
github.repository == 'ajisaacs/OpenNest' && github.repository == 'ajisaacs/OpenNest' &&
((github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 40 timeout-minutes: 40
permissions: permissions:
+6 -1
View File
@@ -68,7 +68,7 @@ jobs:
# mini dump in TestResults, instead of holding the runner until the # mini dump in TestResults, instead of holding the runner until the
# job timeout. # job timeout.
run: | run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) { foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." } if ($LASTEXITCODE -ne 0) { throw "$project failed." }
} }
@@ -96,6 +96,11 @@ jobs:
path: | path: |
artifacts/*.zip artifacts/*.zip
artifacts/*.sha256 artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
compression-level: 0 compression-level: 0
+6 -1
View File
@@ -208,7 +208,9 @@ FakesAssemblies/
*.db-journal *.db-journal
# Local agent state and temporary planning/progress documents # Local agent state and temporary planning/progress documents
.claude/ # (shared Claude Code agent definitions stay tracked)
.claude/*
!.claude/agents/
/.hermes/plans/ /.hermes/plans/
/.hermes/progress/ /.hermes/progress/
.superpowers/ .superpowers/
@@ -225,3 +227,6 @@ OpenNest.Tests/test-config.json
# Vendor programming manuals: keep reference copies outside source control. # Vendor programming manuals: keep reference copies outside source control.
CINCINNATI LASER PROGRAMMING MANUAL.pdf CINCINNATI LASER PROGRAMMING MANUAL.pdf
TF5200_programming_manual_en.pdf TF5200_programming_manual_en.pdf
# Python bytecode
__pycache__/
+28 -9
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. - 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`. - 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/`. - 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. - 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.
@@ -22,48 +22,67 @@ dotnet build OpenNest.sln
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
dotnet test OpenNest.Desktop.Tests/OpenNest.Desktop.Tests.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
# Windows runtime tests # Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
``` ```
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`. On Windows, prefer `scripts/Test-Hidden.ps1 <dotnet test args>` for WinForms tests: it runs them on a hidden desktop so no windows appear or take focus; see [hidden-desktop tests](docs/hidden-desktop-tests.md).
Plan Cutting and nesting-engine changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`), which holds every nest that ever caused a problem; engine changes run only its `auto-nest` area. Never run it for fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); `part-order` and `entries` plan every nest. No plate may rank below its baseline, and none may join or climb the near-budget list compared with master's run. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror. Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
## Project map and boundaries ## Project map and boundaries
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter. - `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter, and call `PortableMath` (not `System.Math`) for trig in Core/Engine/IO/Data so layouts match across platforms. `OpenNest.Math` and `OpenNest.Geometry` are Core's bottom layers (Math uses nothing else in OpenNest; Geometry uses Math plus the root types `OffsetSide`, `RotationType`, `PushDirection`, `RelativePosition`, `PerfCounters`, `SpecialLayers`); `CoreLayeringTests` enforces it.
- `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. - `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. - 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.IO`: ACadSharp and SVG 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). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder; `OpenNest.WinForms.Tests` redirects them to a temp file, so a test run never writes the user's settings. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to each desktop app's output `Posts/` directory (WinForms and Avalonia). Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time. - `OpenNest.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
- `OpenNest.Server`: nest storage HTTP service, kept off Engine and IO. `OpenNest.Server.Worker`: the server nesting worker, shipped in the same image.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
## Geometry and ownership safeguards ## 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. - 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. - `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. - `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. - `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
- `Plate.Outline`/`NestPlateStock.Outline` (`SheetOutline`) make a sheet irregular; `Size` stays its bounding box. Only Irregular fills it (`PackBoundary` eroded by the largest edge spacing); every other engine and fill strategy must place nothing on it, validation must reject parts outside it or over its obstacles, and automatic cut-offs refuse it. See [irregular stock](docs/irregular-stock.md).
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names. - 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. - Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. The one designed raise is the rapid out of a cutout's slug after its lead-out, marked at emission (see the cutting planner doc); no lead-out or an overcut keeps the head down. Every post must raise before a marked rapid or refuse the nest.
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation. - 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. - Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
Read the relevant contract before changing its behavior: Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md) - [Nest file format](docs/nest-file-format.md)
- [SVG import and export](docs/svg-import-export.md): SVG numbers are not drawing units; import never guesses a scale
- [Irregular stock](docs/irregular-stock.md): outlined sheets, what honors the outline and what refuses it
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md) - [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 the stop at a feature's center line) 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 - [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 - [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
- [Dimensions](docs/dimensions.md): CAD Converter annotations that store placement only, are measured from the cut geometry when shown and never affect the program
- [Common-line cutting](docs/common-line-cutting.md): parts one kerf apart in `Plate.CommonLines` share an edge; spans are derived, never stored, and spacing checks hold group members to the kerf. Plan Cutting cuts each shared span once (the owner, cut first, starts its compensated outside on the common line; partners cut only their remainders) unless the plate is set to cut them twice; only posts implementing `ICommonLinePostProcessor` (CI Fiber) may write it, others refuse. Machine trial pending. Auto Nest and Fill Area pair `AllowCommonLine` drawings at the nest's `CommonLineUsage` (Off by default, byte-identical to no common lines); `NestLayoutCheck` exempts only explicit result groups
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, Plan Cutting's tab placement for parts the grid cannot hold level, and its opt-in slicing of slugs the grid cannot hold level
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md) - [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md) - [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md) - [Pre-post verification](docs/post-verification.md)
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf, tabs, pierce and cut-off settings per material and thickness; the Cutting Parameters dialog saves to that thickness
- [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
- [Server nesting](docs/server-nesting.md): Database-mode Auto Nest queued to the server's worker container (`OPENNEST_ROLE=worker`), one job at a time with no time limit, streaming progress and the best plan so far over SSE; the desktop replays the recorded engine answers to validate and bind locally. My Server Jobs reopens results in later sessions, and each result is kept at most once (`If-Match` Keep). The HTTP server stays off Engine/IO
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits - [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) - [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. - [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`. - [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, topic IDs and the pinned help release both packaging scripts install.
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Remote;
using OpenNest.IO;
namespace OpenNest.Api;
/// <summary>
/// A whole-job nest request as one upload for the nest server: a ZIP holding
/// <c>job.json</c> (<see cref="NestJobDocument"/>) and <c>drawings.nest</c>, a nest archive of
/// exactly the items' drawings in the order the document names them. Drawings travel as the
/// nest file format stores them, so the server nests what the client has, edits included.
/// </summary>
public static class NestJobPackage
{
public const string JobEntry = "job.json";
public const string DrawingsEntry = "drawings.nest";
public static byte[] Write(NestPipelineRequest request, Units units = Units.Inches)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Items);
var drawings = new List<Drawing>();
foreach (var item in request.Items)
{
if (item?.Drawing != null && !drawings.Any(d => ReferenceEquals(d, item.Drawing)))
drawings.Add(item.Drawing);
}
var document = NestJobDocument.From(request, drawings);
// A throwaway nest holds the drawings only; the caller's nest is never written.
var nest = new Nest { Units = units };
foreach (var drawing in drawings)
nest.Drawings.Add(drawing);
using var buffer = new MemoryStream();
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
using (var writer = new StreamWriter(zip.CreateEntry(JobEntry).Open(), new UTF8Encoding(false)))
writer.Write(document.ToJson());
using var archive = zip.CreateEntry(DrawingsEntry, CompressionLevel.NoCompression).Open();
new NestWriter(nest).Write(archive);
}
return buffer.ToArray();
}
/// <summary>
/// The package's <c>job.json</c> alone, without reading its drawings: a desktop reviewing the
/// job in a later session binds it to its own drawings instead
/// (<see cref="RemoteNestSubmission.Reopen"/>). Throws <see cref="FormatException"/> when it is not a package.
/// </summary>
public static NestJobDocument ReadDocument(byte[] package)
{
ArgumentNullException.ThrowIfNull(package);
try
{
using var zip = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
var jobEntry = zip.GetEntry(JobEntry) ?? throw new FormatException($"The nest job has no {JobEntry}.");
using var reader = new StreamReader(jobEntry.Open(), Encoding.UTF8);
return NestJobDocument.FromJson(reader.ReadToEnd());
}
catch (InvalidDataException ex)
{
throw new FormatException("The nest job is not a readable ZIP package.", ex);
}
}
/// <summary>Reads a package back into a request; throws <see cref="FormatException"/> when it is not one.</summary>
public static NestPipelineRequest Read(byte[] package)
{
ArgumentNullException.ThrowIfNull(package);
try
{
using var zip = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
var jobEntry = zip.GetEntry(JobEntry) ?? throw new FormatException($"The nest job has no {JobEntry}.");
var drawingsEntry = zip.GetEntry(DrawingsEntry) ?? throw new FormatException($"The nest job has no {DrawingsEntry}.");
string json;
using (var reader = new StreamReader(jobEntry.Open(), Encoding.UTF8))
json = reader.ReadToEnd();
var document = NestJobDocument.FromJson(json);
// NestReader needs a seekable stream.
using var archive = new MemoryStream();
using (var entry = drawingsEntry.Open())
entry.CopyTo(archive);
archive.Position = 0;
var nest = new NestReader(archive).Read();
return document.ToRequest(nest.Drawings.ToList());
}
catch (InvalidDataException ex)
{
throw new FormatException("The nest job is not a readable ZIP package.", ex);
}
}
}
+1
View File
@@ -7,5 +7,6 @@ public class NestRequestPart
public string DxfPath { get; init; } public string DxfPath { get; init; }
public int Quantity { get; init; } = 1; public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true; public bool AllowRotation { get; init; } = true;
/// <summary>Nesting order: higher numbers nest first.</summary>
public int Priority { get; init; } = 0; public int Priority { get; init; } = 0;
} }
+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>
+43
View File
@@ -0,0 +1,43 @@
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>();
// Each nest path before the first --option opens in its own tab; the last is shown.
foreach (var path in args)
{
if (path.StartsWith("--"))
break;
window.OpenNest(path);
}
// --auto-nest: run Auto Nest on the opened nest (with --snapshot, before rendering).
if (System.Array.IndexOf(args, "--auto-nest") >= 0)
window.AutoNestOnOpen();
// --snapshot <png> [plate]: render the window to an image (status line in <png>.txt) and exit.
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);
else
{
window.RememberPlacement();
window.RunStartupOnOpen();
}
}
base.OnFrameworkInitializationCompleted();
}
}
}
+72
View File
@@ -0,0 +1,72 @@
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace OpenNest.Avalonia
{
/// <summary>
/// A message with a row of buttons ending in Cancel (OK for a plain message). That last
/// button is the default and Esc, so a stray Enter never discards work, and closing the
/// dialog counts as it.
/// </summary>
internal sealed class ConfirmDialog : Window
{
private ConfirmDialog(string title, string message, string[] choices, string cancelText)
{
Title = title;
Width = 460;
SizeToContent = SizeToContent.Height;
CanResize = false;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
var buttons = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
};
for (var i = 0; i < choices.Length; i++)
{
var index = i;
var choice = new Button { Content = choices[i] };
choice.Click += (_, _) => Close(index);
buttons.Children.Add(choice);
}
var cancel = new Button { Content = cancelText, IsDefault = true, IsCancel = true };
cancel.Click += (_, _) => Close(null);
buttons.Children.Add(cancel);
var panel = new StackPanel { Margin = new Thickness(18), Spacing = 18 };
// A long message (many load warnings) scrolls rather than pushing the buttons away.
panel.Children.Add(new ScrollViewer
{
MaxHeight = 420,
Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
});
panel.Children.Add(buttons);
Content = panel;
Opened += (_, _) => cancel.Focus();
}
/// <summary>True when the user chose <paramref name="confirmText"/>.</summary>
public static async Task<bool> AskAsync(
Window owner, string title, string message, string confirmText) =>
await ChooseAsync(owner, title, message, confirmText) == 0;
/// <summary>
/// The index of the choice the user clicked, or -1 for Cancel (also Esc, Enter and
/// closing the dialog).
/// </summary>
public static async Task<int> ChooseAsync(
Window owner, string title, string message, params string[] choices) =>
await new ConfirmDialog(title, message, choices, "Cancel")
.ShowDialog<int?>(owner) ?? -1;
/// <summary>A message with only an OK button.</summary>
public static Task ShowAsync(Window owner, string title, string message) =>
new ConfirmDialog(title, message, System.Array.Empty<string>(), "OK")
.ShowDialog<int?>(owner);
}
}
File diff suppressed because it is too large. Load diff
+103
View File
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using OpenNest.Data;
using OpenNest.Desktop;
namespace OpenNest.Avalonia
{
/// <summary>
/// The machines, nest defaults and post processors this app works with, from the shared
/// desktop library: in Database mode (storage.json, shared with the WinForms app) the nest
/// server's copy once it holds one, otherwise this PC's files, with the user settings both
/// apps keep in user-settings.json.
/// </summary>
internal static class DesktopSettings
{
/// <summary>The storage mode and nest server from storage.json; Tools > Options can change the server.</summary>
private static NestStorageSettings storage = NestStorageSettings.Load(NestStorageSettings.DefaultPath);
public static SharedSettingsSource Shared { get; } = new(() => storage);
public static MachineSettingsSource Machines { get; } =
new(Shared, new MachineUserSettingsFile(UserSettingsFile.DefaultPath));
private static RemoteNestRepository repository;
private static string repositoryUrl;
/// <summary>
/// File > New nests, numbered by the nest server in Database mode and otherwise by the
/// counter both apps keep in user-settings.json.
/// </summary>
public static NewNestSource NewNests { get; } = new(
Machines,
new NewNestUserSettingsFile(UserSettingsFile.DefaultPath),
() => storage.IsDatabaseMode ? Repository(storage.ServerUrl) : null);
/// <summary>The nest server URL saved in storage.json (blank when none).</summary>
public static string ServerUrl => storage.ServerUrl;
/// <summary>
/// Saves <paramref name="serverUrl"/> as the nest server in storage.json, keeping the
/// storage mode, as the WinForms Options form does; the next request uses it.
/// </summary>
public static void SaveServerUrl(string serverUrl) =>
storage = NestServerUrlOption.SaveToFile(NestStorageSettings.DefaultPath, serverUrl);
// One client per server, recreated when the URL changes.
private static RemoteNestRepository Repository(string url)
{
if (repository == null || repositoryUrl != url)
{
repository?.Dispose();
repository = new RemoteNestRepository(url);
repositoryUrl = url;
}
return repository;
}
/// <summary>The post processors loaded from Posts/ beside the app, by load order.</summary>
public static IReadOnlyList<IPostProcessor> PostProcessors { get; private set; } = [];
/// <summary>The readable machine configurations, once <see cref="LoadMachinesAsync"/> has run.</summary>
public static IReadOnlyList<MachineConfig> MachineConfigs { get; private set; } = [];
/// <summary>
/// Loads the post processors in <paramref name="directory"/> (each DLL on its own, so one
/// that fails does not stop the others) and registers their materials. Returns the
/// failures to show, or null when every post loaded.
/// </summary>
public static string LoadPosts(string directory)
{
var result = PostProcessorLoader.Load(directory);
PostProcessors = result.PostProcessors;
return result.Failures.Count == 0
? null
: "These post processors could not be loaded: " + string.Join("; ", result.Failures);
}
/// <summary>
/// Reads the machines off the UI thread (the nest server can take seconds to answer), then
/// copies the legacy clamps onto the default or only machine, as the WinForms app does at
/// startup (with several machines and no default it waits for the WinForms app to ask).
/// Returns what to tell the user: the machines' source when the server could not be
/// reached, or why the clamps were not copied; null when there is nothing to say.
/// </summary>
public static Task<string> LoadMachinesAsync() => Task.Run(() =>
{
MachineConfigs = Machines.LoadMachines();
var clamps = Machines.MigrateLegacyClamps(askMachine: null);
var offline = Shared.TakeOfflineWarning();
var notices = new[] { offline, clamps }
.Where(text => !string.IsNullOrEmpty(text))
.Select(OneLine)
.ToList();
return notices.Count == 0 ? null : string.Join(" ", notices);
});
private static string OneLine(string text) =>
string.Join(" ", text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
}
+307
View File
@@ -0,0 +1,307 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using OpenNest.Avalonia.Rendering;
using OpenNest.Data;
using OpenNest.Desktop;
namespace OpenNest.Avalonia
{
/// <summary>
/// File &gt; Export Plate, Export All Plates and Nest Report, and Plate &gt; View in CAD, as
/// the WinForms app has them: the same suggested file names and writers
/// (<see cref="PlateExport"/>, <see cref="NestReportExport"/>). A viewed report opens in the
/// system's PDF viewer, which saves and prints it; View in CAD opens the plate's DXF in the
/// program the system associates with .dxf.
/// </summary>
public partial class MainWindow
{
private static readonly FilePickerFileType DxfFiles =
new("DXF file") { Patterns = new[] { "*.dxf" } };
private static readonly FilePickerFileType ImageFiles =
new("Image as displayed") { Patterns = new[] { "*.png", "*.jpg", "*.jpeg" } };
private static readonly FilePickerFileType PlacementFiles =
new("Locations and rotations") { Patterns = new[] { "*.txt" } };
private static readonly FilePickerFileType SvgFiles =
new("SVG file") { Patterns = new[] { "*.svg" } };
private void UpdateExportCommands(bool hasNest)
{
ExportPlateMenuItem.IsEnabled = hasNest;
ExportAllPlatesMenuItem.IsEnabled = hasNest;
ViewNestReportMenuItem.IsEnabled = hasNest;
ViewInCadMenuItem.IsEnabled = hasNest;
}
private async void OnExportPlate(object sender, RoutedEventArgs e)
{
var view = ActiveView;
if (view == null || !CanExport())
return;
writingFile = true;
try
{
await ExportPlateAsync(view, view.PlateIndex);
}
finally
{
writingFile = false;
}
}
/// <summary>
/// As WinForms: Export Plate once per plate, first to last, each plate shown while its
/// file is chosen; cancelling or a failure stops the rest.
/// </summary>
private async void OnExportAllPlates(object sender, RoutedEventArgs e)
{
var view = ActiveView;
if (view == null || !CanExport())
return;
writingFile = true;
try
{
var count = view.PlateCount;
var exported = await PlateExport.ExportAllAsync(count, i => ExportPlateAsync(view, i));
if (exported < count)
ShowNotice($"Exported {exported} of {count} plates of {view.Tab.Name}");
else
ShowNotice($"Exported all {count} plates of {view.Tab.Name}");
}
finally
{
writingFile = false;
}
}
// One export at a time; nesting does not block it, as its result is kept on this thread.
private bool CanExport()
{
if (!writingFile)
return true;
ShowNotice("A nest is already being saved or exported. Finish that before exporting.");
return false;
}
/// <summary>
/// Shows plate <paramref name="index"/>, asks for its file and writes it in the format the
/// file's extension names. True when it was written.
/// </summary>
private async Task<bool> ExportPlateAsync(NestView view, int index)
{
if (index < 0 || index >= view.PlateCount)
return false;
view.ShowPlate(index);
var plate = view.PlateView.Plate;
if (plate == null)
return false;
var nest = view.Tab.Document.Nest;
var number = index + 1;
var target = await PickPlateExportFileAsync(view.Tab, PlateExport.SuggestedFileName(nest.Name, number));
if (target == null)
return false;
var (path, format) = target.Value;
try
{
// The image is of the plate as shown, so show it again if another was picked meanwhile.
if (format == PlateExportFormat.Image && !ReferenceEquals(view.PlateView.Plate, plate))
view.ShowPlate(nest.Plates.IndexOf(plate));
PlateExport.Write(plate, nest.Units, path, format, file => ViewImage.Save(view.PlateView, file));
}
catch (Exception ex)
{
await ConfirmDialog.ShowAsync(this, "Export Plate",
$"Could not export plate to '{path}': {ex.Message}");
return false;
}
ShowNotice($"Exported plate {number} of {view.Tab.Name} to {path}");
return true;
}
// The file to export a plate to and its format, or null when cancelled or refused.
private async Task<(string Path, PlateExportFormat Format)?> PickPlateExportFileAsync(
NestTab tab, string suggestedFileName)
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export Plate",
SuggestedFileName = suggestedFileName,
DefaultExtension = "dxf",
ShowOverwritePrompt = true,
FileTypeChoices = new[] { DxfFiles, ImageFiles, PlacementFiles, SvgFiles },
SuggestedStartLocation = await NestFolderAsync(tab),
});
if (file == null)
return null;
var path = file.TryGetLocalPath();
if (path == null)
{
ShowNotice("Plates can only be exported to a folder on this computer.");
return null;
}
// A name typed without an extension is the dialog's default type, DXF.
if (Path.GetExtension(path).Length == 0)
{
path += ".dxf";
if (File.Exists(path) && !await ConfirmDialog.AskAsync(this, "Export Plate",
$"{Path.GetFileName(path)} already exists. Replace it?", "Replace"))
return null;
}
if (PlateExport.FromPath(path) is not { } format)
{
await ConfirmDialog.ShowAsync(this, "Export Plate",
$"A plate cannot be exported as '{Path.GetFileName(path)}'. Choose a file name "
+ "ending in .dxf, .png, .jpg, .txt or .svg.");
return null;
}
return (path, format);
}
/// <summary>Writes the shown plate as a DXF in the temp folder and opens it in the system's CAD program.</summary>
private async void OnViewInCad(object sender, RoutedEventArgs e)
{
var view = ActiveView;
var plate = view?.PlateView.Plate;
if (plate == null)
return;
var nest = view.Tab.Document.Nest;
var path = PlateExport.ExternalCadPath(nest.Name, view.PlateIndex + 1);
Task<string> opened;
try
{
PlateExport.Write(plate, nest.Units, path, PlateExportFormat.Dxf, null);
opened = ExternalFileLauncher.OpenAsync(path);
}
catch (Exception ex)
{
await ConfirmDialog.ShowAsync(this, "View in CAD", $"Could not open '{path}' in CAD: {ex.Message}");
return;
}
ShowNotice($"Opening {path}");
await ReportOpenFailureAsync(opened, "View in CAD", $"Could not open '{path}' in CAD");
}
/// <summary>
/// Writes the nest report to a private temporary folder and opens it in the system's PDF
/// viewer, which saves and prints it. Viewed reports older than a day are removed.
/// </summary>
private async void OnViewNestReport(object sender, RoutedEventArgs e)
{
var tab = tabs.Active;
if (tab == null || !TryReportFileName(tab, out var fileName))
return;
if (!CanWrite(tab, "creating the report"))
return;
writingFile = true;
string path = null;
try
{
await MachinesLoaded;
// Auto Nest may have started on it while the machines were read.
if (IsNesting(tab, "creating the report"))
return;
path = NestReportExport.CreateViewPath(fileName);
await WriteReportAsync(tab, path);
await Task.Run(() => NestReportExport.DeleteViewedReportsBefore(
DateTime.UtcNow - NestReportExport.ViewedReportLifetime));
}
catch (Exception ex) when (IsReportFailure(ex))
{
if (path != null)
NestReportExport.DeleteReportFolder(path);
await ReportFailureAsync(tab, ex);
return;
}
finally
{
writingFile = false;
}
Task<string> opened;
try
{
opened = ExternalFileLauncher.OpenAsync(path);
}
catch (Exception ex)
{
await ConfirmDialog.ShowAsync(this, "Nest Report",
$"The nest report was written to '{path}', but it could not be opened: {ex.Message}");
return;
}
ShowNotice($"Opening the nest report for {tab.Name}");
await ReportOpenFailureAsync(opened, "Nest Report",
$"The nest report was written to '{path}', but it could not be opened");
}
// The report's file name, or false (after saying why) when the nest's name cannot be one.
private bool TryReportFileName(NestTab tab, out string fileName)
{
try
{
fileName = NestReportExport.ReportFileName(tab.Document.Nest);
return true;
}
catch (InvalidOperationException ex)
{
fileName = null;
_ = ConfirmDialog.ShowAsync(this, "Nest Report", $"Could not create the nest report: {ex.Message}");
return false;
}
}
/// <summary>
/// Captures the report on this thread, so the nest is read as it stands and is left
/// unchanged, then writes the detached snapshot off it.
/// </summary>
private static Task WriteReportAsync(NestTab tab, string path)
{
var snapshot = NestReportExport.Capture(
tab.Document.Nest,
DateTimeOffset.Now,
DesktopSettings.MachineConfigs,
UserSettingsFile.Read(UserSettingsFile.DefaultPath));
return Task.Run(() => NestReportExport.Write(snapshot, path));
}
private static bool IsReportFailure(Exception ex) =>
ex is InvalidOperationException
or NotSupportedException
or InvalidDataException
or IOException
or UnauthorizedAccessException
or ArgumentException;
private Task ReportFailureAsync(NestTab tab, Exception error) =>
ConfirmDialog.ShowAsync(this, "Nest Report",
$"Could not create the nest report for '{tab.Name}': {error.Message}");
// Says why the system could not open a file, once its opener has reported back.
private async Task ReportOpenFailureAsync(Task<string> opened, string title, string what)
{
string failure;
try
{
failure = await opened;
}
catch (Exception ex)
{
failure = ex.Message;
}
if (failure != null)
await ConfirmDialog.ShowAsync(this, title, $"{what}: {failure}");
}
private async Task<IStorageFolder> NestFolderAsync(NestTab tab)
{
var folder = tab.Path != null ? Path.GetDirectoryName(tab.Path) : null;
return folder != null ? await StorageProvider.TryGetFolderFromPathAsync(folder) : null;
}
}
}
+164
View File
@@ -0,0 +1,164 @@
using System;
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using OpenNest.Desktop;
using OpenNest.Undo;
using Visual = global::Avalonia.Visual;
namespace OpenNest.Avalonia
{
/// <summary>
/// Edit &gt; Undo and Redo of the active nest, through its tab's shared
/// <see cref="NestUndoRecorder"/>: Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z unless a text box has
/// focus, the menu naming the next step, and nothing while Auto Nest works on the nest.
/// Switching tabs rebinds the menu to the active nest's history.
/// </summary>
public partial class MainWindow
{
// Each open tab's history handler, so closing the tab unhooks it.
private readonly System.Collections.Generic.Dictionary<NestTab, EventHandler> undoHandlers = new();
/// <summary>Follows <paramref name="tab"/>'s history: its unsaved dot and, while active, the Edit menu.</summary>
private void WatchHistory(NestTab tab)
{
EventHandler handler = (_, _) => OnHistoryChanged(tab);
undoHandlers[tab] = handler;
tab.Undo.Changed += handler;
}
private void UnwatchHistory(NestTab tab)
{
if (undoHandlers.Remove(tab, out var handler))
tab.Undo.Changed -= handler;
}
private void OnHistoryChanged(NestTab tab)
{
RebuildTabStrip();
if (tab == tabs.Active)
UpdateUndoMenu();
}
/// <summary>
/// Handles Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z for the active nest, as the WinForms main
/// window does. A focused text editor keeps them for its own typing.
/// </summary>
private bool ProcessUndoKeys(KeyEventArgs e)
{
var modifiers = e.KeyModifiers & ~KeyModifiers.Shift;
if (modifiers != KeyModifiers.Control || e.Key is not (Key.Z or Key.Y))
return false;
var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
if (e.Key == Key.Y && shift)
return false;
if (tabs.Active == null || KeepsOwnUndo(FocusManager?.GetFocusedElement() as Visual))
return false;
if (e.Key == Key.Z && !shift)
TryUndo(tabs.Active);
else
TryRedo(tabs.Active);
return true;
}
/// <summary>
/// True for controls that undo their own typing: a text box, including the one inside a
/// numeric box, an editable combo box or a grid cell being edited.
/// </summary>
internal static bool KeepsOwnUndo(Visual focused)
{
for (var visual = focused; visual != null; visual = visual.GetVisualParent())
{
if (visual is TextBox)
return true;
}
return false;
}
// Not while Auto Nest works on the nest: its result is applied to the nest it started on.
private bool CanUndoOrRedo(NestTab tab) => tab != null && tab != nestingTab;
private bool TryUndo(NestTab tab)
{
if (!CanUndoOrRedo(tab) || !tab.Undo.CanUndo)
return false;
return Step(tab, "undo", tab.Undo.Undo);
}
private bool TryRedo(NestTab tab)
{
if (!CanUndoOrRedo(tab) || !tab.Undo.CanRedo)
return false;
return Step(tab, "redo", tab.Undo.Redo);
}
private bool Step(NestTab tab, string what, Action step)
{
try
{
step();
}
catch (Exception ex)
{
// A copy that cannot be read back leaves the nest and the history as they were.
ShowNotice($"Could not {what} in {tab.Name}: {ex.Message}");
UpdateUndoMenu();
return false;
}
UpdateUndoMenu();
return true;
}
private void OnUndo(object sender, RoutedEventArgs e)
{
if (tabs.Active != null)
TryUndo(tabs.Active);
}
private void OnRedo(object sender, RoutedEventArgs e)
{
if (tabs.Active != null)
TryRedo(tabs.Active);
}
// Busy states have no change event of their own; check them whenever the menu opens.
private void OnEditMenuOpened(object sender, RoutedEventArgs e) => UpdateUndoMenu();
/// <summary>Shows the active nest's next Undo and Redo steps, enabled when they can run.</summary>
private void UpdateUndoMenu()
{
var tab = tabs.Active;
var available = CanUndoOrRedo(tab);
UndoMenuItem.IsEnabled = available && tab.Undo.CanUndo;
RedoMenuItem.IsEnabled = available && tab.Undo.CanRedo;
UndoMenuItem.Header = AccessKeyed(tab?.Undo.UndoText ?? "Undo");
RedoMenuItem.Header = AccessKeyed(tab?.Undo.RedoText ?? "Redo");
}
// "Undo Rotate" as "_Undo Rotate": U and R stay the access keys, a label's own
// underscores are shown as typed.
private static string AccessKeyed(string text) => "_" + text.Replace("_", "__");
#if DEBUG
private DispatcherTimer unrecordedCheck;
// Every edit should open a change scope; one that does not folds into the next entry.
private void StartUnrecordedChangeCheck()
{
unrecordedCheck = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Background,
(_, _) =>
{
var tab = tabs.Active;
if (tab != null && tab != nestingTab && tab.Undo.TakeUnrecordedChangeWarning())
Debug.WriteLine($"Undo: '{tab.Name}' changed outside a change scope; the edit is not recorded yet.");
});
unrecordedCheck.Start();
Closed += (_, _) => unrecordedCheck.Stop();
}
#endif
}
}
+92
View File
@@ -0,0 +1,92 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using OpenNest.Data;
using OpenNest.Desktop.Rendering;
namespace OpenNest.Avalonia
{
/// <summary>
/// View &gt; Draw Rapids, Pierce Points, Bounds, Offset, Cut Direction and Support: toggles of the
/// active nest's plate view, as each WinForms nest window keeps its own. Rapids and bounds are
/// read from the shared user settings when a nest opens and written back when it closes.
/// </summary>
public partial class MainWindow
{
private void RestoreViewToggles(NestView view) =>
view.PlateView.Options = snapshotMode
? new PlateViewOptions()
: PlateViewOptions.Read(UserSettingsFile.Read(UserSettingsFile.DefaultPath));
// A snapshot must not depend on the user's settings.
private void UseDefaultViewToggles()
{
foreach (var view in views.Values)
{
view.PlateView.Options = new PlateViewOptions();
view.PlateView.InvalidateVisual();
}
UpdateViewToggles();
}
private void SaveViewToggles(NestView view)
{
if (snapshotMode)
return;
UserSettingsFile.Update(UserSettingsFile.DefaultPath, view.PlateView.Options.WriteTo);
}
// As the WinForms nest windows closing in turn: the last one's toggles are kept.
private void SaveAllViewToggles()
{
foreach (var tab in tabs.Tabs)
{
if (views.TryGetValue(tab, out var view))
SaveViewToggles(view);
}
}
private void UpdateViewToggles()
{
var options = ActiveView?.PlateView.Options;
foreach (var (item, value) in new (MenuItem, Func<PlateViewOptions, bool>)[]
{
(DrawRapidsMenuItem, o => o.DrawRapid),
(DrawPiercePointsMenuItem, o => o.DrawPiercePoints),
(DrawBoundsMenuItem, o => o.DrawBounds),
(DrawOffsetMenuItem, o => o.DrawOffset),
(DrawCutDirectionMenuItem, o => o.DrawCutDirection),
(DrawSupportMenuItem, o => o.DrawSupport),
})
{
item.IsEnabled = options != null;
item.IsChecked = options != null && value(options);
}
}
private void Toggle(Action<PlateViewOptions> toggle)
{
if (ActiveView is { } view)
{
toggle(view.PlateView.Options);
view.PlateView.InvalidateVisual();
}
UpdateViewToggles();
}
private void OnToggleDrawRapids(object sender, RoutedEventArgs e) => Toggle(o => o.DrawRapid = !o.DrawRapid);
private void OnToggleDrawPiercePoints(object sender, RoutedEventArgs e) =>
Toggle(o => o.DrawPiercePoints = !o.DrawPiercePoints);
private void OnToggleDrawBounds(object sender, RoutedEventArgs e) => Toggle(o => o.DrawBounds = !o.DrawBounds);
private void OnToggleDrawOffset(object sender, RoutedEventArgs e) => Toggle(o => o.DrawOffset = !o.DrawOffset);
private void OnToggleDrawCutDirection(object sender, RoutedEventArgs e) =>
Toggle(o => o.DrawCutDirection = !o.DrawCutDirection);
private void OnToggleDrawSupport(object sender, RoutedEventArgs e) =>
Toggle(o => o.DrawSupport = !o.DrawSupport);
}
}
+101
View File
@@ -0,0 +1,101 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OpenNest.Avalonia.MainWindow"
Title="OpenNest (Avalonia prototype)"
Width="1400" Height="860">
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem x:Name="NewMenuItem" Header="_New" InputGesture="Ctrl+N" Click="OnNew" />
<MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
<Separator />
<MenuItem x:Name="SaveMenuItem" Header="_Save" InputGesture="Ctrl+S" Click="OnSave" />
<MenuItem x:Name="SaveAsMenuItem" Header="Save _As..." Click="OnSaveAs" />
<MenuItem x:Name="ExportNestMenuItem" Header="Save _Copy as .nest File..." Click="OnExportNest" />
<Separator />
<MenuItem x:Name="ExportPlateMenuItem" Header="_Export Plate..." Click="OnExportPlate" />
<MenuItem x:Name="ExportAllPlatesMenuItem" Header="Export All _Plates..." Click="OnExportAllPlates" />
<Separator />
<MenuItem x:Name="ViewNestReportMenuItem" Header="Nest _Report..." Click="OnViewNestReport" />
<Separator />
<MenuItem x:Name="CloseMenuItem" Header="_Close" Click="OnCloseNest" />
<MenuItem x:Name="CloseAllMenuItem" Header="Close A_ll" Click="OnCloseAll" />
<Separator />
<MenuItem Header="E_xit" InputGesture="Ctrl+Q" Click="OnExit" />
</MenuItem>
<MenuItem x:Name="EditMenu" Header="_Edit" SubmenuOpened="OnEditMenuOpened">
<!-- Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z are routed in MainWindow.Undo.cs, so text boxes keep theirs. -->
<MenuItem x:Name="UndoMenuItem" Header="_Undo" InputGesture="Ctrl+Z" Click="OnUndo" />
<MenuItem x:Name="RedoMenuItem" Header="_Redo" InputGesture="Ctrl+Y" Click="OnRedo" />
<Separator />
<MenuItem x:Name="SelectAllMenuItem" Header="Select _All" InputGesture="Ctrl+A" Click="OnSelectAll" />
</MenuItem>
<MenuItem Header="_Nest">
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
</MenuItem>
<MenuItem Header="_Plate">
<MenuItem x:Name="ViewInCadMenuItem" Header="View in _CAD" Click="OnViewInCad" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem x:Name="DrawRapidsMenuItem" Header="Draw _Rapids" ToggleType="CheckBox"
Click="OnToggleDrawRapids" />
<MenuItem x:Name="DrawPiercePointsMenuItem" Header="Draw _Pierce Points" ToggleType="CheckBox"
Click="OnToggleDrawPiercePoints" />
<MenuItem x:Name="DrawBoundsMenuItem" Header="Draw _Bounds" ToggleType="CheckBox"
Click="OnToggleDrawBounds" />
<MenuItem x:Name="DrawOffsetMenuItem" Header="Draw _Offset" ToggleType="CheckBox"
Click="OnToggleDrawOffset" />
<MenuItem x:Name="DrawCutDirectionMenuItem" Header="Draw _Cut Direction" ToggleType="CheckBox"
Click="OnToggleDrawCutDirection" />
<MenuItem x:Name="DrawSupportMenuItem" Header="Draw _Support" ToggleType="CheckBox"
Click="OnToggleDrawSupport" />
<Separator />
<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" />
<Separator />
<MenuItem Header="Next N_est" InputGesture="Ctrl+Tab" Click="OnNextNest" />
<MenuItem Header="Previous Ne_st" InputGesture="Ctrl+Shift+Tab" Click="OnPreviousNest" />
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem x:Name="OptionsMenuItem" Header="_Options..." Click="OnOptions" />
<Separator />
<MenuItem x:Name="SetOffsetIncrementMenuItem" Header="Set Offset Increment" InputGesture="F5" Click="OnSetOffsetIncrement" />
<MenuItem x:Name="SetRotationIncrementMenuItem" Header="Set Rotation Increment" InputGesture="F6" Click="OnSetRotationIncrement" />
</MenuItem>
</Menu>
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button x:Name="NewButton" Content="New" Click="OnNew" />
<Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
<Button x:Name="SaveButton" Content="Save" Click="OnSave" />
<Button x:Name="SaveAsButton" Content="Save As" Click="OnSaveAs" />
<Button Content="Fit" Click="OnFit" />
<Button Content="Previous" Click="OnPreviousPlate" />
<Button Content="Next" Click="OnNextPlate" />
<Button x:Name="AutoNestButton" Content="Auto Nest" Click="OnAutoNest" />
</StackPanel>
</Border>
<Border DockPanel.Dock="Bottom" Padding="6,3" Background="#F0F0F0">
<TextBlock x:Name="Status" Text="Create a new nest or open a .nest file to begin." />
</Border>
<!-- One tab per open nest, along the bottom of the window. -->
<Border DockPanel.Dock="Bottom" Background="#E4E7EA">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="TabStrip" Orientation="Horizontal" Margin="6,0,6,6" />
</ScrollViewer>
</Border>
<Panel>
<!-- The empty workspace, as in the WinForms app: the Windows AppWorkspace gray, with the OpenNest logo centered. -->
<Panel x:Name="EmptyHint" Background="#ABABAB">
<Image Source="avares://OpenNest.Avalonia/Assets/watermark.png" Stretch="None"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Panel>
<ContentControl x:Name="NestHost" />
</Panel>
</DockPanel>
</Window>
+951
View File
@@ -0,0 +1,951 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using OpenNest.Desktop;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Forms;
using OpenNest.IO;
namespace OpenNest.Avalonia
{
public partial class MainWindow : Window
{
private static readonly IBrush Accent = Brush.Parse("#3368A0");
private static readonly IBrush Muted = Brush.Parse("#6B7280");
private static readonly IBrush InactiveTab = Brush.Parse("#DDE1E5");
private readonly NestTabs tabs = new();
private readonly Dictionary<NestTab, NestView> views = new();
private string notice;
private CancellationTokenSource nesting;
private NestTab nestingTab;
private Task autoNest = Task.CompletedTask;
private bool closeConfirmed;
// A File > New waiting on the machines or the nest number.
private bool creatingNest;
private bool snapshotMode;
// A save or export waiting on its file picker or write; another one waits for it.
private bool writingFile;
// True while Exit asks about unsaved nests, so a second close request is ignored.
private bool askingToExit;
// Load and start-up warnings shown one dialog at a time, once the window is open.
private readonly Queue<(string Title, string Message)> pendingWarnings = new();
private bool showingWarnings;
private Task warningsShown = Task.CompletedTask;
// The window is closing (its close went ahead) or closed.
private bool closed;
public MainWindow()
{
InitializeComponent();
// As the desktop app: plug-in engines first, then the saved engine selection.
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
var engineWarning = EngineSelection.LoadSavedSelection();
if (!string.IsNullOrEmpty(engineWarning))
notice = engineWarning;
// Post processors from Posts/ (their materials registered), then the machines,
// reported in the status line as the WinForms app reports them in dialogs.
if (DesktopSettings.LoadPosts(Path.Combine(AppContext.BaseDirectory, "Posts")) is { } postWarning)
notice = notice == null ? postWarning : $"{notice} {postWarning}";
MachinesLoaded = LoadMachinesAsync();
// The colour scheme and fill strategies chosen in Tools > Options (either app's), from
// the shared user settings; applying a scheme later recolours the open nests.
var userSettings = Data.UserSettingsFile.Read(Data.UserSettingsFile.DefaultPath);
ColorSchemeRegistry.ApplySaved(userSettings);
FillStrategyOptions.ApplySaved(userSettings);
ColorSchemeRegistry.Applied += OnColorSchemeApplied;
Closed += (_, _) => ColorSchemeRegistry.Applied -= OnColorSchemeApplied;
Closed += (_, _) => closed = true;
tabs.Changed += (_, _) => ShowActiveTab();
KeyBindings.Add(new KeyBinding
{
Gesture = new KeyGesture(Key.N, KeyModifiers.Control),
Command = new Command(() => OnNew(null, null)),
});
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.S, KeyModifiers.Control),
Command = new Command(() => OnSave(null, null)),
});
KeyBindings.Add(new KeyBinding
{
Gesture = new KeyGesture(Key.Q, KeyModifiers.Control),
Command = new Command(() => OnExit(null, null)),
});
KeyBindings.Add(new KeyBinding
{
Gesture = new KeyGesture(Key.A, KeyModifiers.Control),
Command = new Command(() => OnSelectAll(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)) });
KeyBindings.Add(new KeyBinding
{
Gesture = new KeyGesture(Key.F5),
Command = new Command(() => OnSetOffsetIncrement(null, null)),
});
KeyBindings.Add(new KeyBinding
{
Gesture = new KeyGesture(Key.F6),
Command = new Command(() => OnSetRotationIncrement(null, null)),
});
// Ctrl+Tab before focus navigation takes the Tab key.
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
// .nest files dropped anywhere on the window, the nest view included, open in tabs.
DragDrop.SetAllowDrop(this, true);
AddHandler(DragDrop.DragEnterEvent, OnDragOver);
AddHandler(DragDrop.DragOverEvent, OnDragOver);
AddHandler(DragDrop.DropEvent, OnDrop);
Closing += OnWindowClosing;
#if DEBUG
StartUnrecordedChangeCheck();
#endif
Opened += (_, _) =>
{
if (!snapshotMode)
_ = ShowLoadWarningsAsync();
};
ShowActiveTab();
}
// Null while a new tab's view is still being created.
private NestView ActiveView =>
tabs.Active != null && views.TryGetValue(tabs.Active, out var view) ? view : null;
/// <summary>Opens the nest in a new tab, or switches to its tab if open.</summary>
public void OpenNest(string path)
{
try
{
IReadOnlyList<string> warnings = Array.Empty<string>();
var tab = tabs.Open(path, file =>
{
var reader = new NestReader(file);
var document = new Document { Nest = reader.Read() };
warnings = reader.Warnings;
// The view follows the application's scheme, not the one the file was saved under.
ColorSchemeRegistry.RecolorDrawings(document.Nest);
return document;
});
if (!views.ContainsKey(tab))
AddView(tab);
if (warnings.Count > 0)
{
var count = warnings.Count == 1
? "1 load warning"
: $"{warnings.Count} load warnings";
ShowNotice($"{tab.Name}: {count}");
pendingWarnings.Enqueue(("Nest Load Warnings",
$"{tab.Name}:\n\n" + string.Join(Environment.NewLine, warnings)));
if (IsVisible && !snapshotMode)
_ = ShowLoadWarningsAsync();
}
}
catch (Exception ex)
{
ShowNotice($"Could not open {Path.GetFileName(path)}: {ex.Message}");
}
}
/// <summary>
/// Opens a new nest from the nest defaults, the default machine with its clamps and
/// support grid, and the year's next name, in a new tab, as File > New does in WinForms.
/// </summary>
public async Task NewNestAsync()
{
// One at a time, so two nests never take the same number.
if (creatingNest)
return;
creatingNest = true;
NewNest created;
try
{
// The machines finish loading first; reading them and the nest number may wait
// on the nest server, so neither runs on the UI thread.
await MachinesLoaded;
created = await Task.Run(() => DesktopSettings.NewNests.CreateAsync());
}
catch (Exception ex)
{
ShowNotice($"Could not create a new nest: {ex.Message}");
return;
}
finally
{
creatingNest = false;
}
AddView(tabs.Add(new Document { Nest = created.Nest }));
if (created.Warning != null)
{
// Through the queue, so it never stacks on a load warning already showing.
pendingWarnings.Enqueue(("Nest Defaults", created.Warning));
await ShowLoadWarningsAsync();
}
}
private void AddView(NestTab tab)
{
var view = new NestView(tab);
RestoreViewToggles(view);
view.ViewChanged += (_, _) => { if (view == ActiveView) UpdateStatus(); };
views[tab] = view;
WatchHistory(tab);
notice = null;
ShowActiveTab();
}
/// <summary>
/// Restores the window's size, position and maximized state from the user settings the
/// WinForms app shares, and saves them on close. Not used for --snapshot, whose image
/// must not depend on the user's settings.
/// </summary>
public void RememberPlacement() =>
WindowPlacementBinding.Remember(
this, WindowPlacement.MainWindow, Data.UserSettingsFile.DefaultPath);
/// <summary>
/// Once the window opens, converts a legacy nest template into the nest defaults and
/// opens a new nest when Tools > Options asks for one, as the WinForms main window does
/// on load. No new nest beside nests already open (from the command line, or opened or
/// dropped while the template converted). Not used for --snapshot, which must neither
/// change settings nor take a nest number.
/// </summary>
public void RunStartupOnOpen() => Opened += async (_, _) => await RunStartupAsync();
// The window is closing, or asking about unsaved nests before exit: start-up stops.
private bool StartupInterrupted => closed || closeConfirmed || askingToExit;
private async Task RunStartupAsync()
{
DesktopStartupResult startup;
try
{
// Reads the template's nest and writes the defaults file: off the UI thread.
startup = await Task.Run(() => DesktopStartup.Run(
Data.UserSettingsFile.DefaultPath, Data.NestDefaults.DefaultPath,
Engine.BestFit.BestFitCacheStore.Shared));
}
catch (Exception ex)
{
ShowNotice(
$"Start-up could not finish: {ex.Message} Use File > New to start a nest.");
return;
}
// On the UI thread, as the window's other user-settings writers are.
if (startup.ClearTemplateSetting)
DesktopStartup.ClearTemplateSetting(Data.UserSettingsFile.DefaultPath);
if (StartupInterrupted)
return;
if (startup.TemplateWarning != null)
{
pendingWarnings.Enqueue(("Nest Template", startup.TemplateWarning));
// Waits for every queued warning, including ones already showing.
await ShowLoadWarningsAsync();
}
if (!startup.CreateNewNest)
return;
// The machines load first, so the check below is close to the nest-number request.
await MachinesLoaded;
// Closed meanwhile (a window closed in code takes its dialogs with it), or nests
// opened from the command line, or opened or dropped meanwhile: no new nest.
if (StartupInterrupted || tabs.Tabs.Count > 0)
return;
await NewNestAsync();
}
/// <summary>Runs Auto Nest once the window opens, for --auto-nest.</summary>
public void AutoNestOnOpen() => Opened += (_, _) => autoNest = RunAutoNestAsync();
public void SaveSnapshotAndClose(string path, int plateNumber)
{
// No dialogs: the image is taken and the window closes unattended.
snapshotMode = true;
UseDefaultViewToggles();
Opened += async (_, _) =>
{
ActiveView?.ShowPlate(plateNumber - 1);
await autoNest;
await Task.Delay(500);
var size = new PixelSize((int)Bounds.Width, (int)Bounds.Height);
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
bitmap.Render(this);
bitmap.Save(path);
// The status line beside the image, so headless checks can read it.
File.WriteAllText(path + ".txt", Status.Text);
closeConfirmed = true;
Close();
};
}
private void ShowActiveTab()
{
var view = ActiveView;
NestHost.Content = view;
EmptyHint.IsVisible = view == null;
CloseMenuItem.IsEnabled = view != null;
SaveMenuItem.IsEnabled = view != null;
SaveAsMenuItem.IsEnabled = view != null;
ExportNestMenuItem.IsEnabled = view != null;
SaveButton.IsEnabled = view != null;
SaveAsButton.IsEnabled = view != null;
CloseAllMenuItem.IsEnabled = view != null;
SetOffsetIncrementMenuItem.IsEnabled = view != null;
SetRotationIncrementMenuItem.IsEnabled = view != null;
UpdateExportCommands(view != null);
Title = view == null
? "OpenNest (Avalonia prototype)"
: $"{view.Tab.Name} - OpenNest (Avalonia prototype)";
RebuildTabStrip();
UpdateViewToggles();
// The Edit menu follows the active nest's history.
UpdateUndoMenu();
UpdateStatus();
}
private void RebuildTabStrip()
{
TabStrip.Children.Clear();
for (var i = 0; i < tabs.Tabs.Count; i++)
TabStrip.Children.Add(TabButton(tabs.Tabs[i], i == tabs.ActiveIndex));
var add = new Button
{
Content = "+",
FontSize = 15,
Padding = new Thickness(10, 2),
Margin = new Thickness(4, 4, 0, 0),
Background = Brushes.Transparent,
VerticalAlignment = VerticalAlignment.Top,
};
ToolTip.SetTip(add, "New nest");
add.Click += OnNew;
TabStrip.Children.Add(add);
}
private Control TabButton(NestTab tab, bool active)
{
var name = new TextBlock
{
Text = tab.Modified ? tab.Name + " \u2022" : tab.Name,
FontSize = 13,
FontWeight = active ? FontWeight.SemiBold : FontWeight.Normal,
Foreground = active ? Brushes.Black : Muted,
VerticalAlignment = VerticalAlignment.Center,
};
var close = new Button
{
Content = "\u2715",
FontSize = 10,
Padding = new Thickness(5, 1),
Background = Brushes.Transparent,
Foreground = Muted,
VerticalAlignment = VerticalAlignment.Center,
};
ToolTip.SetTip(close, "Close");
close.Click += async (_, e) =>
{
e.Handled = true;
await CloseTabAsync(tab);
};
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
row.Children.Add(name);
row.Children.Add(close);
var border = new Border
{
Child = row,
Padding = new Thickness(14, 5, 6, 5),
Margin = new Thickness(0, 0, 2, 0),
CornerRadius = new CornerRadius(0, 0, 6, 6),
Background = active ? Brushes.White : InactiveTab,
BorderBrush = active ? Accent : Brushes.Transparent,
BorderThickness = new Thickness(0, 0, 0, 2),
Cursor = new Cursor(StandardCursorType.Hand),
};
ToolTip.SetTip(border, tab.Path);
border.PointerPressed += (_, e) =>
{
if (e.GetCurrentPoint(border).Properties.IsLeftButtonPressed)
tabs.Activate(tabs.Tabs.ToList().IndexOf(tab));
};
return border;
}
private async Task<bool> CloseTabAsync(NestTab tab)
{
if (tab == nestingTab)
{
ShowNotice($"Auto Nest is running on {tab.Name}. Stop it before closing the nest.");
return false;
}
if (writingFile)
{
ShowNotice("A nest is being saved or exported. Close the nest once that finishes.");
return false;
}
if (!await ConfirmUnsavedAsync(tab))
return false;
UnwatchHistory(tab);
tabs.Close(tab);
if (views.Remove(tab, out var closed))
SaveViewToggles(closed);
return true;
}
private async void OnCloseNest(object sender, RoutedEventArgs e)
{
if (tabs.Active != null)
await CloseTabAsync(tabs.Active);
}
private async void OnCloseAll(object sender, RoutedEventArgs e)
{
foreach (var tab in tabs.Tabs.Reverse().ToList())
{
if (!await CloseTabAsync(tab))
return;
}
}
private async void OnWindowClosing(object sender, WindowClosingEventArgs e)
{
if (closeConfirmed || (!tabs.AnyModified && !writingFile))
{
// Set before Avalonia closes the owned dialogs, whose awaiters then resume.
closed = true;
nesting?.Cancel();
SaveAllViewToggles();
return;
}
e.Cancel = true;
if (askingToExit)
return;
if (writingFile)
{
ShowNotice("A nest is being saved or exported. Exit again once that finishes.");
return;
}
// As closing each WinForms nest window: ask about every unsaved nest in turn,
// showing it; Cancel, or a save that does not happen, keeps the app open. The list
// is read again after each answer: Auto Nest can finish while a question is open.
askingToExit = true;
try
{
var answered = new HashSet<NestTab>();
while (tabs.Tabs.FirstOrDefault(t => t.Modified && !answered.Contains(t))
is { } tab)
{
tabs.Activate(tabs.Tabs.ToList().IndexOf(tab));
if (tab == nestingTab)
{
await ConfirmDialog.ShowAsync(this, "Exit OpenNest",
$"Auto Nest is running on {tab.Name}, which has unsaved changes. "
+ "Stop Auto Nest (Nest > Stop Auto Nest) or let it finish, then "
+ "exit again.");
return;
}
if (!await ConfirmUnsavedAsync(tab))
return;
answered.Add(tab);
}
}
finally
{
askingToExit = false;
}
closeConfirmed = true;
Close();
}
/// <summary>
/// For a nest with unsaved changes, asks Save / Don't Save / Cancel. True when it may
/// close: saved, or the user chose not to save.
/// </summary>
private async Task<bool> ConfirmUnsavedAsync(NestTab tab)
{
if (!tab.Modified)
return true;
var choice = await ConfirmDialog.ChooseAsync(this, "Unsaved Changes",
$"Save changes to '{tab.Name}' before closing?", "Save", "Don't Save");
return choice switch
{
0 => await SaveAsync(tab, askForFile: false),
1 => true,
_ => false,
};
}
private async void OnSave(object sender, RoutedEventArgs e)
{
if (tabs.Active != null)
await SaveAsync(tabs.Active, askForFile: false);
}
private async void OnSaveAs(object sender, RoutedEventArgs e)
{
if (tabs.Active != null)
await SaveAsync(tabs.Active, askForFile: true);
}
/// <summary>
/// Saves the nest to the file it was saved to this session, else (or for Save As) to a
/// file the user picks. True when it was saved.
/// </summary>
private async Task<bool> SaveAsync(NestTab tab, bool askForFile)
{
if (!CanWrite(tab, "saving"))
return false;
writingFile = true;
try
{
var path = askForFile ? null : tab.SavePath;
path ??= await PickNestFileAsync(tab, "Save Nest As");
// Auto Nest may have started on it while the file picker was open.
if (path == null || IsNesting(tab, "saving"))
return false;
try
{
tabs.SaveAs(tab, path);
}
catch (SaveTargetRefusedException ex)
{
await ConfirmDialog.ShowAsync(
this, "Save Nest", $"The nest was not saved. {ex.Message}");
return false;
}
catch (Exception ex)
{
await ConfirmDialog.ShowAsync(this, "Save Nest",
"The nest was not saved. Any copy already at that path was left as it "
+ $"was.\n\n{path}\n\n{ex.Message}\n\nChoose File > Save As to save "
+ "it to another folder or name.");
return false;
}
ShowNotice($"Saved {tab.Name} to {path}");
return true;
}
finally
{
writingFile = false;
}
}
private async void OnExportNest(object sender, RoutedEventArgs e)
{
var tab = tabs.Active;
if (tab == null || !CanWrite(tab, "exporting"))
return;
writingFile = true;
try
{
var path = await PickNestFileAsync(tab, "Save Copy as .nest File");
if (path == null || IsNesting(tab, "exporting"))
return;
try
{
// Export never changes the nest's file, name or unsaved mark.
tabs.ExportCopy(tab, path);
ShowNotice($"Saved a copy to {path}");
}
catch (SaveTargetRefusedException ex)
{
await ConfirmDialog.ShowAsync(
this, "Save Copy", $"The copy was not saved. {ex.Message}");
}
catch (Exception ex)
{
await ConfirmDialog.ShowAsync(this, "Save Copy",
$"The copy was not saved.\n\n{path}\n\n{ex.Message}\n\nChoose "
+ "File > Save Copy as .nest File again to pick another folder or name.");
}
}
finally
{
writingFile = false;
}
}
// Whether the tab's nest can be written now: not while Auto Nest works on it, and one
// save or export at a time.
private bool CanWrite(NestTab tab, string doing)
{
if (IsNesting(tab, doing))
return false;
if (writingFile)
{
ShowNotice("A nest is already being saved or exported. "
+ $"Finish that before {doing}.");
return false;
}
return true;
}
private bool IsNesting(NestTab tab, string doing)
{
if (tab != nestingTab)
return false;
ShowNotice($"Auto Nest is running on {tab.Name}. "
+ $"Stop it or let it finish before {doing}.");
return true;
}
// A .nest file to write, named after the nest, starting in the folder of its file.
private async Task<string> PickNestFileAsync(NestTab tab, string title)
{
var folder = tab.Path != null ? Path.GetDirectoryName(tab.Path) : null;
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = title,
SuggestedFileName = tab.Name,
DefaultExtension = "nest",
ShowOverwritePrompt = true,
FileTypeChoices = new[] { NestFiles },
SuggestedStartLocation = folder != null
? await StorageProvider.TryGetFolderFromPathAsync(folder)
: null,
});
return file?.TryGetLocalPath();
}
private static readonly FilePickerFileType NestFiles =
new("Nest files") { Patterns = new[] { "*.nest" } };
/// <summary>Shows the queued warnings one at a time; finishes when the queue is empty,
/// also when called while they are already showing.</summary>
private Task ShowLoadWarningsAsync()
{
if (!showingWarnings)
warningsShown = ShowQueuedWarningsAsync();
return warningsShown;
}
private async Task ShowQueuedWarningsAsync()
{
showingWarnings = true;
try
{
// None once the window has closed: a dialog needs a live owner.
while (!closed && pendingWarnings.TryDequeue(out var item))
await ConfirmDialog.ShowAsync(this, item.Title, item.Message);
}
finally
{
showingWarnings = false;
}
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (ProcessUndoKeys(e))
{
e.Handled = true;
return;
}
if (e.Key == Key.Tab && e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
StepNest(e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? -1 : 1);
e.Handled = true;
}
}
private void OnNextNest(object sender, RoutedEventArgs e) => StepNest(1);
private void OnPreviousNest(object sender, RoutedEventArgs e) => StepNest(-1);
private void StepNest(int step)
{
var count = tabs.Tabs.Count;
if (count > 1)
tabs.Activate(((tabs.ActiveIndex + step) % count + count) % count);
}
private async void OnNew(object sender, RoutedEventArgs e) => await NewNestAsync();
private async void OnOpen(object sender, RoutedEventArgs e)
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open Nest",
AllowMultiple = true,
FileTypeFilter = new[] { NestFiles },
});
foreach (var path in files.Select(f => f.TryGetLocalPath()).Where(p => p != null))
OpenNest(path);
}
// As the WinForms main window: a drop is taken only when it holds a .nest file.
private void OnDragOver(object sender, DragEventArgs e)
{
e.DragEffects = DroppedNestFiles.Any(DroppedPaths(e))
? DragDropEffects.Copy
: DragDropEffects.None;
e.Handled = true;
}
// Each dropped .nest file opens in a tab (or shows its tab); other files are ignored.
// As File > Open, this works while Auto Nest runs.
private void OnDrop(object sender, DragEventArgs e)
{
var paths = DroppedNestFiles.Select(DroppedPaths(e));
e.DragEffects = paths.Count > 0 ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
foreach (var path in paths)
OpenNest(path);
}
private static IEnumerable<string> DroppedPaths(DragEventArgs e) =>
e.Data.GetFiles()?.Select(file => file.TryGetLocalPath())
?? Enumerable.Empty<string>();
private void OnExit(object sender, RoutedEventArgs e) => Close();
private void OnAutoNest(object sender, RoutedEventArgs e)
{
// Stop always cancels and discards the whole proposal.
if (nesting != null)
{
nesting.Cancel();
return;
}
autoNest = RunAutoNestAsync();
}
/// <summary>
/// Nests every drawing's remaining quantity of the active nest on sheets like its shown
/// plate, through the shared pipeline off the UI thread. Other nests can be opened and
/// viewed meanwhile; the nest being nested cannot be closed until it finishes.
/// </summary>
private async Task RunAutoNestAsync()
{
var view = ActiveView;
if (view == null || nesting != null)
return;
var tab = view.Tab;
var nest = tab.Document.Nest;
var engineName = EngineSelection.EngineName;
var template = view.PlateView.Plate
?? nest.Plates.FirstOrDefault()
?? nest.PlateDefaults.CreateNew();
var request = AutoNestJob.ForRemaining(nest, template, engineName);
if (request == null)
{
ShowNotice("Auto Nest: nothing has a remaining quantity to nest.");
return;
}
nesting = new CancellationTokenSource();
nestingTab = tab;
// As WinForms behind its modal progress window: the nest's plate takes no edits
// while Auto Nest works on it.
var lockedView = views.GetValueOrDefault(tab);
if (lockedView != null)
lockedView.PlateView.EditsLocked = true;
var token = nesting.Token;
SetNesting(true);
ShowNotice($"{engineName}: starting...");
var progress = new Progress<NestProgress>(p =>
{
if (!token.IsCancellationRequested)
ShowNotice(p.Description ?? $"{engineName}: plate {p.PlateNumber}");
});
try
{
var jobProgress = JobEngineNest.CreateProgress(engineName, progress);
var result = await Task.Run(() => NestPipeline.Run(request, jobProgress, token));
// Engines that ignore cancellation still have their proposal discarded.
token.ThrowIfCancellationRequested();
var firstNew = nest.Plates.Count(p => p.Parts.Count > 0);
// One undo step for the whole result, as in the WinForms app; the history change
// updates the tab's unsaved mark.
NestRenestResult kept;
using (var change = tab.Undo.BeginChange("Auto Nest"))
{
try
{
kept = AutoNestJob.Keep(nest, result);
}
catch
{
// A commit that failed part way is rolled back.
change.MarkChanged();
throw;
}
if (kept.Nested)
{
change.MarkChanged();
view.Refresh(firstNew);
}
change.Complete();
}
ShowNotice(kept.Nested
? $"{tab.Name}: {engineName}: {kept.Note}"
: AutoNestJob.RefusalReason(result));
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
ShowNotice("Auto Nest stopped; nothing was changed.");
}
catch (Exception ex)
{
ShowNotice($"Auto Nest failed: {ex.Message}");
}
finally
{
nesting.Dispose();
nesting = null;
nestingTab = null;
if (lockedView != null)
lockedView.PlateView.EditsLocked = false;
SetNesting(false);
}
}
private void SetNesting(bool running)
{
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
UpdateUndoMenu();
}
/// <summary>Completes once the machines are read (see <see cref="DesktopSettings.LoadMachinesAsync"/>).</summary>
internal Task MachinesLoaded { get; }
private async Task LoadMachinesAsync()
{
string warning;
try
{
warning = await DesktopSettings.LoadMachinesAsync();
}
catch (Exception ex)
{
warning = $"The machines could not be read: {ex.Message}";
}
if (warning != null)
ShowNotice(warning);
}
private void ShowNotice(string text)
{
notice = text;
UpdateStatus();
}
private void OnFit(object sender, RoutedEventArgs e) => ActiveView?.PlateView.ZoomToFit();
private void OnSelectAll(object sender, RoutedEventArgs e) => ActiveView?.PlateView.SelectAll();
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
private void StepPlate(int step) => ActiveView?.StepPlate(step);
// Tools > Set Offset Increment (F5), as WinForms: how far an arrow key nudges the active
// nest's selection, or the calculated step (selection size plus part spacing). Each nest
// keeps its own increments for the session, as each WinForms nest window does.
private async void OnSetOffsetIncrement(object sender, RoutedEventArgs e)
{
if (ActiveView is not { } view)
return;
var edits = view.PlateView.Edits;
var result = await SetValueDialog.ShowAsync(this, "Set Offset Increment",
edits.OffsetIncrement,
optionText: "Calculated distance (selection size + part spacing)",
optionChecked: edits.CalculatedOffset);
if (result == null)
return;
edits.OffsetIncrement = result.Value;
edits.CalculatedOffset = result.OptionChecked;
}
// Tools > Set Rotation Increment (F6): the Shift+wheel turn, at most 360 degrees.
private async void OnSetRotationIncrement(object sender, RoutedEventArgs e)
{
if (ActiveView is not { } view)
return;
var edits = view.PlateView.Edits;
var result = await SetValueDialog.ShowAsync(this, "Set Rotation Increment",
edits.RotateIncrementDegrees, maximum: 360);
if (result == null)
return;
edits.RotateIncrementDegrees = result.Value;
}
// Tools > Options. Settings for features the app does not have yet (Database mode, the
// update feed) are still saved, for the WinForms app and later milestones.
private async void OnOptions(object sender, RoutedEventArgs e) =>
await OptionsDialog.ShowAsync(this, Data.UserSettingsFile.DefaultPath);
// As the WinForms nest windows: every open nest takes the new scheme's drawing colours.
private void OnColorSchemeApplied(object sender, ColorScheme scheme)
{
foreach (var (tab, view) in views)
{
ColorSchemeRegistry.RecolorDrawings(tab.Document.Nest);
view.PlateView.InvalidateVisual();
}
}
private void UpdateStatus()
{
var view = ActiveView;
var canvas = view?.PlateView;
if (canvas?.Plate == null)
{
var empty = view == null
? "Create a new nest or open a .nest file to begin."
: $"{view.Tab.Name} No plates";
Status.Text = notice ?? empty;
return;
}
// The active tool's name and hint first, as the WinForms status label shows them.
var text = $"{canvas.Tools.Status} {view.Tab.Name} Plate {view.PlateIndex + 1} of {view.PlateCount}"
+ $" Zoom {canvas.Zoom:0.##} px/unit";
if (canvas.CursorWorld is { } at)
text += $" X {at.X:0.000} Y {at.Y:0.000}";
var selected = canvas.Selection.Parts.Where(canvas.Plate.Parts.Contains).ToList();
if (selected.Count > 0)
text += " " + Desktop.Interaction.SelectionStatus.Text(selected);
if (canvas.HoveredPart is { } part)
text += $" Over: {part.BaseDrawing.Name}";
if (notice != null)
text += $" | {notice}";
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();
}
}
}
+41
View File
@@ -0,0 +1,41 @@
<UserControl 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.NestView">
<Grid ColumnDefinitions="290,4,*">
<Border Grid.Column="0" Background="White">
<TabControl x:Name="SideTabs" Padding="0">
<TabItem Header="Plates" FontSize="14" MinHeight="34">
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
</TabItem>
<TabItem Header="Drawings" FontSize="14" MinHeight="34">
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
FontSize="13" AutoGenerateColumns="False"
GridLinesVisibility="Horizontal" CanUserSortColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="Nested" Binding="{Binding Nested}" />
<DataGridTextColumn Header="Required" Binding="{Binding Required}" />
</DataGrid.Columns>
</DataGrid>
</TabItem>
</TabControl>
</Border>
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="#D0D4D8" />
<DockPanel Grid.Column="2">
<Border DockPanel.Dock="Top" Padding="8,4" Background="White"
BorderBrush="#D0D4D8" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Content="&#x23EE;" ToolTip.Tip="First plate" Click="OnFirstPlate" />
<Button Content="&#x25C0;" ToolTip.Tip="Previous plate (Page Up)"
Click="OnPreviousPlate" />
<TextBlock x:Name="PlateLabel" VerticalAlignment="Center" FontSize="13" Margin="6,0" />
<Button Content="&#x25B6;" ToolTip.Tip="Next plate (Page Down)" Click="OnNextPlate" />
<Button Content="&#x23ED;" ToolTip.Tip="Last plate" Click="OnLastPlate" />
</StackPanel>
</Border>
<c:PlateCanvas x:Name="Canvas" />
</DockPanel>
</Grid>
</UserControl>
+192
View File
@@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using OpenNest.Avalonia.Controls;
using OpenNest.Desktop;
namespace OpenNest.Avalonia
{
public sealed record DrawingRow(string Name, int Nested, int Required);
/// <summary>
/// One open nest: Plates and Drawings tabs on the left, the plate header and plate view on
/// the right. Each tab keeps its own view, so switching nests keeps each one's plate and zoom.
/// </summary>
public partial class NestView : UserControl
{
public NestView()
{
InitializeComponent();
}
public NestView(NestTab tab)
: this()
{
Tab = tab;
Canvas.ViewChanged += (_, _) => ViewChanged?.Invoke(this, EventArgs.Empty);
Canvas.BeginChangeHandler = (label, key) => tab.Undo.BeginChange(label, key);
tab.Undo.SwapIn = SwapIn;
// Deleting parts changes the plate and drawing counts the lists show.
tab.Undo.Changed += (_, _) => RefreshCounts();
Canvas.CutOffSettings = CutOffSettingsFor(tab.Document.Nest);
plates = new PlateManager(tab.Document.Nest);
// Starts watching the last two plates (the tab already put the empty plate there).
plates.EnsureSentinel();
Refresh(0);
}
// Undo and Redo: puts the copy read back from the history in the document and shows it
// on the same plate, keeping the zoom, pan and selection (by part index while the part
// count is unchanged). Every list is rebuilt: each held the old nest's objects.
private void SwapIn(Nest restored)
{
var plateIndex = System.Math.Max(0, PlateList.SelectedIndex);
var shown = Canvas.Plate;
keptSelection = shown != null
? (Canvas.Selection.PartIndices, shown.Parts.Count)
: (Array.Empty<int>(), -1);
// Read first: a throw here leaves the live nest in place, as the recorder expects.
var cutOffSettings = CutOffSettingsFor(restored);
Tab.Document.Nest = restored;
Canvas.CutOffSettings = cutOffSettings;
plates.Dispose();
plates = new PlateManager(restored);
plates.EnsureSentinel();
// Reading recolours drawings from the file; the view keeps the application's scheme.
// Cut-offs stay as reading regenerated them, as when the nest was opened.
ColorSchemeRegistry.RecolorDrawings(restored);
keepView = true;
try
{
Refresh(plateIndex);
}
finally
{
keepView = false;
}
}
// While an Undo or Redo swaps its copy in: the plate is shown without zooming to fit,
// and the parts selected before (their indices, the plate's part count then) are selected again.
private bool keepView;
// Keeps one empty plate at the end of the nest as edits add or remove parts on the last
// two plates, inside the edit's own undo step, as the WinForms nest window's manager does.
private PlateManager plates;
private (IReadOnlyList<int> Indices, int PartCount) keptSelection = (Array.Empty<int>(), -1);
/// <summary>Raised when the shown plate, zoom, hover or selection changes.</summary>
public event EventHandler ViewChanged;
public NestTab Tab { get; }
public PlateCanvas PlateView => Canvas;
public int PlateIndex => PlateList.SelectedIndex;
public int PlateCount => Tab.Document.Nest.Plates.Count;
/// <summary>Rebuilds both lists and shows plate <paramref name="selectIndex"/>.</summary>
public void Refresh(int selectIndex)
{
var nest = Tab.Document.Nest;
// As the WinForms nest window: one empty plate stays at the end to nest onto, so a
// new nest shows Plate 1. Files leave empty plates out, so saving is unchanged. The
// tab put it there on opening and the manager keeps it, so this only reads.
Canvas.ClearCache();
PlateList.ItemsSource = nest.Plates
.Select((plate, i) =>
$"Plate {i + 1} {SizeText(plate)} ({plate.Parts.Count} parts)")
.ToList();
PlateList.SelectedIndex = nest.Plates.Count > 0
? System.Math.Clamp(selectIndex, 0, nest.Plates.Count - 1)
: -1;
DrawingGrid.ItemsSource = nest.Drawings
.Where(d => !d.IsCutOff)
.Select(d => new DrawingRow(d.Name, d.Quantity.Nested, d.Quantity.Required))
.ToList();
shownCounts = CountsText(nest);
ShowPlate();
}
// As the WinForms nest window binding a nest: the saved head-lock defaults in the nest's
// units, then the machine's cut-off defaults for its material and thickness.
private static CutOffs.CutOffSettings CutOffSettingsFor(Nest nest)
{
var settings = new CutOffs.CutOffSettings();
SavedCutOffHeadLock.Read(Data.UserSettingsFile.Read(Data.UserSettingsFile.DefaultPath))
.ApplyTo(settings, nest.Units);
DesktopSettings.Machines.ApplyCutOffDefaults(settings, nest);
return settings;
}
// After an edit: rebuilds the lists only when their counts changed, keeping the shown
// plate, its zoom and the selection, as an Undo does.
private void RefreshCounts()
{
var nest = Tab.Document.Nest;
var counts = CountsText(nest);
if (counts == shownCounts)
return;
var shown = Canvas.Plate;
keptSelection = shown != null
? (Canvas.Selection.PartIndices, shown.Parts.Count)
: (Array.Empty<int>(), -1);
keepView = true;
try
{
Refresh(System.Math.Max(0, PlateList.SelectedIndex));
}
finally
{
keepView = false;
}
}
private string shownCounts;
private static string CountsText(Nest nest) =>
string.Join(",", nest.Plates.Select(p => p.Parts.Count)) + "|"
+ string.Join(",", nest.Drawings.Select(d => d.Quantity.Nested));
public void ShowPlate(int index)
{
if (PlateCount > 0)
PlateList.SelectedIndex = System.Math.Clamp(index, 0, PlateCount - 1);
}
public void StepPlate(int step) => ShowPlate(PlateList.SelectedIndex + step);
private void OnPlateSelected(object sender, SelectionChangedEventArgs e) => ShowPlate();
private void ShowPlate()
{
var nest = Tab.Document.Nest;
var index = PlateList.SelectedIndex;
var plate = index >= 0 && index < nest.Plates.Count ? nest.Plates[index] : null;
if (keepView)
Canvas.ReplacePlate(plate,
plate?.Parts.Count == keptSelection.PartCount ? keptSelection.Indices : Array.Empty<int>());
else
Canvas.Plate = plate;
PlateLabel.Text = plate == null
? "No plates"
: $"Plate {index + 1} of {nest.Plates.Count} "
+ $"{SizeText(plate)} qty {plate.Quantity}";
ViewChanged?.Invoke(this, EventArgs.Empty);
}
private static string SizeText(Plate plate) =>
$"{plate.Size.Length:0.###} x {plate.Size.Width:0.###}";
private void OnFirstPlate(object sender, RoutedEventArgs e) => ShowPlate(0);
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
private void OnLastPlate(object sender, RoutedEventArgs e) => ShowPlate(PlateCount - 1);
}
}
@@ -0,0 +1,44 @@
<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" />
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
</ItemGroup>
<ItemGroup>
<!-- The WinForms app's empty-workspace logo, shared rather than copied. -->
<AvaloniaResource Include="..\OpenNest\Resources\watermark.png" Link="Assets\watermark.png" />
</ItemGroup>
<ItemGroup>
<!-- The posts the WinForms app ships, built with this app and copied to its Posts/ directory. -->
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" IsPost="true" />
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" ReferenceOutputAssembly="false" IsPost="true" />
</ItemGroup>
<!-- As for the WinForms app: each post DLL, and its shipped settings JSON unless one is already
deployed (an edited copy is kept), go to Posts/ beside the app, which loads them at startup. -->
<Target Name="CopyPostsToOutput" AfterTargets="Build">
<MSBuild Projects="@(ProjectReference)" Condition="'%(ProjectReference.IsPost)' == 'true'" Targets="GetTargetPath">
<Output TaskParameter="TargetOutputs" ItemName="PostAssembly" />
</MSBuild>
<ItemGroup>
<PostConfig Include="@(PostAssembly->'%(RootDir)%(Directory)%(Filename).json')" />
<PostConfigToCopy Include="@(PostConfig)" Condition="Exists('%(FullPath)') and !Exists('$(OutDir)Posts\%(Filename)%(Extension)')" />
</ItemGroup>
<MakeDir Directories="$(OutDir)Posts" />
<Copy SourceFiles="@(PostAssembly)" DestinationFolder="$(OutDir)Posts" SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PostConfigToCopy)" DestinationFolder="$(OutDir)Posts" SkipUnchangedFiles="true" />
</Target>
</Project>
+290
View File
@@ -0,0 +1,290 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using OpenNest.Data;
using OpenNest.Desktop;
namespace OpenNest.Avalonia
{
/// <summary>
/// Tools &gt; Options, as the WinForms Options form: Save writes the general options and the
/// fill strategies to the user settings both apps share and applies them; the nest server URL
/// (storage.json) and the update feed URL have their own buttons that save at once. The
/// MaterialLedger connection is not here: its API key is kept with Windows data protection,
/// so it is set in the WinForms app.
/// </summary>
internal sealed class OptionsDialog : Window
{
private static readonly IBrush Muted = Brush.Parse("#6B7280");
private readonly string settingsPath;
private readonly CheckBox createNewNest = new() { Content = "Create a new nest on startup" };
private readonly NumericUpDown autoSizeFactor = new()
{
Minimum = (decimal)DesktopOptions.AutoSizePlateFactorMinimum,
Maximum = (decimal)DesktopOptions.AutoSizePlateFactorMaximum,
Increment = (decimal)DesktopOptions.AutoSizePlateFactorIncrement,
FormatString = "0.####",
Width = 150,
HorizontalAlignment = HorizontalAlignment.Left,
};
private readonly ComboBox colorScheme = new() { Width = 200 };
private readonly NumericUpDown planningEffort = new()
{
Minimum = (decimal)DesktopOptions.CuttingPlanEffortMinimum,
Maximum = (decimal)DesktopOptions.CuttingPlanEffortMaximum,
Increment = (decimal)DesktopOptions.CuttingPlanEffortIncrement,
FormatString = "0.0",
Width = 150,
HorizontalAlignment = HorizontalAlignment.Left,
};
private readonly ComboBox clonePlacement = new() { Width = 200 };
private readonly TextBox serverUrl = new() { Watermark = "http://server:port" };
private readonly TextBox updateFeedUrl = new();
private readonly CheckBox includeDevBuilds = new()
{
Content = "Include development builds (latest master; takes effect after restart)",
};
private readonly List<(CheckBox Enabled, string Name)> strategies = new();
private OptionsDialog(string settingsPath)
{
this.settingsPath = settingsPath;
Title = "Options";
Width = 720;
SizeToContent = SizeToContent.Height;
CanResize = false;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
var general = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
RowDefinitions = new RowDefinitions("Auto,Auto,Auto,Auto,Auto"),
ColumnSpacing = 12,
RowSpacing = 8,
};
AddRow(general, 0, "Auto-size plate factor:", autoSizeFactor,
"The amount to round the plate size up.");
AddRow(general, 1, "Color scheme:", colorScheme, null);
AddRow(general, 2, "Cut planning effort:", planningEffort,
"How long Plan Cutting searches for a route; 1 is normal.");
AddRow(general, 3, "Clone push placement:", clonePlacement,
"Which way Shift+click and Ctrl+click copies are pushed.");
Grid.SetRow(createNewNest, 4);
Grid.SetColumnSpan(createNewNest, 2);
general.Children.Add(createNewNest);
var setServer = new Button { Content = "Set / Change Server" };
setServer.Click += async (_, _) => await SetServerAsync();
var server = Group(
"Nest server URL (File / Database mode is selected under File > Storage Mode)",
UrlRow(serverUrl, setServer));
var saveFeed = new Button { Content = "Save Feed URL" };
saveFeed.Click += async (_, _) => await SaveFeedAsync();
var updates = Group(
"Auto-update feed URL (blank: nest server mirror in Database mode, else GitHub Releases)",
UrlRow(updateFeedUrl, saveFeed), includeDevBuilds);
var ledger = new TextBlock
{
Text = "MaterialLedger is set in the Windows app: its API key is saved with Windows data protection.",
Foreground = Muted,
TextWrapping = TextWrapping.Wrap,
};
var save = new Button { Content = "Save", IsDefault = true, MinWidth = 80 };
save.Click += async (_, _) => await SaveAsync();
var cancel = new Button { Content = "Cancel", IsCancel = true, MinWidth = 80 };
cancel.Click += (_, _) => Close(false);
var buttons = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { save, cancel },
};
var panel = new StackPanel
{
Margin = new Thickness(18, 18, 18, 8),
Spacing = 14,
Children =
{
general,
server,
ledger,
updates,
Group("Fill Strategies", StrategyGrid()),
},
};
// Save and Cancel stay in view when a small screen makes the options scroll.
buttons.Margin = new Thickness(18, 8, 18, 18);
DockPanel.SetDock(buttons, Dock.Bottom);
Content = new DockPanel
{
Children = { buttons, new ScrollViewer { Content = panel } },
};
Load();
}
/// <summary>
/// Shows the dialog over <paramref name="owner"/>; true when the options were saved.
/// <paramref name="settingsPath"/> is the shared user-settings.json.
/// </summary>
public static async Task<bool> ShowAsync(Window owner, string settingsPath) =>
await new OptionsDialog(settingsPath).ShowDialog<bool?>(owner) == true;
private void Load()
{
var settings = UserSettingsFile.Read(settingsPath);
var options = DesktopOptions.Read(settings);
serverUrl.Text = DesktopSettings.ServerUrl;
updateFeedUrl.Text = UpdateFeedOption.Read(settings);
includeDevBuilds.IsChecked = options.UpdateIncludeDevBuilds;
createNewNest.IsChecked = options.CreateNewNestOnOpen;
autoSizeFactor.Value = (decimal)options.AutoSizePlateFactorForEditing;
planningEffort.Value = (decimal)options.CuttingPlanEffortForEditing;
var schemes = DesktopOptions.ColorSchemeNames;
colorScheme.ItemsSource = schemes;
colorScheme.SelectedIndex = options.ColorSchemeIndex(schemes);
clonePlacement.ItemsSource = ClonePlacement.Choices.Select(c => c.Text).ToList();
clonePlacement.SelectedIndex = ClonePlacement.IndexOf(options.ClonePlacementOrder);
var disabled = new HashSet<string>(options.DisabledStrategies, StringComparer.OrdinalIgnoreCase);
foreach (var (enabled, name) in strategies)
enabled.IsChecked = !disabled.Contains(name);
}
private DesktopOptions FromControls() => new()
{
CreateNewNestOnOpen = createNewNest.IsChecked == true,
UpdateIncludeDevBuilds = includeDevBuilds.IsChecked == true,
// A cleared box keeps the value it had, as a WinForms numeric box does.
AutoSizePlateFactor = (double)(autoSizeFactor.Value ?? (decimal)DesktopOptions.Defaults.AutoSizePlateFactor),
CuttingPlanEffort = (double)(planningEffort.Value ?? (decimal)DesktopOptions.Defaults.CuttingPlanEffort),
ColorScheme = colorScheme.SelectedItem as string ?? ColorSchemeRegistry.DefaultSchemeName,
ClonePlacementOrder = ClonePlacement.At(clonePlacement.SelectedIndex),
DisabledStrategies = strategies.Where(s => s.Enabled.IsChecked != true).Select(s => s.Name).ToList(),
};
private async Task SaveAsync()
{
var options = FromControls();
if (!UserSettingsFile.Update(settingsPath, options.WriteTo))
{
await ConfirmDialog.ShowAsync(this, "Options",
$"Could not save the options to {settingsPath}. Nothing was changed.");
return;
}
options.Apply();
Close(true);
}
private async Task SetServerAsync()
{
var (notice, url) = NestServerUrlOption.Save(serverUrl.Text, DesktopSettings.SaveServerUrl);
if (url != null)
serverUrl.Text = DesktopSettings.ServerUrl;
await ConfirmDialog.ShowAsync(this, notice.Title, notice.Text);
}
private async Task SaveFeedAsync()
{
var (notice, url) = UpdateFeedOption.Save(updateFeedUrl.Text, next =>
{
if (!UserSettingsFile.Update(settingsPath, values => values[UpdateFeedOption.SettingName] = next))
throw new System.IO.IOException($"{settingsPath} could not be written.");
});
if (url != null)
updateFeedUrl.Text = url;
await ConfirmDialog.ShowAsync(this, notice.Title, notice.Text);
}
private Grid StrategyGrid()
{
var rows = FillStrategyOptions.Rows;
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto"),
ColumnSpacing = 16,
RowSpacing = 4,
};
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
var header = 1;
foreach (var (text, column) in new[] { ("Strategy", 1), ("Phase", 2), ("Order", 3) })
{
var cell = new TextBlock { Text = text, FontWeight = FontWeight.SemiBold };
Grid.SetColumn(cell, column);
grid.Children.Add(cell);
}
for (var i = 0; i < rows.Count; i++)
{
var row = rows[i];
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
var enabled = new CheckBox { IsChecked = true, MinWidth = 0, MinHeight = 0 };
strategies.Add((enabled, row.Name));
var cells = new Control[]
{
enabled,
Cell(row.Name),
Cell(row.Phase.ToString()),
Cell(row.Order.ToString()),
};
for (var column = 0; column < cells.Length; column++)
{
Grid.SetRow(cells[column], i + header);
Grid.SetColumn(cells[column], column);
grid.Children.Add(cells[column]);
}
}
return grid;
}
private static TextBlock Cell(string text) =>
new() { Text = text, VerticalAlignment = VerticalAlignment.Center };
private static void AddRow(Grid grid, int row, string label, Control editor, string tip)
{
var text = new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center };
Grid.SetRow(text, row);
Grid.SetRow(editor, row);
Grid.SetColumn(editor, 1);
if (tip != null)
ToolTip.SetTip(editor, tip);
grid.Children.Add(text);
grid.Children.Add(editor);
}
private static Grid UrlRow(TextBox box, Button button)
{
var row = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8 };
Grid.SetColumn(button, 1);
button.MinWidth = 150;
row.Children.Add(box);
row.Children.Add(button);
return row;
}
private static StackPanel Group(string header, params Control[] children)
{
var group = new StackPanel { Spacing = 8 };
group.Children.Add(new TextBlock
{
Text = header,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap,
});
foreach (var child in children)
group.Children.Add(child);
return group;
}
}
}
+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();
}
}
+107
View File
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using OpenNest.Desktop.Rendering;
using OpenNest.Geometry;
using SkiaSharp;
namespace OpenNest.Avalonia.Rendering
{
/// <summary>Converts the shared plate-view figures to Skia paths.</summary>
internal static class SkiaFigures
{
public static SKPath ToPath(IEnumerable<PathFigure> figures, SKPathFillType fillType = SKPathFillType.EvenOdd)
{
var path = new SKPath { FillType = fillType };
foreach (var figure in figures)
Add(path, figure);
return path;
}
public static void Add(SKPath path, PathFigure figure)
{
path.MoveTo((float)figure.Start.X, (float)figure.Start.Y);
foreach (var segment in figure.Segments)
{
switch (segment)
{
case LineTo line:
path.LineTo((float)line.End.X, (float)line.End.Y);
break;
case ArcTo arc:
{
var r = (float)arc.Radius;
var oval = new SKRect((float)arc.Center.X - r, (float)arc.Center.Y - r,
(float)arc.Center.X + r, (float)arc.Center.Y + r);
if (arc.IsFullCircle)
{
// Skia drops a whole-turn sweep; two halves keep the contour joined.
var half = (float)(arc.SweepAngle / 2);
path.ArcTo(oval, (float)arc.StartAngle, half, false);
path.ArcTo(oval, (float)arc.StartAngle + half, half, false);
}
else
path.ArcTo(oval, (float)arc.StartAngle, (float)arc.SweepAngle, false);
break;
}
}
}
if (figure.IsClosed && figure.Segments.Count > 0)
path.Close();
}
public static SKPath Polygons(IEnumerable<IReadOnlyList<Vector>> polygons)
{
var path = new SKPath { FillType = SKPathFillType.EvenOdd };
foreach (var points in polygons)
{
if (points.Count == 0)
continue;
path.MoveTo((float)points[0].X, (float)points[0].Y);
for (var i = 1; i < points.Count; i++)
path.LineTo((float)points[i].X, (float)points[i].Y);
path.Close();
}
return path;
}
}
/// <summary>
/// A part's shared display geometry as Skia paths in its program frame, before placement.
/// Built on the UI thread and only read afterwards, so the render thread may draw it.
/// </summary>
internal sealed class PartPaths
{
private PartPaths(PartDisplayGeometry geometry)
{
Geometry = geometry;
Cut = SkiaFigures.ToPath(geometry.Cut);
Lead = SkiaFigures.ToPath(geometry.Lead);
Etch = SkiaFigures.ToPath(geometry.Etch);
Material = SkiaFigures.ToPath(geometry.Material);
Display = geometry.Display.Count > 0 ? SkiaFigures.ToPath(geometry.Display) : null;
Selection = geometry.Cut.Count > 0 ? Material : Etch;
StrokeBounds = geometry.StrokeBounds();
}
public PartDisplayGeometry Geometry { get; }
public SKPath Cut { get; }
public SKPath Lead { get; }
public SKPath Etch { get; }
public SKPath Material { get; }
/// <summary>Display-only geometry; null when the drawing has none.</summary>
public SKPath Display { get; }
/// <summary>What picks the part: its material, or an etch-only part's marks.</summary>
public SKPath Selection { get; }
/// <summary>The bounds of every stroke in the program frame; null when nothing is drawn.</summary>
public Box StrokeBounds { get; }
public static PartPaths Build(Part part) => new(PartDisplayGeometry.Build(part));
}
}
@@ -0,0 +1,88 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using SkiaSharp;
using DrawingColor = System.Drawing.Color;
namespace OpenNest.Avalonia.Rendering
{
/// <summary>
/// A colour scheme's colours as Skia colours, for the plate canvas. Immutable, so the render
/// thread may read one while the UI thread changes the scheme; <see cref="For"/> returns the
/// cached palette until the scheme's colours change.
/// </summary>
internal sealed class SkiaSchemePalette
{
// The WinForms view's translucent selection fill, used when the scheme sets none.
private static readonly DrawingColor LegacySelectedColor = DrawingColor.FromArgb(90, 150, 200, 255);
private static readonly ConditionalWeakTable<ColorScheme, SkiaSchemePalette> Cache = new();
private readonly DrawingColor partOutline;
private readonly ConcurrentDictionary<SKColor, SKColor> outlines = new();
private SkiaSchemePalette(ColorScheme scheme)
{
partOutline = scheme.PartOutlineColor;
Version = scheme.Version;
Background = ToSk(scheme.BackgroundColor);
PlateFill = ToSk(scheme.LayoutFillColor);
PlateOutline = ToSk(scheme.LayoutOutlineColor);
EdgeSpacing = ToSk(scheme.EdgeSpacingColor);
Etch = ToSk(scheme.EtchColor);
Origin = ToSk(scheme.OriginColor);
Rapid = ToSk(scheme.RapidColor);
BoundingBox = ToSk(scheme.BoundingBoxColor);
var selected = scheme.SelectedPartColor.IsEmpty ? LegacySelectedColor : scheme.SelectedPartColor;
SelectedFill = ToSk(selected);
// As WinForms: the scheme's outline, else the legacy selection fill darkened.
SelectedOutline = ToSk(scheme.PartOutlineColor.IsEmpty
? ColorShade.Dark(LegacySelectedColor)
: scheme.PartOutlineColor);
}
private int Version { get; }
public SKColor Background { get; }
public SKColor PlateFill { get; }
public SKColor PlateOutline { get; }
public SKColor EdgeSpacing { get; }
public SKColor Etch { get; }
public SKColor Origin { get; }
public SKColor Rapid { get; }
public SKColor BoundingBox { get; }
public SKColor SelectedFill { get; }
public SKColor SelectedOutline { get; }
/// <summary>The palette for <paramref name="scheme"/>'s current colours.</summary>
public static SkiaSchemePalette For(ColorScheme scheme)
{
lock (Cache)
{
if (!Cache.TryGetValue(scheme, out var palette) || palette.Version != scheme.Version)
{
palette = new SkiaSchemePalette(scheme);
Cache.AddOrUpdate(scheme, palette);
}
return palette;
}
}
/// <summary>A part's outline for its fill: the scheme's outline colour, or the fill darkened.</summary>
public SKColor PartOutline(SKColor fill) =>
outlines.GetOrAdd(fill, f => ToSk(partOutline.IsEmpty
? ColorShade.Dark(DrawingColor.FromArgb(f.Alpha, f.Red, f.Green, f.Blue))
: partOutline));
public static SKColor ToSk(DrawingColor color) => new(color.R, color.G, color.B, color.A);
}
}
+59
View File
@@ -0,0 +1,59 @@
using System;
using System.IO;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using OpenNest.Avalonia.Controls;
using SkiaSharp;
namespace OpenNest.Avalonia.Rendering
{
/// <summary>
/// File &gt; Export's "image as displayed": the plate view drawn as it is shown, at the
/// screen's pixel density, by the same Skia drawing that paints it on screen. A .jpg or .jpeg
/// file is written as JPEG (as the WinForms export writes), any other as PNG.
/// </summary>
internal static class ViewImage
{
private const int JpegQuality = 90;
public static void Save(PlateCanvas canvas, string path)
{
ArgumentNullException.ThrowIfNull(canvas);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (canvas.Bounds.Width < 1 || canvas.Bounds.Height < 1)
throw new InvalidOperationException("The plate view is not shown, so there is no image to save.");
var scaling = TopLevel.GetTopLevel(canvas)?.RenderScaling ?? 1;
var size = new PixelSize(
System.Math.Max(1, (int)System.Math.Ceiling(canvas.Bounds.Width * scaling)),
System.Math.Max(1, (int)System.Math.Ceiling(canvas.Bounds.Height * scaling)));
using var bitmap = new RenderTargetBitmap(size, new Vector(96 * scaling, 96 * scaling));
// The canvas draws itself (it has no child controls) at the origin: RenderTargetBitmap
// .Render would place it where it sits in its parent, shifting it out of the image.
using (var context = bitmap.CreateDrawingContext())
canvas.Render(context);
if (!IsJpeg(path))
{
bitmap.Save(path);
return;
}
// Avalonia saves PNG only; Skia re-encodes it as JPEG.
using var png = new MemoryStream();
bitmap.Save(png);
png.Position = 0;
using var decoded = SKBitmap.Decode(png)
?? throw new InvalidOperationException("The plate view image could not be encoded.");
using var image = SKImage.FromBitmap(decoded);
using var jpeg = image.Encode(SKEncodedImageFormat.Jpeg, JpegQuality)
?? throw new InvalidOperationException("The plate view image could not be encoded as JPEG.");
using var file = File.Create(path);
jpeg.SaveTo(file);
}
private static bool IsJpeg(string path) =>
Path.GetExtension(path).ToLowerInvariant() is ".jpg" or ".jpeg";
}
}
+116
View File
@@ -0,0 +1,116 @@
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
namespace OpenNest.Avalonia
{
/// <summary>
/// One number to set, as the WinForms SetValueForm: an "Increment :" box from 0 to a maximum
/// shown with four decimals, an optional check box that disables the box, and a Set button
/// that is also Enter. Esc or closing the window changes nothing.
/// </summary>
internal sealed class SetValueDialog : Window
{
public const double DefaultMaximum = 999999;
/// <summary>What the user set: the value and the option check box.</summary>
public sealed record Result(double Value, bool OptionChecked);
private readonly NumericUpDown value;
private readonly CheckBox option;
// The last value the box held, which an emptied box keeps, as the WinForms box does.
private double lastValid;
private readonly double maximum;
private SetValueDialog(
string title, double initialValue, double maximum, string optionText,
bool optionChecked)
{
Title = title;
// Wide enough for the option's text on one line, as the WinForms form widens.
MinWidth = 300;
SizeToContent = SizeToContent.WidthAndHeight;
CanResize = false;
ShowInTaskbar = false;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
this.maximum = maximum;
lastValid = System.Math.Clamp(initialValue, 0, maximum);
value = new NumericUpDown
{
Name = "Value",
Minimum = 0,
Maximum = (decimal)maximum,
Increment = 1,
// A typed value out of range goes to the nearest limit, as in WinForms, rather
// than being refused.
ClipValueToMinMax = true,
FormatString = "0.0000",
Value = (decimal)lastValid,
HorizontalAlignment = HorizontalAlignment.Stretch,
};
value.ValueChanged += (_, e) =>
{
if (e.NewValue is { } v)
lastValid = (double)v;
};
var set = new Button { Name = "Set", Content = "Set", IsDefault = true };
set.Click += (_, _) => Close(new Result(Current, option.IsChecked == true));
var row = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
};
var label = new TextBlock
{
Text = "Increment :",
VerticalAlignment = VerticalAlignment.Center,
};
row.Children.Add(label);
Grid.SetColumn(value, 1);
row.Children.Add(value);
Grid.SetColumn(set, 2);
row.Children.Add(set);
option = new CheckBox
{
Name = "Option",
Content = optionText,
IsChecked = optionChecked,
IsVisible = !string.IsNullOrEmpty(optionText),
};
value.IsEnabled = !optionChecked;
option.IsCheckedChanged += (_, _) => value.IsEnabled = option.IsChecked != true;
var panel = new StackPanel { Margin = new Thickness(12), Spacing = 10 };
panel.Children.Add(row);
panel.Children.Add(option);
Content = panel;
AddHandler(KeyDownEvent, (_, e) =>
{
if (e.Key != Key.Escape)
return;
e.Handled = true;
Close(null);
}, RoutingStrategies.Tunnel);
Opened += (_, _) => value.Focus();
}
// A value out of range is taken to the nearest limit, as the WinForms box does.
private double Current => System.Math.Clamp(lastValid, 0, maximum);
/// <summary>
/// The value and option the user set, or null when they closed the dialog without Set.
/// </summary>
public static Task<Result> ShowAsync(
Window owner, string title, double value, double maximum = DefaultMaximum,
string optionText = null, bool optionChecked = false) =>
new SetValueDialog(title, value, maximum, optionText, optionChecked)
.ShowDialog<Result>(owner);
}
}
+111
View File
@@ -0,0 +1,111 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using OpenNest.Data;
using OpenNest.Desktop;
namespace OpenNest.Avalonia
{
/// <summary>
/// Restores a window's size, position and maximized state from the shared user-settings
/// file and saves them when it closes, under the same names the WinForms app uses.
/// </summary>
/// <remarks>
/// Sizes are stored in screen pixels. Avalonia sets the client area, while WinForms stores
/// the outer window, so a size saved by one app opens a frame's width different in the
/// other; each app's own round trip is exact.
/// </remarks>
internal sealed class WindowPlacementBinding
{
private readonly Window window;
private readonly string name;
private readonly string settingsPath;
private PixelPoint? normalPosition;
private (int Width, int Height)? normalSize;
private WindowPlacementBinding(Window window, string name, string settingsPath)
{
this.window = window;
this.name = name;
this.settingsPath = settingsPath;
}
/// <summary>Call before the window is shown.</summary>
public static void Remember(Window window, string name, string settingsPath)
{
var binding = new WindowPlacementBinding(window, name, settingsPath);
binding.Restore();
window.Opened += (_, _) => binding.Track();
window.PositionChanged += (_, _) => binding.Track();
window.PropertyChanged += (_, e) =>
{
if (e.Property == TopLevel.ClientSizeProperty
|| e.Property == Window.WindowStateProperty)
binding.Track();
};
window.Closing += (_, _) => binding.Save();
}
private void Restore()
{
var saved = WindowPlacement.Read(UserSettingsFile.Read(settingsPath), name);
var screens = window.Screens?.All;
if (saved == null || screens == null)
return;
var areas = screens
.Select(s => s.WorkingArea)
.Select(a => new OpenNest.Desktop.PixelRect(a.X, a.Y, a.Width, a.Height))
.ToList();
var placement = saved.FitTo(areas);
var screen = placement.Location is { } at
? screens.First(s => s.WorkingArea.Contains(new PixelPoint(at.X, at.Y)))
: screens.FirstOrDefault(s => s.IsPrimary) ?? screens.FirstOrDefault();
if (placement.Location is { } location)
{
window.WindowStartupLocation = WindowStartupLocation.Manual;
window.Position = new PixelPoint(location.X, location.Y);
}
if (placement.Size is { } size)
{
var scaling = screen?.Scaling ?? 1;
window.Width = size.Width / scaling;
window.Height = size.Height / scaling;
}
if (placement.Maximized)
window.WindowState = WindowState.Maximized;
}
/// <summary>Remembers the normal bounds, which maximizing keeps.</summary>
private void Track()
{
if (window.WindowState != WindowState.Normal || !window.IsVisible)
return;
normalPosition = window.Position;
var scaling = window.DesktopScaling;
normalSize = ((int)System.Math.Round(window.ClientSize.Width * scaling),
(int)System.Math.Round(window.ClientSize.Height * scaling));
}
private void Save()
{
Track();
var maximized = window.WindowState == WindowState.Maximized;
UserSettingsFile.Update(settingsPath, values =>
{
// A window never shown normal this session keeps the bounds saved before.
var previous = WindowPlacement.Read(values, name);
var placement = new WindowPlacement(
normalPosition is { } p ? (p.X, p.Y) : previous?.Location,
normalSize ?? previous?.Size,
maximized);
placement.WriteTo(values, name);
});
}
}
}
+42 -11
View File
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using OpenNest.Clamps;
using OpenNest.CutOffs;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -37,9 +40,19 @@ namespace OpenNest.Benchmark
public double PartSpacing { get; init; } public double PartSpacing { get; init; }
public int Quadrant { 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> /// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
public double SalvageRate { get; init; } public double SalvageRate { get; init; }
/// <summary>Manifest "offcutCut": which edge strips count as a sheet's offcut;
/// null for all.</summary>
public CutOffAxis? OffcutCut { get; init; }
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary> /// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; } public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
public List<DrawingRequest> Requests { get; init; } public List<DrawingRequest> Requests { get; init; }
@@ -60,7 +73,8 @@ namespace OpenNest.Benchmark
/// (unlimited quantity - the engine under test decides how many of each /// (unlimited quantity - the engine under test decides how many of each
/// size it actually uses, and how demand splits across plates). The /// size it actually uses, and how demand splits across plates). The
/// engine owns its own multi-plate/size strategy; this harness no /// 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> /// </summary>
public NestJob BuildNestJob( public NestJob BuildNestJob(
int maxPlates, int maxPlates,
@@ -71,24 +85,41 @@ namespace OpenNest.Benchmark
var parts = Requests.Select(r => var parts = Requests.Select(r =>
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity) DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
); );
var stock = CandidateSizes.Select(size => new NestPlateStock( // No options would make the builder offer the template itself; an empty pool stays empty.
size.ToString(1), var stock = CandidateSizes.Count == 0
size, ? Array.Empty<NestPlateStock>()
null, : NestStockBuilder.FromTemplate(
PartSpacing, StockTemplate(),
EdgeSpacing, CandidateSizes.Select(size => new PlateOption { Width = size.Width, Length = size.Length }).ToList()
Quadrant );
));
return new NestJob( return new NestJob(
parts, parts,
stock, stock,
new NestJobOptions( new NestJobOptions(
"Fill", "Lattice",
maxPlates, maxPlates,
salvageRate ?? SalvageRate, salvageRate ?? SalvageRate,
minimumSalvageDimension ?? 0 minimumSalvageDimension ?? 0,
offcutCut: OffcutCut
) )
); );
} }
/// <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;
}
} }
} }
+47 -15
View File
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.NestingEngines;
namespace OpenNest.Benchmark namespace OpenNest.Benchmark
{ {
@@ -28,6 +29,15 @@ namespace OpenNest.Benchmark
/// <summary>Wall-clock budget for one engine solving one job.</summary> /// <summary>Wall-clock budget for one engine solving one job.</summary>
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
/// <summary>How long past a --time-limit the harness waits before abandoning a solve.
/// </summary>
private static readonly TimeSpan TimeLimitGrace = TimeSpan.FromMinutes(1);
/// <summary>The longest --time-limit: the harness timeout above it must still fit a
/// cancellation timer.</summary>
internal static readonly TimeSpan MaxTimeLimit =
DeadlineNestingEngine.MaxLimit - TimeLimitGrace;
public static List<JobResult> Run( public static List<JobResult> Run(
List<BenchmarkJob> jobs, List<BenchmarkJob> jobs,
IReadOnlyList<NestingEngineInfo> engines, IReadOnlyList<NestingEngineInfo> engines,
@@ -35,7 +45,9 @@ namespace OpenNest.Benchmark
double? minimumSalvageDimension = null, double? minimumSalvageDimension = null,
string outputDirectory = null, string outputDirectory = null,
int maxParallelism = 1, int maxParallelism = 1,
System.IO.TextWriter progressLog = null System.IO.TextWriter progressLog = null,
TimeSpan? timeLimit = null,
CommonLineSetting commonLines = null
) )
{ {
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine))) var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
@@ -71,7 +83,9 @@ namespace OpenNest.Benchmark
salvageRate, salvageRate,
minimumSalvageDimension, minimumSalvageDimension,
outputDirectory, outputDirectory,
progressLog progressLog,
timeLimit,
commonLines
) )
); );
@@ -126,14 +140,7 @@ namespace OpenNest.Benchmark
.BaselinePlateRuns.Select( .BaselinePlateRuns.Select(
(run, index) => (run, index) =>
{ {
var stock = new NestPlateStock( var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
$"baseline-{index}",
run.Plate.Size,
1,
run.Plate.PartSpacing,
run.Plate.EdgeSpacing,
run.Plate.Quadrant
);
var placements = run var placements = run
.Parts.Select(part => .Parts.Select(part =>
{ {
@@ -221,13 +228,22 @@ namespace OpenNest.Benchmark
} }
} }
/// <summary>The harness's own limit on a solve: five minutes, or a --time-limit plus a
/// grace period, so the engine's limit always fires first.</summary>
private static TimeSpan HarnessTimeout(TimeSpan? timeLimit) =>
timeLimit is { } limit && limit + TimeLimitGrace > SolveTimeout
? limit + TimeLimitGrace
: SolveTimeout;
private static JobResult RunOne( private static JobResult RunOne(
BenchmarkJob job, BenchmarkJob job,
NestingEngineInfo engineInfo, NestingEngineInfo engineInfo,
double? salvageRate, double? salvageRate,
double? minimumSalvageDimension, double? minimumSalvageDimension,
string outputDirectory, string outputDirectory,
System.IO.TextWriter progressLog System.IO.TextWriter progressLog,
TimeSpan? timeLimit,
CommonLineSetting commonLines = null
) )
{ {
var requested = job.TotalRequestedQuantity; var requested = job.TotalRequestedQuantity;
@@ -241,8 +257,22 @@ namespace OpenNest.Benchmark
{ {
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension); var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
var engine = engineInfo.Factory(); var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(SolveTimeout); if (timeLimit is { } limit)
var jobResult = engine.Solve(nestJob, log, cts.Token); engine = new DeadlineNestingEngine(engine, limit);
using var cts = new CancellationTokenSource(HarnessTimeout(timeLimit));
NestJobResult jobResult;
if (commonLines != null)
{
// Through the desktop's pipeline, so its common-line trial runs; every part
// may pair. "off" is the like-for-like baseline for the levels.
nestJob = commonLines.Apply(nestJob);
var pipeline = NestPipeline.Run(engine, engineInfo.Name, nestJob,
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing), log, cts.Token);
nestJob = pipeline.Job;
jobResult = pipeline.Raw;
}
else
jobResult = engine.Solve(nestJob, log, cts.Token);
log?.Finished(jobResult, sw.ElapsedMilliseconds); log?.Finished(jobResult, sw.ElapsedMilliseconds);
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult); var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
@@ -363,7 +393,7 @@ namespace OpenNest.Benchmark
ElapsedMs = sw.ElapsedMilliseconds, ElapsedMs = sw.ElapsedMilliseconds,
}; };
} }
catch (OperationCanceledException) catch (Exception ex) when (ex is OperationCanceledException || ex is TimeoutException)
{ {
sw.Stop(); sw.Stop();
log?.Failed("timed out", sw.ElapsedMilliseconds); log?.Failed("timed out", sw.ElapsedMilliseconds);
@@ -375,7 +405,9 @@ namespace OpenNest.Benchmark
PartsRequested = requested, PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty, UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds, ElapsedMs = sw.ElapsedMilliseconds,
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)", Error = ex is TimeoutException
? ex.Message
: $"Timed out after {HarnessTimeout(timeLimit).TotalMinutes:0.#} minute(s)",
}; };
} }
catch (Exception ex) catch (Exception ex)
+29
View File
@@ -0,0 +1,29 @@
using System.Linq;
using OpenNest.CommonLines;
using OpenNest.Engine.Jobs;
namespace OpenNest.Benchmark
{
/// <summary>
/// <c>--common-lines</c>: solve every job through <see cref="NestPipeline"/> at this usage
/// level and kerf, with every part allowed to share a common line.
/// </summary>
public sealed record CommonLineSetting(CommonLineUsage Usage, double Kerf)
{
public NestJob Apply(NestJob job)
{
var o = job.Options;
var options = new NestJobOptions(o.PlacementStrategy, o.MaxPlates, o.SalvageRate,
o.MinimumSalvageDimension, o.SearchUntilCancelled, o.OffcutCut)
{
CommonLineUsage = Usage,
CommonLineKerf = Kerf,
};
var parts = job.Parts.Select(p => new NestJobPart(p.Id, p.Geometry, p.Quantity, p.Priority, p.Rotation)
{
AllowCommonLine = true,
});
return new NestJob(parts, job.Plates, options);
}
}
}
+65 -3
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using OpenNest.CutOffs;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
@@ -52,9 +53,24 @@ namespace OpenNest.Benchmark
PartSpacing = partSpacingOverride ?? manifest.Spacing, PartSpacing = partSpacingOverride ?? manifest.Spacing,
Quadrant = manifest.Quadrant, Quadrant = manifest.Quadrant,
Requests = requests, Requests = requests,
OffcutCut = OffcutCut(manifest.OffcutCut, manifestPath),
}; };
} }
/// <summary>"horizontal" or "vertical" (any case); absent counts every edge strip.
/// </summary>
private static CutOffAxis? OffcutCut(string value, string manifestPath) =>
value?.ToLowerInvariant() switch
{
null => null,
"horizontal" => CutOffAxis.Horizontal,
"vertical" => CutOffAxis.Vertical,
_ => throw new InvalidOperationException(
$"Manifest '{manifestPath}' has offcutCut \"{value}\"; "
+ "use \"horizontal\" or \"vertical\"."
),
};
private static Manifest ReadManifest(string manifestPath) private static Manifest ReadManifest(string manifestPath)
{ {
try try
@@ -135,12 +151,18 @@ namespace OpenNest.Benchmark
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}"); throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly. // A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
if (!part.AllowRotation) // Quarter turns are a 90° sweep from 0 to 270°; automatic rotation allows any angle.
var rotations = RotationSet(part);
if (rotations != RotationChoice.Any)
{ {
var quarter = rotations == RotationChoice.QuarterTurns;
drawing.Constraints ??= new NestConstraints(); drawing.Constraints ??= new NestConstraints();
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI; drawing.Constraints.StepAngle = quarter
? OpenNest.Math.Angle.HalfPI
: OpenNest.Math.Angle.TwoPI;
drawing.Constraints.StartAngle = 0; drawing.Constraints.StartAngle = 0;
drawing.Constraints.EndAngle = 0; drawing.Constraints.EndAngle = quarter ? 3 * OpenNest.Math.Angle.HalfPI : 0;
drawing.Constraints.Allow180Equivalent = rotations == RotationChoice.HalfTurns;
} }
var constraints = drawing.Constraints; var constraints = drawing.Constraints;
@@ -156,12 +178,51 @@ namespace OpenNest.Benchmark
}; };
} }
private enum RotationChoice
{
None,
HalfTurns,
QuarterTurns,
Any,
}
/// <summary>
/// A part's "rotations" (degrees) must be {0}, {0, 180} or {0, 90, 180, 270}; without it,
/// "allowRotation" picks any angle (true, the default: automatic rotation) or none.
/// </summary>
private static RotationChoice RotationSet(ManifestPart part)
{
if (part.Rotations == null)
return part.AllowRotation ? RotationChoice.Any : RotationChoice.None;
if (!part.AllowRotation)
throw new InvalidOperationException(
$"Manifest part '{part.Dxf}': set \"rotations\" or \"allowRotation\", not both."
);
var set = part.Rotations
.Select(degrees => ((degrees % 360) + 360) % 360)
.Distinct()
.OrderBy(degrees => degrees)
.ToArray();
if (set.SequenceEqual(new[] { 0.0 }))
return RotationChoice.None;
if (set.SequenceEqual(new[] { 0.0, 180.0 }))
return RotationChoice.HalfTurns;
if (set.SequenceEqual(new[] { 0.0, 90.0, 180.0, 270.0 }))
return RotationChoice.QuarterTurns;
throw new InvalidOperationException(
$"Manifest part '{part.Dxf}': \"rotations\" must be [0], [0, 180] or "
+ $"[0, 90, 180, 270] (was [{string.Join(", ", part.Rotations)}])."
);
}
private class Manifest private class Manifest
{ {
public List<string> SheetSizes { get; set; } public List<string> SheetSizes { get; set; }
public double Spacing { get; set; } public double Spacing { get; set; }
public double EdgeSpacing { get; set; } public double EdgeSpacing { get; set; }
public int Quadrant { get; set; } = 1; public int Quadrant { get; set; } = 1;
public string OffcutCut { get; set; }
public List<ManifestPart> Parts { get; set; } public List<ManifestPart> Parts { get; set; }
} }
@@ -170,6 +231,7 @@ namespace OpenNest.Benchmark
public string Dxf { get; set; } public string Dxf { get; set; }
public int Quantity { get; set; } public int Quantity { get; set; }
public bool AllowRotation { get; set; } = true; public bool AllowRotation { get; set; } = true;
public List<double> Rotations { get; set; }
} }
} }
} }
+30 -5
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using OpenNest.Clamps;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
@@ -74,6 +75,8 @@ namespace OpenNest.Benchmark
EdgeSpacing = template.EdgeSpacing, EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing, PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant, Quadrant = template.Quadrant,
ClampRail = template.ClampRail,
ClampZones = template.ClampZones,
SalvageRate = nest.SalvageRate, SalvageRate = nest.SalvageRate,
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride), BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
Requests = requests, Requests = requests,
@@ -182,23 +185,45 @@ namespace OpenNest.Benchmark
plate.EdgeSpacing.Top 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++) for (var copy = 0; copy < plate.Quantity; copy++)
runs.Add((validationPlate, parts)); runs.Add((validationPlate, parts));
} }
return runs.Count > 0 ? runs : null; return runs.Count > 0 ? runs : null;
} }
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate( private static (
Nest nest Spacing EdgeSpacing,
) double PartSpacing,
int Quadrant,
ClampRail ClampRail,
IReadOnlyList<ClampZone> ClampZones
) ResolvePlateTemplate(Nest nest)
{ {
var source = nest.Plates?.FirstOrDefault(); var source = nest.Plates?.FirstOrDefault();
if (source != null) 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; 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) private static List<Size> ResolveSheetSizes(Nest nest)
+86 -1
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Linq; using System.Linq;
using OpenNest; using OpenNest;
using OpenNest.Benchmark; using OpenNest.Benchmark;
using OpenNest.CommonLines;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -18,6 +19,9 @@ static class BenchmarkConsole
if (options == null) if (options == null)
return 0; // --help was requested return 0; // --help was requested
if (options.Invalid)
return 1;
if (options.InputPath == null) if (options.InputPath == null)
{ {
PrintUsage(); PrintUsage();
@@ -124,7 +128,9 @@ static class BenchmarkConsole
options.MinimumSalvageDimension, options.MinimumSalvageDimension,
options.OutputDirectory, options.OutputDirectory,
options.Parallel, options.Parallel,
options.Progress ? Console.Out : null options.Progress ? Console.Out : null,
options.TimeLimit,
options.CommonLines
); );
Report.PrintDetailed(results); Report.PrintDetailed(results);
@@ -197,10 +203,31 @@ static class BenchmarkConsole
); );
break; break;
case "--time-limit":
o.TimeLimit = i + 1 < args.Length ? ParseTimeLimit(args[++i]) : null;
if (o.TimeLimit == null)
{
Console.Error.WriteLine(
"Error: --time-limit needs a number of seconds from 0.001 to "
+ $"{BenchmarkRunner.MaxTimeLimit.TotalSeconds:0}"
);
o.Invalid = true;
}
break;
case "--progress": case "--progress":
o.Progress = true; o.Progress = true;
break; break;
case "--common-lines" when i + 1 < args.Length:
o.CommonLineLevel = args[++i];
break;
case "--kerf" when i + 1 < args.Length:
o.Kerf = double.TryParse(args[++i], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var kerf) ? kerf : double.NaN;
break;
case "--help": case "--help":
PrintUsage(); PrintUsage();
return null; return null;
@@ -212,6 +239,25 @@ static class BenchmarkConsole
} }
} }
if (o.CommonLineLevel != null)
{
CommonLineUsage? usage = o.CommonLineLevel.ToLowerInvariant() switch
{
"off" => CommonLineUsage.Off,
"when-it-adds-parts" => CommonLineUsage.WhenItAddsParts,
"whenever" => CommonLineUsage.Whenever,
_ => null,
};
if (usage == null || o.Kerf is not { } kerf || !double.IsFinite(kerf) || kerf <= 0)
{
Console.Error.WriteLine(
"Error: --common-lines needs off, when-it-adds-parts or whenever, and a positive --kerf"
);
o.Invalid = true;
}
else
o.CommonLines = new CommonLineSetting(usage.Value, kerf);
}
return o; return o;
} }
@@ -235,6 +281,22 @@ static class BenchmarkConsole
return sizes.Distinct().ToList(); return sizes.Distinct().ToList();
} }
private static TimeSpan? ParseTimeLimit(string text)
{
if (
!double.TryParse(
text,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var seconds
)
|| !(seconds >= 0.001)
|| seconds > BenchmarkRunner.MaxTimeLimit.TotalSeconds
)
return null;
return TimeSpan.FromSeconds(seconds);
}
private static void PrintUsage() private static void PrintUsage()
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
@@ -330,6 +392,24 @@ static class BenchmarkConsole
Console.Error.WriteLine( Console.Error.WriteLine(
" --progress Log each solve's start, engine progress and finish" " --progress Log each solve's start, engine progress and finish"
); );
Console.Error.WriteLine(
" --time-limit <seconds> Stop each solve at this wall-clock limit and score"
);
Console.Error.WriteLine(
" the best valid plan reported; none = timed out"
);
Console.Error.WriteLine(
" --common-lines <level> Solve through the Auto Nest pipeline with every part allowed"
);
Console.Error.WriteLine(
" to share a common line: off, when-it-adds-parts or whenever"
);
Console.Error.WriteLine(
" (needs --kerf; off is the baseline to compare levels with)"
);
Console.Error.WriteLine(
" --kerf <value> Common-line kerf in job units (with --common-lines)"
);
Console.Error.WriteLine(" --help Show this message"); Console.Error.WriteLine(" --help Show this message");
} }
@@ -345,5 +425,10 @@ static class BenchmarkConsole
public double? MinimumSalvageDimension; public double? MinimumSalvageDimension;
public int Parallel = 3; public int Parallel = 3;
public bool Progress; public bool Progress;
public TimeSpan? TimeLimit;
public string CommonLineLevel;
public double? Kerf;
public CommonLineSetting CommonLines;
public bool Invalid;
} }
} }
+126 -27
View File
@@ -24,6 +24,9 @@ return NestConsole.Run(args);
static class NestConsole static class NestConsole
{ {
private const string OutlinedSheetMessage =
"Error: Fill strategies need a rectangular sheet. Use --autonest --engine Irregular.";
public static int Run(string[] args) public static int Run(string[] args)
{ {
using var cancellation = new CancellationTokenSource(); using var cancellation = new CancellationTokenSource();
@@ -35,7 +38,7 @@ static class NestConsole
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
Console.Error.WriteLine("Nesting cancelled; nothing saved."); Console.Error.WriteLine("Nesting canceled; nothing saved.");
return 2; return 2;
} }
catch (Exception ex) catch (Exception ex)
@@ -115,7 +118,7 @@ static class NestConsole
Console.Error.WriteLine( Console.Error.WriteLine(
engine.IsJobEngine 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: 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; return 1;
} }
@@ -132,14 +135,26 @@ static class NestConsole
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue || options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile))) || options.OutlineFile != null || options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template." "Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size, --outline or --template."
); );
return 1; return 1;
} }
if (options.OutlineFile != null && !options.AutoNest)
{
Console.Error.WriteLine("Error: --outline needs --autonest; single-plate fill strategies fill rectangles only.");
return 1;
}
if (options.OutlineFile != null && !File.Exists(options.OutlineFile))
{
Console.Error.WriteLine($"Error: Outline file not found: {options.OutlineFile}");
return 1;
}
if (options.InputFiles.Count == 0) if (options.InputFiles.Count == 0)
{ {
PrintUsage(); PrintUsage();
@@ -197,6 +212,12 @@ static class NestConsole
ApplyTemplate(plate, options); ApplyTemplate(plate, options);
ApplyOverrides(plate, options); ApplyOverrides(plate, options);
if (!options.AutoNest && PlateFillService.Refusal(plate) != null)
{
Console.Error.WriteLine(OutlinedSheetMessage);
return 2;
}
var drawing = ResolveDrawing(nest, options); var drawing = ResolveDrawing(nest, options);
if (drawing == null) if (drawing == null)
@@ -257,6 +278,9 @@ static class NestConsole
_ => BendRepairUnits.Unspecified, _ => BendRepairUnits.Unspecified,
}; };
break; break;
case "--svg-units-per-inch" when i + 1 < args.Length:
o.SvgUnitsPerInch = double.Parse(args[++i], CultureInfo.InvariantCulture);
break;
case "--drawing" when i + 1 < args.Length: case "--drawing" when i + 1 < args.Length:
o.DrawingName = args[++i]; o.DrawingName = args[++i];
break; break;
@@ -276,6 +300,9 @@ static class NestConsole
if (Size.TryParse(args[++i], out var sz)) if (Size.TryParse(args[++i], out var sz))
o.PlateSize = sz; o.PlateSize = sz;
break; break;
case "--outline" when i + 1 < args.Length:
o.OutlineFile = args[++i];
break;
case "--check-overlaps": case "--check-overlaps":
o.CheckOverlaps = true; o.CheckOverlaps = true;
break; break;
@@ -384,9 +411,10 @@ static class NestConsole
return stock; return stock;
} }
static bool IsCadFile(string path) => static bool IsCadFile(string path) => CadFormats.IsCadFile(path);
path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase); static Units DrawingUnits(Options options) =>
options.CadUnits == BendRepairUnits.Millimeters ? Units.Millimeters : Units.Inches;
static Nest LoadOrCreateNest(Options options) static Nest LoadOrCreateNest(Options options)
{ {
@@ -432,7 +460,7 @@ static class NestConsole
// DXF-only mode: create a fresh nest. // DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0) if (dxfFiles.Count == 0)
{ {
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified"); Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg/.svg) files specified");
return null; return null;
} }
@@ -479,8 +507,12 @@ static class NestConsole
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value, MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
} }
: null, : null,
DrawingUnits = DrawingUnits(options),
SvgUserUnitsPerInch = options.SvgUnitsPerInch,
} }
); );
foreach (var warning in result.Warnings)
Console.Error.WriteLine($"Warning: {Path.GetFileName(path)}: {warning}");
foreach (var report in result.BendRepairReports) foreach (var report in result.BendRepairReports)
Console.WriteLine( Console.WriteLine(
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})" $"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
@@ -526,6 +558,22 @@ static class NestConsole
if (options.PlateSize.HasValue && !hasDxfOnly) if (options.PlateSize.HasValue && !hasDxfOnly)
plate.Size = options.PlateSize.Value; plate.Size = options.PlateSize.Value;
if (options.OutlineFile != null)
{
// The outline is the sheet: its bounds replace any size given.
plate.Outline = SheetOutline.FromGeometry(
CadImporter.Import(
options.OutlineFile,
new CadImportOptions
{
DrawingUnits = DrawingUnits(options),
SvgUserUnitsPerInch = options.SvgUnitsPerInch,
}
).Entities
);
Console.WriteLine($"Outline: {options.OutlineFile} ({plate.Outline})");
}
} }
static Drawing ResolveDrawing(Nest nest, Options options) static Drawing ResolveDrawing(Nest nest, Options options)
@@ -589,17 +637,13 @@ static class NestConsole
if (options.AutoNest) if (options.AutoNest)
{ {
var nestItems = new List<NestItem>(); var nestItems = AutoNestItems(nest, plate, drawing, options);
var qty = options.Quantity > 0 ? options.Quantity : 1; if (nestItems.Count == 0)
if (options.DrawingName != null)
{ {
nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty }); Console.Error.WriteLine(
} "Error: other plates already hold every required part. Use --quantity to nest more. Nothing saved."
else );
{ return (false, sw.ElapsedMilliseconds, false, plates);
foreach (var d in nest.Drawings)
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
} }
Console.WriteLine( Console.WriteLine(
@@ -637,6 +681,38 @@ static class NestConsole
return (success, sw.ElapsedMilliseconds, accepted, plates); return (success, sw.ElapsedMilliseconds, accepted, plates);
} }
/// <summary>
/// The job's demand: --quantity of each drawing when given, otherwise each drawing's
/// required quantity less the copies on other plates (the target's parts are replaced).
/// As in Desktop Auto Nest, a drawing without a required quantity is left out, unless no
/// drawing has one: then one of each is nested.
/// </summary>
static List<NestItem> AutoNestItems(Nest nest, Plate target, Drawing drawing, Options options)
{
var drawings = (options.DrawingName != null ? new[] { drawing } : nest.Drawings.ToArray())
.Where(d => !d.IsCutOff)
.ToList();
var anyRequired = drawings.Any(d => d.Quantity.Required > 0);
int Demand(Drawing d)
{
if (options.Quantity > 0)
return options.Quantity;
if (!anyRequired)
return 1;
var elsewhere = nest.Plates
.Where(p => p != target)
.Sum(p => p.Parts.Count(part => part.BaseDrawing == d) * p.Quantity);
return d.Quantity.Required - elsewhere;
}
return drawings
.Select(d => new NestItem { Drawing = d, Quantity = Demand(d) })
.Where(item => item.Quantity > 0)
.ToList();
}
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob( static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token) Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
{ {
@@ -713,14 +789,14 @@ static class NestConsole
var total = 0; var total = 0;
foreach (var plate in plates.Where(p => p.Parts.Count > 0)) foreach (var plate in plates.Where(p => p.Parts.Count > 0))
{ {
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts); var overlapping = PartOverlapChecker.FindOverlappingPairs(plate.Parts);
var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : ""; var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : "";
Console.WriteLine( Console.WriteLine(
hasOverlaps overlapping.Count > 0
? $"{label}OVERLAPS DETECTED: {overlapPts.Count} intersection points" ? $"{label}OVERLAPS DETECTED: {overlapping.Count} overlapping pair(s)"
: $"{label}Overlap check: PASS" : $"{label}Overlap check: PASS"
); );
total += overlapPts.Count; total += overlapping.Count;
} }
return total; return total;
@@ -824,6 +900,7 @@ static class NestConsole
{ {
CuttingPlanPhase.Reordering => "finding a part order...", CuttingPlanPhase.Reordering => "finding a part order...",
CuttingPlanPhase.KeepingOrder => "keeping the current order...", CuttingPlanPhase.KeepingOrder => "keeping the current order...",
CuttingPlanPhase.SafestRoute => "finding the safest route...",
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...", CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
_ => value.Phase + "...", _ => value.Phase + "...",
}); });
@@ -936,6 +1013,17 @@ static class NestConsole
return false; return false;
} }
// A common line cut once needs a post that writes it; none falls back to cutting it twice.
try
{
OpenNest.CNC.CommonLineCuts.EnsurePostable(nest, post);
}
catch (InvalidOperationException ex)
{
Console.Error.WriteLine($"Error: {ex.Message} No output was written.");
return false;
}
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post); var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
Console.WriteLine(verification.ToDisplayText()); Console.WriteLine(verification.ToDisplayText());
if (!verification.CanPost(options.AcknowledgePostRisks)) if (!verification.CanPost(options.AcknowledgePostRisks))
@@ -980,7 +1068,7 @@ static class NestConsole
Usage: OpenNest.Console <input-files...> [options] Usage: OpenNest.Console <input-files...> [options]
Arguments: Arguments:
input-files One or more .nest nest files or .dxf/.dwg drawing files input-files One or more .nest nest files or .dxf/.dwg/.svg drawing files
Modes: Modes:
<nest.nest> Load nest and fill (existing behavior) <nest.nest> Load nest and fill (existing behavior)
@@ -989,12 +1077,19 @@ static class NestConsole
Options: Options:
--repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm --repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm
--cad-units inches|mm Explicit source coordinate units required for bend repair --cad-units inches|mm Explicit source coordinate units required for bend repair;
also the units SVGs import in (default inches)
--svg-units-per-inch N SVG units per inch, for a file without a real-world size
--drawing <name> Drawing name to fill with (default: first drawing) --drawing <name> Drawing name to fill with (default: first drawing)
--plate <index> Plate index to fill (default: 0) --plate <index> Plate index to fill (default: 0)
--quantity <n> Max parts to place (default: 0 = unlimited) --quantity <n> Max parts to place (default: 0 = unlimited). With --autonest: copies of
each drawing (default: its required quantity less copies on other
plates; one of each when no drawing has a required quantity)
--spacing <value> Override part spacing --spacing <value> Override part spacing
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--outline <dxf> Make the plate an irregular sheet: the file's largest closed contour is
the sheet, other closed contours are obstacles; sets the size. Only
--autonest with the Irregular (or Default) engine nests on it
--output <path> Output nest file path (default: <input>-result.nest) --output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing) --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance --autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
@@ -1003,8 +1098,10 @@ static class NestConsole
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the --stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
target plate's size); spacing, quadrant and clamps follow the target target plate's size); spacing, quadrant and clamps follow the target
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2) --allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...). --engine <name> With --autonest: jobs engine (default: Default; also Irregular,
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant) Rectangles, Lattice, ...). Without --autonest: fill strategy
(Lattice (default), Strip, Vertical Remnant,
Horizontal Remnant)
--keep-parts Don't clear existing parts before filling --keep-parts Don't clear existing parts before filling
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it --no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order, --plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
@@ -1036,6 +1133,7 @@ static class NestConsole
public int Quantity; public int Quantity;
public double? Spacing; public double? Spacing;
public Size? PlateSize; public Size? PlateSize;
public string OutlineFile;
public bool CheckOverlaps; public bool CheckOverlaps;
public bool NoSave; public bool NoSave;
public bool KeepParts; public bool KeepParts;
@@ -1058,5 +1156,6 @@ static class NestConsole
public bool AllPlates; public bool AllPlates;
public double? RepairBendsMillimeters; public double? RepairBendsMillimeters;
public BendRepairUnits CadUnits; public BendRepairUnits CadUnits;
public double? SvgUnitsPerInch;
} }
} }
+1
View File
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.CutOffs;
namespace OpenNest; namespace OpenNest;
+2 -2
View File
@@ -62,8 +62,8 @@ namespace OpenNest.Bending
else else
{ {
var angle = StartPoint.AngleTo(EndPoint); var angle = StartPoint.AngleTo(EndPoint);
var dx = System.Math.Cos(angle) * etchLength; var dx = PortableMath.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength; var dy = PortableMath.Sin(angle) * etchLength;
result.Add( result.Add(
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)) CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
+11 -3
View File
@@ -21,12 +21,17 @@ namespace OpenNest.BestFit
IReadOnlyList<double> HullAngles 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( public sealed record SavedPairSet(
double PlateWidth, double PlateWidth,
double PlateHeight, double PlateHeight,
double Spacing, double Spacing,
IReadOnlyList<SavedPairResult> Results IReadOnlyList<SavedPairResult> Results,
double? CanonicalAngle = null
); );
/// <summary> /// <summary>
@@ -45,7 +50,10 @@ namespace OpenNest.BestFit
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys 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); void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ namespace OpenNest.Breakoffs
{ {
/// <summary> /// <summary>
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the /// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
/// break layer between tabs, each travelling with the scrap on its left so the cutting /// break layer between tabs, each traveling with the scrap on its left so the cutting
/// strategies pierce and lead in from the scrap. /// strategies pierce and lead in from the scrap.
/// </summary> /// </summary>
public static class BreakoffBuilder public static class BreakoffBuilder
+1 -1
View File
@@ -49,7 +49,7 @@ namespace OpenNest.Breakoffs
/// <summary> /// <summary>
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the /// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the
/// joining line and keeps a gap from its neighbours. /// joining line and keeps a gap from its neighbors.
/// </summary> /// </summary>
public double ClampTabCenter(int index, double center) public double ClampTabCenter(int index, double center)
{ {
+4 -4
View File
@@ -153,8 +153,8 @@ namespace OpenNest.Breakoffs
} }
var arc = (Arc)e; var arc = (Arc)e;
var angle = ArcAngleAt(arc, local); var angle = ArcAngleAt(arc, local);
return new Vector(arc.Center.X + arc.Radius * System.Math.Cos(angle), return new Vector(arc.Center.X + arc.Radius * PortableMath.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle)); arc.Center.Y + arc.Radius * PortableMath.Sin(angle));
} }
private static Vector TangentOn(Entity e, double local) private static Vector TangentOn(Entity e, double local)
@@ -168,8 +168,8 @@ namespace OpenNest.Breakoffs
var arc = (Arc)e; var arc = (Arc)e;
var angle = ArcAngleAt(arc, local); var angle = ArcAngleAt(arc, local);
return arc.IsReversed return arc.IsReversed
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle)) ? new Vector(PortableMath.Sin(angle), -PortableMath.Cos(angle))
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle)); : new Vector(-PortableMath.Sin(angle), PortableMath.Cos(angle));
} }
private static double ArcAngleAt(Arc arc, double local) => private static double ArcAngleAt(Arc arc, double local) =>
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Linq;
namespace OpenNest.CNC
{
/// <summary>
/// How a planned program cuts a common line once (see docs/common-line-cutting.md). A shared cut
/// is a run of <see cref="LayerType.CommonLine"/> moves: an open centerline cut made without
/// kerf compensation, with straight leads on its own axis. A remainder (a member's perimeter
/// between its shared spans) is an open contour whose rapid is followed directly by one of the
/// <see cref="RemainderMarker(RotationType)"/> comments, naming the winding of the perimeter it
/// belongs to, so a post compensates it on the perimeter's side even though it encloses nothing.
/// </summary>
public static class CommonLineCuts
{
/// <summary>The comment after the rapid into a remainder of a clockwise perimeter.</summary>
public const string ClockwiseRemainder = "COMMON LINE REMAINDER CW";
/// <summary>The comment after the rapid into a remainder of a counterclockwise perimeter.</summary>
public const string CounterclockwiseRemainder = "COMMON LINE REMAINDER CCW";
/// <summary>The remainder comment for a perimeter wound <paramref name="winding"/>.</summary>
public static string RemainderMarker(RotationType winding) =>
winding == RotationType.CW ? ClockwiseRemainder : CounterclockwiseRemainder;
/// <summary>The perimeter winding a remainder comment names, or null for any other code.</summary>
public static RotationType? RemainderWinding(ICode code) => code switch
{
Comment { Value: ClockwiseRemainder } => RotationType.CW,
Comment { Value: CounterclockwiseRemainder } => RotationType.CCW,
_ => null,
};
/// <summary>Whether <paramref name="program"/>'s own codes cut a common line once.</summary>
public static bool IsSinglePass(Program program) =>
program?.Codes != null && program.Codes.Any(code =>
code is LinearMove { Layer: LayerType.CommonLine } or ArcMove { Layer: LayerType.CommonLine }
|| RemainderWinding(code) != null);
/// <summary>
/// Refuses, before any output, a nest with a common line cut once when
/// <paramref name="postProcessor"/> cannot write shared cuts and remainders: it would cut the
/// shared cut with compensation (into a part) or a remainder on the wrong side. Posts never
/// fall back to cutting the line twice; set the plate to cut its common lines twice instead.
/// </summary>
/// <exception cref="InvalidOperationException">The post cannot write the nest.</exception>
public static void EnsurePostable(Nest nest, IPostProcessor postProcessor)
{
ArgumentNullException.ThrowIfNull(nest);
if (postProcessor is ICommonLinePostProcessor)
return;
for (var p = 0; p < nest.Plates.Count; p++)
{
var plate = nest.Plates[p];
for (var i = 0; i < plate.Parts.Count; i++)
if (IsSinglePass(plate.Parts[i]?.Program))
throw new InvalidOperationException(
$"Plate {p + 1}, part {i + 1} cuts a common line once, but the "
+ $"{postProcessor?.Name ?? "selected"} post cannot write single-pass common lines. "
+ "Set the plate to cut its common lines twice (Plate > Arrange > Cut Common Lines Twice), "
+ "plan cutting again, then post.");
}
}
}
/// <summary>
/// A post that writes common lines cut once (<see cref="CommonLineCuts"/>): each shared cut
/// without kerf compensation and each remainder compensated on its perimeter's side. Posts
/// without it refuse such a nest (<see cref="CommonLineCuts.EnsurePostable"/>).
/// </summary>
public interface ICommonLinePostProcessor : IPostProcessor
{
}
}
@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.CNC; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.CNC
{ {
/// <summary> /// <summary>
/// One world-space point on a cut contour together with the cut direction there. /// One world-space point on a cut contour together with the cut direction there.
@@ -80,7 +80,7 @@ namespace OpenNest.Geometry
var dirX = dx / length; var dirX = dx / length;
var dirY = dy / length; var dirY = dy / length;
var tangent = System.Math.Atan2(dirY, dirX); var tangent = PortableMath.Atan2(dirY, dirX);
var count = System.Math.Max(1, (int)(length / spacing)); var count = System.Math.Max(1, (int)(length / spacing));
var step = length / (count + 1); var step = length / (count + 1);
@@ -114,8 +114,8 @@ namespace OpenNest.Geometry
if (radius < Tolerance.Epsilon) if (radius < Tolerance.Epsilon)
return; return;
var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X); var startAngle = PortableMath.Atan2(start.Y - center.Y, start.X - center.X);
var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X); var endAngle = PortableMath.Atan2(end.Y - center.Y, end.X - center.X);
double sweep; double sweep;
if (rotation == RotationType.CCW) if (rotation == RotationType.CCW)
@@ -147,8 +147,8 @@ namespace OpenNest.Geometry
angle = startAngle - stepAngle * i; angle = startAngle - stepAngle * i;
var pt = new Vector( var pt = new Vector(
center.X + radius * System.Math.Cos(angle), center.X + radius * PortableMath.Cos(angle),
center.Y + radius * System.Math.Sin(angle) center.Y + radius * PortableMath.Sin(angle)
); );
double tangent; double tangent;
@@ -157,7 +157,7 @@ namespace OpenNest.Geometry
else else
tangent = angle - System.Math.PI / 2; tangent = angle - System.Math.PI / 2;
var dir = new Vector(System.Math.Cos(tangent), System.Math.Sin(tangent)); var dir = new Vector(PortableMath.Cos(tangent), PortableMath.Sin(tangent));
var at = radius * System.Math.Abs(stepAngle * i); var at = radius * System.Math.Abs(stepAngle * i);
output.Add(new ContourSample(pt, dir, tangent, at)); output.Add(new ContourSample(pt, dir, tangent, at));
} }
@@ -325,7 +325,7 @@ namespace OpenNest.Geometry
var local = segLen > 0 ? (s - segStart) / segLen : 0.0; var local = segLen > 0 ? (s - segStart) / segLen : 0.0;
var dir = SegmentDirection(a, b); var dir = SegmentDirection(a, b);
var pt = new Vector(a.X + (b.X - a.X) * local, a.Y + (b.Y - a.Y) * local); var pt = new Vector(a.X + (b.X - a.X) * local, a.Y + (b.Y - a.Y) * local);
output.Add(new ContourSample(pt, dir, System.Math.Atan2(dir.Y, dir.X), s)); output.Add(new ContourSample(pt, dir, PortableMath.Atan2(dir.Y, dir.X), s));
} }
} }
@@ -357,8 +357,8 @@ namespace OpenNest.Geometry
// Same sweep convention as ArcMoves: always in (0, 2*PI], so a full // Same sweep convention as ArcMoves: always in (0, 2*PI], so a full
// circle counts its whole circumference toward the walk's arclength. // circle counts its whole circumference toward the walk's arclength.
var startAngle = System.Math.Atan2(start.Y - center.Y, start.X - center.X); var startAngle = PortableMath.Atan2(start.Y - center.Y, start.X - center.X);
var endAngle = System.Math.Atan2(end.Y - center.Y, end.X - center.X); var endAngle = PortableMath.Atan2(end.Y - center.Y, end.X - center.X);
var sweep = var sweep =
rotation == RotationType.CCW ? endAngle - startAngle : startAngle - endAngle; rotation == RotationType.CCW ? endAngle - startAngle : startAngle - endAngle;
if (sweep <= 0) if (sweep <= 0)
+5 -5
View File
@@ -123,7 +123,7 @@ namespace OpenNest.CNC
{ {
var angle = StartAngle + Sweep * fraction; var angle = StartAngle + Sweep * fraction;
var sign = System.Math.Sign(Sweep); var sign = System.Math.Sign(Sweep);
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign); return new Vector(-PortableMath.Sin(angle) * sign, PortableMath.Cos(angle) * sign);
} }
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero; return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
} }
@@ -137,8 +137,8 @@ namespace OpenNest.CNC
{ {
var angle = StartAngle + Sweep * fraction; var angle = StartAngle + Sweep * fraction;
return new Vector( return new Vector(
Center.X + Radius * System.Math.Cos(angle), Center.X + Radius * PortableMath.Cos(angle),
Center.Y + Radius * System.Math.Sin(angle)); Center.Y + Radius * PortableMath.Sin(angle));
} }
return new Vector( return new Vector(
@@ -238,7 +238,7 @@ namespace OpenNest.CNC
builder.PartIndex = i; builder.PartIndex = i;
var mode = Mode.Absolute; var mode = Mode.Absolute;
var pos = part.Location; 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); var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
@@ -379,7 +379,7 @@ namespace OpenNest.CNC
{ {
// Cutting strategies emit contour moves on Display; the posts cut them. // Cutting strategies emit contour moves on Display; the posts cut them.
LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break
=> CutSimulationStepKind.Cut, or LayerType.CommonLine => CutSimulationStepKind.Cut,
LayerType.Scribe => CutSimulationStepKind.Scribe, LayerType.Scribe => CutSimulationStepKind.Scribe,
_ => null, // Tab spans are never cut. _ => null, // Tab spans are never cut.
}; };
@@ -36,14 +36,45 @@ internal enum AutomaticEntryKind
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary> /// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
Pinned = 7, 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 catalog.
/// </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> /// <summary>
/// One automatic start candidate: the owned contour choice plus its preference kind and a /// One automatic start candidate: the owned contour choice plus its preference kind and a
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as /// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
/// owner; nothing here exposes or mutates the underlying shape. /// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
/// ranking can count it on both of that corner's sides.
/// </summary> /// </summary>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind) internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
{ {
/// <summary> /// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so /// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
@@ -7,7 +7,7 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
/// <summary> /// <summary>
/// Pure deterministic ordering of the automatic entry catalogue toward the next cut: /// Pure deterministic ordering of the automatic entry catalog toward the next cut:
/// circles rank by outgoing distance; other contours use facing sides, tier and travel, /// circles rank by outgoing distance; other contours use facing sides, tier and travel,
/// then a stable geometric key. It adds no /// then a stable geometric key. It adds no
/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility /// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
@@ -62,12 +62,12 @@ internal static class ContourEntryRanking
if (p.Y > maxY) maxY = p.Y; if (p.Y > maxY) maxY = p.Y;
} }
// Facing sides from the target relative to the centre, matching the source plan: // Facing sides from the target relative to the center, matching the source plan:
// horizontal right when the target is right of centre else left; vertical likewise. // horizontal right when the target is right of center else left; vertical likewise.
var centreX = minX + (maxX - minX) * 0.5; var centerX = minX + (maxX - minX) * 0.5;
var centreY = minY + (maxY - minY) * 0.5; var centerY = minY + (maxY - minY) * 0.5;
var facingRight = target != null && target.Value.X > centreX; var facingRight = target != null && target.Value.X > centerX;
var facingTop = target != null && target.Value.Y > centreY; var facingTop = target != null && target.Value.Y > centerY;
return candidates return candidates
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival))) .Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
if (target != null) if (target != null)
{ {
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two // Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
// sides within tolerance); count how many of them are facing sides. // sides within tolerance, a fillet's joint within its reach of the corner it
// rounds); count how many of them are facing sides.
var left = p.X - minX; var left = p.X - minX;
var right = maxX - p.X; var right = maxX - p.X;
var bottom = p.Y - minY; var bottom = p.Y - minY;
var top = maxY - p.Y; var top = maxY - p.Y;
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top)); var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++; var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++; if (facingRight && right <= near) facing++;
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++; if (!facingRight && left <= near) facing++;
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++; if (facingTop && top <= near) facing++;
if (!facingTop && bottom <= near) facing++;
} }
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0) var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ (target != null ? p.DistanceTo(target.Value) : 0.0); + (target != null ? p.DistanceTo(target.Value) : 0.0);
@@ -47,7 +47,8 @@ internal static class ContourProgramVerifier
if (material == null || !material.IsComplete) return false; if (material == null || !material.IsComplete) return false;
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false; if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
var visited = new HashSet<int>(); var visited = new HashSet<int>();
var piecesCut = new Dictionary<int, HashSet<int>>(); // Per tabbed ring: what each run cut, as (span start, from, to) along its span.
var piecesCut = new Dictionary<int, List<(int Span, double From, double To)>>();
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut)) foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -55,32 +56,34 @@ internal static class ContourProgramVerifier
var first = run.Curves[0]; var first = run.Curves[0];
var ringIndex = -1; var ringIndex = -1;
var entityIndex = -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]); 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); ringIndex = r;
var curve = material.Rings[r][e]; entityIndex = e;
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted) break;
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
ringIndex = r;
entityIndex = e;
break;
}
} }
} }
if (ringIndex < 0) return false; 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]; var tabs = material.Tabs[ringIndex];
if (tabs.Any(tab => tab)) if (tabs.Any(tab => tab))
{ {
// A tabbed cutout: each run cuts exactly one span between tabs, whole and once. // A tabbed ring: each run follows one span between tabs and never crosses a tab. A
if (!CutsTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget) // span is usually one run; a common-line member that starts on its common line cuts
|| !(piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(entityIndex)) // the span holding that start in two, each from where the other began or ended.
if (CutTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget)
is not { } piece)
return false; return false;
if (piecesCut[ringIndex].Count == SpanStarts(tabs).Count) (piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(piece);
visited.Add(ringIndex);
continue; continue;
} }
if (!visited.Add(ringIndex)) return false; if (!visited.Add(ringIndex)) return false;
@@ -108,26 +111,69 @@ internal static class ContourProgramVerifier
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start), if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
true, tolerance, token, ref budget)) return false; true, tolerance, token, ref budget)) return false;
} }
return visited.Count == material.Rings.Count; // Every span of a tabbed ring is cut exactly once: its pieces tile it with no gap or overlap.
foreach (var (ringIndex, pieces) in piecesCut)
{
var ring = material.Rings[ringIndex];
var tabs = material.Tabs[ringIndex];
var tolerance = AdmittedClosureSlack(ring);
foreach (var start in SpanStarts(tabs))
{
var length = Span(ring, tabs, start).Sum(c => c.Length);
var at = 0.0;
foreach (var piece in pieces.Where(p => p.Span == start).OrderBy(p => p.From))
{
if (System.Math.Abs(piece.From - at) > tolerance)
return false;
at = piece.To;
}
if (System.Math.Abs(length - at) > tolerance)
return false;
}
visited.Add(ringIndex);
}
return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
} }
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions. // Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length) private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length)
.Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList(); .Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList();
// A run that starts exactly where a span between tabs starts and covers that span exactly, // What a run along a tabbed ring cuts: its span (by the span's first curve) and where along the
// stopping at the next tab. // span it starts and ends. Null unless the run follows the ring from its start without
private static bool CutsTabbedSpan(IReadOnlyList<Curve> run, IReadOnlyList<Curve> ring, bool[] tabs, // reaching past the span's next tab.
int entityIndex, CancellationToken token, ref int budget) private static (int Span, double From, double To)? CutTabbedSpan(IReadOnlyList<Curve> run,
IReadOnlyList<Curve> ring, bool[] tabs, int entityIndex, CancellationToken token, ref int budget)
{ {
if (tabs[entityIndex])
return null;
var tolerance = AdmittedClosureSlack(ring); var tolerance = AdmittedClosureSlack(ring);
if (!SpanStarts(tabs).Contains(entityIndex) || ring[entityIndex].DistanceAlong(run[0].Start) > tolerance) var count = tabs.Length;
return false; var start = entityIndex;
var from = ring[entityIndex].DistanceAlong(run[0].Start);
for (var guard = 0; guard < count && !tabs[(start + count - 1) % count]; guard++)
{
start = (start + count - 1) % count;
from += ring[start].Length;
}
var rest = new List<Curve>();
for (var e = entityIndex; !tabs[e % count] && rest.Count < count; e++)
rest.Add(ring[e % count]);
var length = run.Sum(c => c.Length);
var offset = ring[entityIndex].DistanceAlong(run[0].Start);
if (length > rest.Sum(c => c.Length) - offset + tolerance
|| !Follows(run, rest, 0, offset, false, tolerance, token, ref budget, partial: true))
return null;
return (start, from, from + length);
}
// The curves of the span between tabs that starts at curve start.
private static List<Curve> Span(IReadOnlyList<Curve> ring, bool[] tabs, int start)
{
var span = new List<Curve>(); var span = new List<Curve>();
for (var e = entityIndex; !tabs[e % tabs.Length]; e++) for (var e = start; !tabs[e % tabs.Length] && span.Count < tabs.Length; e++)
span.Add(ring[e % tabs.Length]); span.Add(ring[e % tabs.Length]);
return System.Math.Abs(run.Sum(c => c.Length) - span.Sum(c => c.Length)) <= tolerance return span;
&& Follows(run, span, 0, 0, false, tolerance, token, ref budget);
} }
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure // Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
@@ -162,8 +208,10 @@ internal static class ContourProgramVerifier
// collinear moves and a cyclic ring's reindexing do not change coverage. // collinear moves and a cyclic ring's reindexing do not change coverage.
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon // `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
// unless the nominal ring closes only within the nominal closure tolerance). // unless the nominal ring closes only within the nominal closure tolerance).
// `partial` accepts an actual stream that ends before the expected one does.
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected, private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget) int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget,
bool partial = false)
{ {
foreach (var curve in actual) foreach (var curve in actual)
{ {
@@ -200,7 +248,7 @@ internal static class ContourProgramVerifier
} }
} }
} }
return cyclic || index == expected.Count || index == expected.Count - 1 return cyclic || partial || index == expected.Count || index == expected.Count - 1
&& expected[index].Length - offset <= tolerance; && expected[index].Length - offset <= tolerance;
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.CNC.CuttingStrategy; using OpenNest.CNC.CuttingStrategy;
using OpenNest.CutOffs;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
@@ -22,20 +23,39 @@ internal sealed class PlannedPartProgram
public CuttingParameters Parameters { get; } 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> /// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
internal sealed class PlateCuttingPlan internal sealed class PlateCuttingPlan
{ {
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order, public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null) IEnumerable<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = null)
{ {
Expected = expected; Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray()); Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray()); Programs = Array.AsReadOnly((programs ?? []).ToArray());
CutOffs = Array.AsReadOnly((cutOffs ?? []).ToArray());
} }
public PlateCuttingState Expected { get; } public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; } public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; } public IReadOnlyList<PlannedPartProgram> Programs { get; }
public IReadOnlyList<PlannedCutOffProgram> CutOffs { get; }
} }
public enum CuttingCommitStatus public enum CuttingCommitStatus
@@ -43,7 +63,7 @@ public enum CuttingCommitStatus
Applied, Applied,
Stale, Stale,
InvalidInput, InvalidInput,
Cancelled, Canceled,
Failed Failed
} }
@@ -88,7 +108,7 @@ internal static class CuttingPlanCommit
Action<Plate, Part> beforeInstall) Action<Plate, Part> beforeInstall)
{ {
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit."); return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
var scope = plans?.ToArray(); var scope = plans?.ToArray();
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null)) if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
return Invalid(null, "A nonempty set of captured plate plans is required."); return Invalid(null, "A nonempty set of captured plate plans is required.");
@@ -107,7 +127,7 @@ internal static class CuttingPlanCommit
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit."); return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
} }
if (difference != null) if (difference != null)
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate); return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
@@ -161,27 +181,49 @@ internal static class CuttingPlanCommit
} }
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters)); 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); 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. // Last cancellation point. The boundary below has no await, search or geometry work.
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit."); return new(CuttingCommitStatus.Canceled, "Canceled 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 try
{ {
foreach (var item in staged) foreach (var item in staged)
{ {
undo.Add((item.Plate, item.Plate.Parts.ToArray(), 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); item.Plate.Parts.SetOrder(item.Order);
foreach (var (part, program, bounds, parameters) in item.Programs) foreach (var (part, program, bounds, parameters) in item.Programs)
{ {
beforeInstall?.Invoke(item.Plate, part); beforeInstall?.Invoke(item.Plate, part);
part.InstallPlannedProgram(program, bounds, parameters); 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) catch (Exception ex)
@@ -190,6 +232,11 @@ internal static class CuttingPlanCommit
{ {
foreach (var (part, state) in undo[i].States) foreach (var (part, state) in undo[i].States)
part.RestoreCuttingState(state); 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); undo[i].Plate.Parts.SetOrder(undo[i].Order);
} }
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.", return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
@@ -208,5 +255,6 @@ internal static class CuttingPlanCommit
new(CuttingCommitStatus.InvalidInput, message, plate); new(CuttingCommitStatus.InvalidInput, message, plate);
private sealed record Staged(Plate Plate, Part[] Order, 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);
} }
@@ -27,10 +27,12 @@ public static class ExecutionMotionReader
var moves = new List<ExecutionMotion>(); var moves = new List<ExecutionMotion>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance); var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000; var budget = 1000000;
Walk(program, origin, previous); Walk(program, origin, previous, false, null);
return new OwnedExecution(moves); return new OwnedExecution(moves);
Vector Walk(Program current, Vector frame, Vector? arrival) // raiseArrival: a marked call raises the head for the rapid that arrives at its first motion.
// callCode: the part's own code that called this sub-program, or null for the part's own codes.
Vector Walk(Program current, Vector frame, Vector? arrival, bool raiseArrival, int? callCode)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame); PostVerificationGeometry.Validate(frame);
@@ -41,13 +43,24 @@ public static class ExecutionMotionReader
var pos = frame; var pos = frame;
var first = true; var first = true;
var countBefore = moves.Count; var countBefore = moves.Count;
foreach (var code in current.Codes) var raised = HeadRaise.RaisedMask(current.Codes);
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
{ {
var code = current.Codes[codeIndex];
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (--budget < 0) if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit."); throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null) if (code == null)
throw new ArgumentException("Program contains a missing instruction."); 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) if (requireSupportedTypes)
{ {
var type = code.GetType(); var type = code.GetType();
@@ -61,7 +74,8 @@ public static class ExecutionMotionReader
throw new ArgumentException("Subprogram rotation is not finite."); throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not // Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode. // rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos); pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos,
raised[codeIndex] || first && raiseArrival, callCode ?? codeIndex);
first = false; first = false;
continue; continue;
} }
@@ -82,7 +96,7 @@ public static class ExecutionMotionReader
PostVerificationGeometry.Validate(end); PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove; var rapid = motion is RapidMove;
if (first && !rapid) if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null)); moves.Add(new(arrival, frame, true, LayerType.Display, null, raiseArrival, callCode: callCode));
var start = first && rapid ? arrival : pos; var start = first && rapid ? arrival : pos;
var layer = motion switch var layer = motion switch
{ {
@@ -97,7 +111,8 @@ public static class ExecutionMotionReader
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end, var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null, motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW }); motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve)); moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex] || first && rapid && raiseArrival,
ReferenceEquals(current, program) ? codeIndex : null, callCode));
pos = end; pos = end;
first = false; first = false;
} }
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Clamps;
using OpenNest.CNC.CuttingStrategy; using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics; using OpenNest.Diagnostics;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -49,8 +49,8 @@ internal static class IncrementalClosureDrift
// The chains material capture closes: cut, display and tab moves between rapids and other layers. // The chains material capture closes: cut, display and tab moves between rapids and other layers.
private static bool IsContour(Motion motion) => !motion.Suppressed private static bool IsContour(Motion motion) => !motion.Suppressed
&& motion is LinearMove { 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 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) private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
{ {
@@ -79,7 +79,7 @@ internal static class IncrementalClosureDrift
var endPoint = last.EndPoint + correction; var endPoint = last.EndPoint + correction;
if (last is ArcMove arc) if (last is ArcMove arc)
{ {
// Keep the arc's centre (relative to its start) equidistant from its moved end. // Keep the arc's center (relative to its start) equidistant from its moved end.
var chord = endPoint.DistanceTo(new Vector()); var chord = endPoint.DistanceTo(new Vector());
if (chord <= Tolerance.Epsilon) if (chord <= Tolerance.Epsilon)
return false; return false;
@@ -12,8 +12,9 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class LeadMaterialSnapshot public sealed class LeadMaterialSnapshot
{ {
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs, private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
string reason) string reason, bool displayPerimeter = false)
{ {
HasDisplayPerimeter = displayPerimeter;
Rings = rings; Rings = rings;
Tabs = tabs; Tabs = tabs;
Reason = reason; Reason = reason;
@@ -41,10 +42,21 @@ public sealed class LeadMaterialSnapshot
/// </summary> /// </summary>
internal IReadOnlyList<bool[]> Tabs { get; } 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. /// <summary>Capture a stable, clean, rotation-baked program, applying location once.
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary> /// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location, public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
CancellationToken token = default) CancellationToken token = default) => Capture(cleanProgram, location, token, sharedSpans: false);
/// <param name="sharedSpans">A common-line member's clean program (docs/common-line-cutting.md):
/// its perimeter may carry Tab spans, the shared spans the member never cuts.</param>
internal static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
CancellationToken token, bool sharedSpans)
{ {
try try
{ {
@@ -53,6 +65,8 @@ public sealed class LeadMaterialSnapshot
var ringTabs = new List<bool[]>(); var ringTabs = new List<bool[]>();
var chain = new List<PostVerificationGeometry.Curve>(); var chain = new List<PostVerificationGeometry.Curve>();
var chainTabs = new List<bool>(); var chainTabs = new List<bool>();
var ringDisplay = new List<bool>();
var chainDisplay = new List<bool>();
foreach (var motion in execution.Motions) foreach (var motion in execution.Motions)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -63,7 +77,8 @@ public sealed class LeadMaterialSnapshot
continue; continue;
} }
// A tabbed cutout's tabs are never cut but still close its hole. // 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."); throw new ArgumentException("Material capture contains an unsupported layer.");
var curve = motion.Curve; var curve = motion.Curve;
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon) if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
@@ -72,6 +87,7 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material contour is discontinuous."); throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve); chain.Add(curve);
chainTabs.Add(motion.Layer == LayerType.Tab); chainTabs.Add(motion.Layer == LayerType.Tab);
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End)) if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish(); Finish();
} }
@@ -80,42 +96,46 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material has no closed contour."); throw new ArgumentException("Material has no closed contour.");
var budget = 1000000; var budget = 1000000;
// Certify simple rings and mutually disjoint boundaries before containment. // Certify simple rings and mutually disjoint boundaries before containment.
for (var r = 0; r < rings.Count; r++) foreach (var (r, i, s, j) in ContactCandidates(rings, token))
for (var s = r; s < rings.Count; s++) {
for (var i = 0; i < rings[r].Length; i++) token.ThrowIfCancellationRequested();
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++) if (--budget < 0)
{ throw new NotSupportedException("Material validation exceeds the native query limit.");
token.ThrowIfCancellationRequested(); var a = rings[r][i];
if (--budget < 0) var b = rings[s][j];
throw new NotSupportedException("Material validation exceeds the native query limit."); var contacts = a.Contacts(b, out var overlap);
var a = rings[r][i]; var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
var b = rings[s][j]; var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
var contacts = a.Contacts(b, out var overlap); && (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1)); || a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc // Native arc queries pad angles by 1e-5 radians, admitting
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon // intersections on an extension (distance grows with radius).
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon); // At a shared arc joint require both actual finite spans;
// Native arc queries pad angles by 1e-5 radians, admitting // overlap and genuine second intersections still refuse.
// intersections on an extension (distance grows with radius). if (arcJoint)
// At a shared arc joint require both actual finite spans; contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// overlap and genuine second intersections still refuse. // Native contacts at rounded arc/line joints can drift from the
if (arcJoint) // authored endpoint. Use the shared geometry tolerance only to
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray(); // recognize that joint; overlaps and other contacts still refuse.
// Native contacts at rounded arc/line joints can drift from the if (overlap || contacts.Any(p => !adjacent
// authored endpoint. Use the shared geometry tolerance only to || !(p.DistanceTo(a.End) <= Tolerance.Epsilon
// recognize that joint; overlaps and other contacts still refuse. && p.DistanceTo(b.Start) <= Tolerance.Epsilon)
if (overlap || contacts.Any(p => !adjacent && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon && p.DistanceTo(b.End) <= Tolerance.Epsilon)))
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon) throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon ? "A display perimeter must enclose every other contour."
&& p.DistanceTo(b.End) <= Tolerance.Epsilon))) : $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); }
} // A ring encloses a point only inside the union of its curves' extents (each at least
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
(extent, curve) => extent.Union(curve.Extent))).ToArray();
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
var outer = -1; var outer = -1;
for (var i = 0; i < rings.Count; i++) for (var i = 0; i < rings.Count; i++)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token))) if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
{ {
if (outer >= 0) if (outer >= 0)
throw new ArgumentException("Material perimeter is ambiguous."); throw new ArgumentException("Material perimeter is ambiguous.");
@@ -123,16 +143,23 @@ public sealed class LeadMaterialSnapshot
} }
} }
if (outer < 0) if (outer < 0)
throw new ArgumentException("Material must have exactly one enclosing perimeter."); throw new ArgumentException(ringDisplay.Contains(true)
if (ringTabs[outer].Any(tab => tab)) ? "A display perimeter must enclose every other contour."
: "Material must have exactly one enclosing perimeter.");
if (!sharedSpans && ringTabs[outer].Any(tab => tab))
throw new ArgumentException("A perimeter cannot have tabs."); throw new ArgumentException("A perimeter cannot have tabs.");
var holes = rings.Where((_, i) => i != outer).ToArray(); if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
for (var i = 0; i < holes.Length; i++) throw new ArgumentException("A display perimeter must enclose every other contour.");
for (var j = i + 1; j < holes.Length; j++) if (ringDisplay[outer] && rings.Count == 1)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token)) throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
var holes = holeRings.Select(i => rings[i]).ToArray();
for (var i = 0; i < holeRings.Length; i++)
for (var j = i + 1; j < holeRings.Length; j++)
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
throw new ArgumentException("Nested cutouts are not supported material."); throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), 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() void Finish()
{ {
@@ -145,10 +172,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}.") $"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."); + " 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()); rings.Add(chain.ToArray());
ringTabs.Add(chainTabs.ToArray()); ringTabs.Add(chainTabs.ToArray());
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
chain.Clear(); chain.Clear();
chainTabs.Clear(); chainTabs.Clear();
chainDisplay.Clear();
} }
} }
catch (Exception ex) when (ex is ArgumentException or NotSupportedException) catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
@@ -157,18 +188,182 @@ public sealed class LeadMaterialSnapshot
} }
} }
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token) /// <summary>
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token)); /// 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'
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token) /// 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; var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
foreach (var curve in ring) 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(); for (var a = 0; a < curves.Count; a++)
if (curve.CrossesRay(point)) for (var b = a + 1; b < curves.Count; b++)
inside = !inside; 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);
} }
@@ -65,6 +65,17 @@ public static class LeadPathValidator
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var move = execution.Motions[i]; var move = execution.Motions[i];
if (!move.Rapid && move.Layer == LayerType.CommonLine)
{
// A shared cut runs in the kerf between two parts: it may touch no material at all.
var (shared, sharedObstacle) = CheckSharedCut(i);
if (shared == null)
continue;
if (isFixed?.Invoke(i) != true || fixedFailures == null)
return shared;
fixedFailures.Add(new(i, shared, sharedObstacle));
continue;
}
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout)) if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
continue; continue;
var (failure, obstacle) = CheckMotion(i); var (failure, obstacle) = CheckMotion(i);
@@ -78,6 +89,27 @@ public static class LeadPathValidator
// checks rapid travel and contour accounting for the complete program. // checks rapid travel and contour accounting for the complete program.
return new(true, true, null); return new(true, true, null);
(LeadPathValidationResult Failure, int? Obstacle) CheckSharedCut(int i)
{
var move = execution.Motions[i];
if (move.Curve == null || !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon)
return (new(false, false, "A common-line shared cut is missing, degenerate or inconsistent."), null);
if (MaterialContact(move.Curve, target, null, ref budget, token) is { } own)
return (new(true, false, $"Common-line shared cut {i} contacts its own part's material ({own}). "
+ "Rejoin the parts one kerf apart, then replan."), null);
for (var other = 0; other < otherMaterials.Count; other++)
{
token.ThrowIfCancellationRequested();
var material = otherMaterials[other];
if (ReferenceEquals(material, target) || move.Curve.Extent.IsClearOfQueriesFrom(material.Extent))
continue;
if (MaterialContact(move.Curve, material, null, ref budget, token) != null)
return (new(true, false, "A common-line shared cut contacts or enters another placed material. "
+ "Rejoin the parts one kerf apart at the current kerf, then replan."), other);
}
return (null, null);
}
(LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i) (LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
{ {
var move = execution.Motions[i]; var move = execution.Motions[i];
@@ -100,6 +132,7 @@ public static class LeadPathValidator
var adjacentIndex = groupEdge + step; var adjacentIndex = groupEdge + step;
var genuineJoint = false; var genuineJoint = false;
var breakLead = false; var breakLead = false;
var sharedLead = false;
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count) if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
{ {
var adjacent = execution.Motions[adjacentIndex]; var adjacent = execution.Motions[adjacentIndex];
@@ -119,13 +152,28 @@ public static class LeadPathValidator
allowed = joint; allowed = joint;
} }
} }
// A shared cut lies in the kerf between two parts, so its lead joins no boundary
// and may touch none: its joint is the cut's own start (or end for a lead-out).
else if (!adjacent.Rapid && adjacent.Layer == LayerType.CommonLine
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
{
var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon)
{
genuineJoint = true;
sharedLead = true;
}
}
else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon) && adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
{ {
var edge = execution.Motions[groupEdge]; var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start; var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End; 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(); token.ThrowIfCancellationRequested();
if (--budget < 0) if (--budget < 0)
@@ -153,11 +201,13 @@ public static class LeadPathValidator
// material is expected there, but touching any of its boundaries is not. // material is expected there, but touching any of its boundaries is not.
var failure = breakLead var failure = breakLead
? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true) ? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true)
: CheckMaterial(target, allowed); : CheckMaterial(target, sharedLead ? null : allowed);
if (failure != null) if (failure != null)
return (new(true, false, breakLead return (new(true, false, breakLead
? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs." ? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs."
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null); : sharedLead
? $"Common-line lead motion {i} contacts its part's material ({failure}). The lead runs along the shared line: shorten the lead-in or lead-out, or cut the common lines twice."
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
var reach = move.Curve.Extent; var reach = move.Curve.Extent;
for (var other = 0; other < otherMaterials.Count; other++) for (var other = 0; other < otherMaterials.Count; other++)
{ {
@@ -190,10 +240,7 @@ public static class LeadPathValidator
// Every boundary of material this far away is farther than the zone. // Every boundary of material this far away is farther than the zone.
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y) if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
.IsClearOfQueriesFrom(material.Extent, pierceRadius)) .IsClearOfQueriesFrom(material.Extent, pierceRadius))
{
Charge(material.BoundaryCount);
continue; continue;
}
if (Clearance(material) is { } distance) if (Clearance(material) is { } distance)
return (Short(distance, "another placed part"), other); return (Short(distance, "another placed part"), other);
} }
@@ -232,11 +279,11 @@ public static class LeadPathValidator
} }
// Material too far from the lead for any native boundary query to report a contact // 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. // (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
// Ray parity is still asked natively unless the lead lies beside the material's rows. // 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) bool FarInside(LeadMaterialSnapshot material)
{ {
Charge(material.BoundaryCount);
var midpoint = move.Curve.Midpoint; var midpoint = move.Curve.Midpoint;
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token); return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
} }
@@ -289,19 +336,32 @@ public static class LeadPathValidator
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false) 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(); token.ThrowIfCancellationRequested();
if (--budget < 0) if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit."); throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = curve.Contacts(boundary, out var overlap); var contacts = curve.Contacts(boundary, out var overlap);
if (overlap || contacts.Any(p => permittedJoint is not { } joint if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}"; return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
} }
// With all other boundary contacts excluded, the connected open path // With all other boundary contacts excluded, the connected open path
// has constant material membership. Use the native arc midpoint, not // has constant material membership. Use the native arc midpoint, not
// the chord midpoint or endpoints (which can both lie in scrap). // the chord midpoint or endpoints (which can both lie in scrap).
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null; return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
// A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a
// double root at the joint that rounding splits into two contacts a few 1e-8 apart; one
// within JointTolerance of a joint the boundary itself passes through is still the joint.
static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary)
{
var distance = point.DistanceTo(joint);
return distance <= PostVerificationGeometry.Epsilon
|| distance <= JointTolerance && boundary.Contains(joint);
}
} }
// As SlideContact's: far below any kerf, far above a tangent root's rounding drift.
private const double JointTolerance = 1e-6;
} }
@@ -2,7 +2,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
@@ -54,19 +53,6 @@ internal static class NestedPartContainment
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary> /// <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) => internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions 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(); .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>
internal static bool Within(Box inner, Box outer) =>
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
/// <summary>The union of two boxes.</summary>
internal static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
}
} }
@@ -37,7 +37,7 @@ internal static class NestedPartReview
continue; continue;
var program = FixedLeadReview.Clean(part); var program = FixedLeadReview.Clean(part);
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token); cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
hostBounds[index] = NestedPartContainment.Union(part.BoundingBox, cleanBounds[index]); hostBounds[index] = BoundingBox.Union(part.BoundingBox, cleanBounds[index]);
clean[index] = program; clean[index] = program;
} }
catch (Exception exception) when (IsInvalid(exception)) catch (Exception exception) when (IsInvalid(exception))
@@ -52,7 +52,7 @@ internal static class NestedPartReview
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (inner == host || clean[inner] == null || clean[host] == null if (inner == host || clean[inner] == null || clean[host] == null
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host])) || !BoundingBox.IsWithin(cleanBounds[inner], hostBounds[host]))
continue; continue;
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token, var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
out var ambiguity); out var ambiguity);
@@ -33,18 +33,20 @@ internal static class OwnedCuttingParameters
PierceDiameter = Dimension(source.PierceDiameter), PierceDiameter = Dimension(source.PierceDiameter),
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn), ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
InternalLeadIn = CopyLeadIn(source.InternalLeadIn), InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut), ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
InternalLeadOut = CopyLeadOut(source.InternalLeadOut), InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
RoundLeadInAngles = source.RoundLeadInAngles, RoundLeadInAngles = source.RoundLeadInAngles,
LeadInAngleIncrement = source.LeadInAngleIncrement, LeadInAngleIncrement = source.LeadInAngleIncrement,
AutoTabMinSize = Dimension(source.AutoTabMinSize),
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
TabConfig = source.TabConfig == null TabConfig = source.TabConfig == null
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null ? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
: CopyTab(source.TabConfig), : CopyTab(source.TabConfig),
TabsEnabled = source.TabsEnabled, TabsEnabled = source.TabsEnabled,
SupportTabs = source.SupportTabs,
TabBySize = source.TabBySize,
TabSizeMinWidth = Dimension(source.TabSizeMinWidth),
TabSizeMaxWidth = Dimension(source.TabSizeMaxWidth),
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
SliceTippingSlugs = source.SliceTippingSlugs,
Sequencing = new SequenceParameters Sequencing = new SequenceParameters
{ {
Method = sequence.Method, Method = sequence.Method,
@@ -71,7 +73,8 @@ internal static class OwnedCuttingParameters
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(), NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) }, { 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 LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) }, { LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
@@ -137,4 +140,7 @@ internal static class OwnedCuttingParameters
// Approach angles are degrees, not lengths. Signed and periodic angles are valid. // Approach angles are degrees, not lengths. Signed and periodic angles are valid.
private static double Angle(double value) => double.IsFinite(value) private static double Angle(double value) => double.IsFinite(value)
? value : throw new ArgumentException("Angles must be finite."); ? 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.");
} }
@@ -38,20 +38,38 @@ public sealed class OwnedExecution
public sealed class ExecutionMotion public sealed class ExecutionMotion
{ {
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer, internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve) PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null, int? callCode = null)
{ {
Start = start; Start = start;
End = end; End = end;
Rapid = rapid; Rapid = rapid;
Layer = layer; Layer = layer;
Curve = curve; Curve = curve;
HeadRaised = headRaised;
Code = code;
CallCode = callCode;
} }
public Vector? Start { get; } public Vector? Start { get; }
public Vector End { get; } public Vector End { get; }
public bool Rapid { get; } public bool Rapid { get; }
public LayerType Layer { get; } public LayerType Layer { get; }
/// <summary>A rapid made with the head raised over parts already cut (<see cref="HeadRaise"/>).</summary>
public bool HeadRaised { get; }
/// <summary>
/// The index of the code this motion runs in the read program's own codes; null for a motion
/// inside a sub-program and for the rapid a program that opens with a cut is given.
/// </summary>
public int? Code { get; }
/// <summary>
/// For a motion inside a sub-program, the index of the read program's own call that runs it;
/// null for the program's own motions.
/// </summary>
internal int? CallCode { get; }
public double Length => Curve?.Length ?? 0; public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; } internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve); internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code, CallCode);
} }
@@ -4,6 +4,8 @@ using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.CNC.CuttingStrategy; using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.CommonLines;
using OpenNest.CutOffs;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
@@ -27,10 +29,17 @@ public sealed class PlateCuttingState
private readonly Dictionary<Drawing, DrawingRecord> drawings; private readonly Dictionary<Drawing, DrawingRecord> drawings;
private readonly Dictionary<CuttingParameters, string> settings; private readonly Dictionary<CuttingParameters, string> settings;
private readonly CuttingParameters plateSettings; private readonly CuttingParameters plateSettings;
private readonly ObservableList<CommonLineGroup> commonLineList;
private readonly CommonLineRecord[] commonLines;
private readonly bool cutCommonLinesTwice;
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs, private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings) Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
{ {
commonLineList = plate.CommonLines;
commonLines = plate.CommonLines.Select(group => new CommonLineRecord(group, group.Kerf,
group.Members.ToArray())).ToArray();
cutCommonLinesTwice = plate.CutCommonLinesTwice;
this.settings = settings; this.settings = settings;
plateSettings = plate.CuttingParameters; plateSettings = plate.CuttingParameters;
Plate = plate; Plate = plate;
@@ -82,7 +91,8 @@ public sealed class PlateCuttingState
} }
var cutOffs = plate.CutOffs.Select(c => c == null var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.") ? 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.Gaps,
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings); return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters) void Fingerprint(CuttingParameters parameters)
@@ -155,9 +165,24 @@ public sealed class PlateCuttingState
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing) if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X) || cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y) || !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)
// Gaps are replaced, never edited in place, so the reference decides.
|| !ReferenceEquals(cutOff.Gaps, record.Gaps)
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
return $"Cutoff {i + 1} definition changed."; return $"Cutoff {i + 1} definition changed.";
} }
// Common lines decide which edges each member cuts, and whether they are cut once.
if (!ReferenceEquals(plate.CommonLines, commonLineList) || plate.CutCommonLinesTwice != cutCommonLinesTwice
|| plate.CommonLines.Count != commonLines.Length)
return "Common lines changed.";
for (var i = 0; i < commonLines.Length; i++)
{
var record = commonLines[i];
var group = plate.CommonLines[i];
if (!ReferenceEquals(group, record.Group) || !Bits(group.Kerf, record.Kerf)
|| !group.Members.SequenceEqual(record.Members, ReferenceEqualityComparer.Instance))
return $"Common line {i + 1} changed.";
}
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation, // Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
// all compared exactly above. // all compared exactly above.
return null; return null;
@@ -203,6 +228,9 @@ public sealed class PlateCuttingState
private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff, private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff,
List<ContourCutPin> Pins, string PinsFingerprint); List<ContourCutPin> Pins, string PinsFingerprint);
private sealed record CommonLineRecord(CommonLineGroup Group, double Kerf, Part[] Members);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position, private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit); double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps, double HeadLockOvercut,
bool FarEdgeFirst);
} }
@@ -5,6 +5,8 @@ using System.Threading;
using OpenNest.CNC.CuttingStrategy; using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics; using OpenNest.Diagnostics;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Support;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
@@ -14,13 +16,32 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
internal PreparedContours Owner { get; init; } internal PreparedContours Owner { get; init; }
/// <summary> /// <summary>
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see /// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to /// zone (see <see cref="PreparedContours.ScrapPierceEntry"/>), or a cutout's where eroding it
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice. /// 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> /// </summary>
public Vector? Pierce { get; internal init; } public Vector? Pierce { get; internal init; }
} }
/// <summary>
/// How a common-line member's outside is cut once (see docs/common-line-cutting.md), with the
/// group's kerf. The member cuts its outside compensated, so each shared edge it cuts lands on the
/// kerf centerline: that edge is the common line. Spans an earlier member cut carry the Tab layer
/// and are never cut again. A member that cuts a common line itself (<see cref="Entry"/> set)
/// starts there: at <see cref="Entry"/> (part coordinates), the vertex where its shared edge
/// begins, with a straight lead-in from <see cref="Pierce"/> along that edge's axis, and runs on
/// round its perimeter with no lead-out, closing at the entry or stopping in an earlier member's
/// kerf. Its other stretches, and every stretch of a member without an entry, are remainder pieces
/// entered in the kerf before them.
/// </summary>
public sealed record CommonLinePerimeter(double Kerf, Vector? Entry = null, Vector? Pierce = null)
{
/// <summary>Whether the member starts its outside on a common line it cuts.</summary>
public bool StartsOnCommonLine => Entry != null;
}
/// <summary> /// <summary>
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated /// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
/// program and keep it stable during Capture. This is not a topology or lead-path verdict. /// program and keep it stable during Capture. This is not a topology or lead-path verdict.
@@ -36,15 +57,44 @@ public sealed class PreparedContours
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices: // Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
// every full or prefix emission cuts their pieces after the break cuts. // every full or prefix emission cuts their pieces after the break cuts.
private readonly List<Shape> tabbedCutouts; private readonly List<Shape> tabbedCutouts;
// Slices of the slugs the support bed cannot hold level, in cutting order: open cuts inside
// their cutouts, cut right after the break cuts so every slug is sliced before its cutout.
private readonly IReadOnlyList<SlugSlice> slugSlices;
// Pierce-zone resolutions by contour, shared with every copy: the contours are the same by ordinal.
private readonly PierceZoneMemo pierceZones;
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and // By contour ordinal; all null without pins. A pinned contour has exactly one entry and
// its own owned settings (the pin's lead-in, lead-out and tab). // its own owned settings (the pin's lead-in, lead-out and tab).
private readonly ContourChoice[] pinned; private readonly ContourChoice[] pinned;
private readonly CuttingParameters[] contourParameters; private readonly CuttingParameters[] contourParameters;
private readonly int[] pinNumbers; 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;
// Set for a common-line member, cut from one fixed entry: its whole perimeter from where its
// first shared edge begins, or its remainder pieces between the Tab spans earlier members cut.
private readonly CommonLinePerimeter commonLine;
private readonly ContourChoice commonLineEntry;
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts, private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters) 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, PierceZoneMemo pierceZones = null,
CommonLinePerimeter commonLine = null, IReadOnlyList<SlugSlice> slugSlices = null)
{
this.slugSlices = slugSlices ?? Array.Empty<SlugSlice>();
this.pierceZones = pierceZones ?? new PierceZoneMemo();
this.displayOutline = displayOutline;
this.commonLine = commonLine;
this.shapes = shapes; this.shapes = shapes;
this.scribes = scribes; this.scribes = scribes;
this.breakCuts = breakCuts; this.breakCuts = breakCuts;
@@ -52,10 +102,31 @@ public sealed class PreparedContours
this.parameters = parameters; this.parameters = parameters;
pinned = new ContourChoice[shapes.Length]; pinned = new ContourChoice[shapes.Length];
pinNumbers = new int[shapes.Length]; pinNumbers = new int[shapes.Length];
if (commonLine is { Entry: { } entry })
{
// The outside starts where a shared edge it cuts begins, pierced off it.
var perimeter = shapes[^1];
var entity = perimeter.Entities.FindIndex(e => e.Layer != SpecialLayers.Tab
&& Start(e).DistanceTo(entry) <= PostVerificationGeometry.Epsilon);
commonLineEntry = new ContourChoice(shapes.Length - 1, entity, Start(perimeter.Entities[entity]))
{
Owner = this,
Pierce = commonLine.Pierce,
};
}
else if (commonLine != null)
{
// The remainder's first piece starts where a shared span ends: the one fixed entry.
var perimeter = shapes[^1];
var first = ContourCuttingStrategy.TabbedCutoutPieces(perimeter)[0].Entities[0];
var entity = perimeter.Entities.IndexOf(first);
commonLineEntry = new ContourChoice(shapes.Length - 1, entity, Start(first)) { Owner = this };
}
} }
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token) 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, unpinned.pierceZones, unpinned.commonLine, unpinned.slugSlices)
{ {
contourParameters = new CuttingParameters[shapes.Length]; contourParameters = new CuttingParameters[shapes.Length];
var rejected = new List<CutPinResolution>(); var rejected = new List<CutPinResolution>();
@@ -72,13 +143,16 @@ public sealed class PreparedContours
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins)); throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
if (pinned[contour] != null) if (pinned[contour] != null)
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins)); throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned. // The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned. A
if (pin.Pin.HasTab && contour != PerimeterOrdinal) // common-line member's outside is cut from its shared spans, never from a pin.
if (pin.Pin.HasTab && contour != PerimeterOrdinal || commonLine != null && contour == PerimeterOrdinal)
{ {
rejected.Add(pin); rejected.Add(pin);
continue; 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); ValidateChoice(choice);
pinned[contour] = choice; pinned[contour] = choice;
pinNumbers[contour] = pin.PinIndex + 1; pinNumbers[contour] = pin.PinIndex + 1;
@@ -87,17 +161,170 @@ public sealed class PreparedContours
RejectedPins = rejected.AsReadOnly(); 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, source.pierceZones, source.commonLine, source.slugSlices)
{
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));
if (commonLine != null)
throw new ArgumentException("A common-line member is never support-tabbed.", nameof(support));
return new PreparedContours(this, support, location, tab);
}
// A copy that slices slugs; everything else, pins and support analysis included, is kept.
private PreparedContours(PreparedContours source, IReadOnlyList<SlugSlice> slices)
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
source.displayOutline, source.pierceZones, source.commonLine, slices)
{
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 = (CuttingParameters[])source.contourParameters?.Clone();
RejectedPins = source.RejectedPins;
support = source.support;
supportOrigin = source.supportOrigin;
}
/// <summary>
/// A copy that slices the slug of each cutout <paramref name="bed"/> cannot hold level
/// (<see cref="SlugSlicer.Tips"/>), with the part placed at <paramref name="location"/> on a
/// sheet in <paramref name="quadrant"/>, along the bed's contact lines, each slice stopping the
/// kerf short of its cutout (docs/support-tabs.md#slug-slices). Every full or prefix emission
/// cuts the slices right after the break cuts, so each slug is sliced before its cutout.
/// A cutout that holds another of this part's cuts, or whose box holds one of
/// <paramref name="otherParts"/> (world boxes of the plate's other parts), is not sliced:
/// a slice would cut what is nested in it. Without a bed or a kerf, or with nothing to slice,
/// this preparation is returned unchanged.
/// </summary>
internal PreparedContours WithSlugSlices(SupportBed bed, int quadrant, Vector location,
IReadOnlyList<Box> otherParts, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(otherParts);
PostVerificationGeometry.Validate(location);
var standoff = parameters.Kerf;
if (bed == null || !(standoff > 0) || !double.IsFinite(standoff))
return this;
// Pierced on the slice where the cutouts' own lead-in would pierce, and at least as far
// from the cutout as any pierce keeps from its contour.
var clearance = parameters.GetEffectivePierceClearance();
var reach = System.Math.Max(clearance,
ContourCuttingStrategy.CommonLineLeadInReach(parameters.InternalLeadIn ?? new NoLeadIn()));
var slices = new List<SlugSlice>();
for (var ordinal = 0; ordinal < PerimeterOrdinal; ordinal++)
{
token.ThrowIfCancellationRequested();
var hole = shapes[ordinal];
var box = hole.BoundingBox;
var world = new Box(box.Left + location.X, box.Bottom + location.Y, box.Length, box.Width);
if (otherParts.Any(other => other != null && world.Contains(other)))
continue;
var polygon = hole.ToPolygonWithTolerance(SupportMaterial.ChordTolerance);
if (EnclosesOtherCuts(ordinal, polygon)
|| !SlugSlicer.Tips(bed, quadrant, SupportMaterial.FromPolygons(polygon.Vertices.Select(v => v + location))))
continue;
slices.AddRange(SlugSlicer.Slices(bed, quadrant, polygon.Vertices, location, standoff, reach, clearance));
}
return slices.Count == 0 ? this : new PreparedContours(this, slices.AsReadOnly());
}
// Whether another contour, break cut or tabbed cutout of this part lies inside the cutout.
private bool EnclosesOtherCuts(int ordinal, Polygon cutout)
{
var others = shapes.Where((_, index) => index != ordinal && index != PerimeterOrdinal)
.Concat(breakCuts).Concat(tabbedCutouts);
return others.Any(other => other.Entities.Count > 0 && cutout.ContainsPoint(other.Entities[0] switch
{
Circle circle => circle.Center + new Vector(circle.Radius, 0),
var entity => Start(entity),
}));
}
/// <summary>The slug slices this preparation cuts after its break cuts and before its contours.</summary>
public IReadOnlyList<SlugSlice> SlugSlices => slugSlices;
/// <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 && commonLine == null && ParametersFor(PerimeterOrdinal).TabsEnabled;
/// <summary>
/// True for a common-line member (see <see cref="CommonLinePerimeter"/>): its perimeter has one
/// fixed entry and is emitted whole from its common line, or as its remainder pieces.
/// </summary>
public bool IsCommonLinePerimeter => commonLine != null;
/// <summary>How the member's outside is cut once, or null for any other part.</summary>
public CommonLinePerimeter CommonLine => commonLine;
public int Count => shapes.Length; public int Count => shapes.Length;
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary> /// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
internal double PierceZoneRadius => parameters.GetPierceZoneRadius(); internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
public int PerimeterOrdinal => Count - 1;
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary> /// <summary>The pierce-zone resolutions these contours' emissions share; for tests.</summary>
internal PierceZoneMemo PierceZones => pierceZones;
/// <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 honor: a tab on an internal contour.</summary>
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>(); public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary> /// <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> /// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null; public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
@@ -115,24 +342,22 @@ public sealed class PreparedContours
/// contour keeps the automatic selection with <paramref name="parameters"/>. /// contour keeps the automatic selection with <paramref name="parameters"/>.
/// </summary> /// </summary>
public static PreparedContours Capture(Program program, CuttingParameters parameters, public static PreparedContours Capture(Program program, CuttingParameters parameters,
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default) IReadOnlyList<CutPinResolution> pins, CancellationToken token = default, CommonLinePerimeter commonLine = null)
{ {
ArgumentNullException.ThrowIfNull(pins); ArgumentNullException.ThrowIfNull(pins);
var unpinned = Capture(program, parameters, token); var unpinned = Capture(program, parameters, token, commonLine);
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned; 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. // not the caller's concern; angle rounding is off because a pinned entry never moves.
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin) private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
{ {
var owned = OwnedCuttingParameters.Copy(shared); var owned = OwnedCuttingParameters.Copy(shared);
owned.ExternalLeadIn = pin.LeadIn; owned.ExternalLeadIn = pin.LeadIn;
owned.InternalLeadIn = pin.LeadIn; owned.InternalLeadIn = pin.LeadIn;
owned.ArcCircleLeadIn = pin.LeadIn;
owned.ExternalLeadOut = pin.LeadOut; owned.ExternalLeadOut = pin.LeadOut;
owned.InternalLeadOut = pin.LeadOut; owned.InternalLeadOut = pin.LeadOut;
owned.ArcCircleLeadOut = pin.LeadOut;
owned.RoundLeadInAngles = false; owned.RoundLeadInAngles = false;
owned.TabsEnabled = pin.HasTab; owned.TabsEnabled = pin.HasTab;
if (pin.HasTab) if (pin.HasTab)
@@ -142,7 +367,12 @@ public sealed class PreparedContours
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters; private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default) /// <param name="commonLine">For a common-line member: how its outside is cut once. With an
/// entry, the program's perimeter is closed and starts there. Without one, it must carry the
/// spans earlier members cut on the Tab layer (CommonLineCutting.MarkSharedSpans); it is then
/// the perimeter, cut in pieces between them, rather than a tabbed cutout.</param>
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default,
CommonLinePerimeter commonLine = null)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var ownedParameters = OwnedCuttingParameters.Copy(parameters); var ownedParameters = OwnedCuttingParameters.Copy(parameters);
@@ -155,6 +385,7 @@ public sealed class PreparedContours
var scribes = new List<Entity>(); var scribes = new List<Entity>();
var breakCuts = new List<Shape>(); var breakCuts = new List<Shape>();
var tabbedCutouts = new List<Shape>(); var tabbedCutouts = new List<Shape>();
Shape displayPerimeter = null;
Shape breakCut = null; Shape breakCut = null;
var current = new Shape(); var current = new Shape();
foreach (var entity in geometryProgram.ToGeometry()) foreach (var entity in geometryProgram.ToGeometry())
@@ -185,7 +416,8 @@ public sealed class PreparedContours
scribes.Add(entity); scribes.Add(entity);
continue; 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."); throw new ArgumentException("Preparation requires clean cut/display contours.");
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length)) if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate contour entity."); throw new ArgumentException("Degenerate contour entity.");
@@ -203,6 +435,82 @@ public sealed class PreparedContours
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity))) if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour(); FinishContour();
} }
if (commonLine != null)
{
if (displayPerimeter != null || current.Entities.Count != 0)
throw new ArgumentException("A common-line member needs one closed cut perimeter.");
if (!double.IsFinite(commonLine.Kerf) || commonLine.Kerf <= 0
|| (commonLine.Entry == null) != (commonLine.Pierce == null))
throw new ArgumentException("A common-line member needs a positive kerf, and an entry with its pierce.");
var outside = tabbedCutouts.Concat(contours).MaxBy(shape => shape.BoundingBox.Width * shape.BoundingBox.Length);
if (commonLine is { Entry: { } entry, Pierce: { } pierce })
{
// Cut from its entry vertex on a common line; every other contour lies inside it.
PostVerificationGeometry.Validate(entry);
PostVerificationGeometry.Validate(pierce);
if (outside == null
|| !outside.Entities.Exists(e => e.Layer != SpecialLayers.Tab
&& Start(e).DistanceTo(entry) <= PostVerificationGeometry.Epsilon)
|| pierce.DistanceTo(entry) <= PostVerificationGeometry.Epsilon)
throw new ArgumentException("A common-line member's perimeter does not start at its common line.");
if (contours.Contains(outside))
{
contours.Remove(outside);
contours.Add(outside);
foreach (var cut in breakCuts)
cut.UpdateBounds();
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, null, null,
commonLine);
}
}
// The outside carries the shared spans earlier members cut as tabs; every other contour lies inside it.
else if (outside == null || !tabbedCutouts.Contains(outside)
|| ContourCuttingStrategy.TabbedCutoutPieces(outside).Count == 0)
throw new ArgumentException("A common-line member's perimeter does not carry its shared spans.");
// Each remainder piece is pierced in the kerf of the span before it, the external
// lead-in's reach back along it: the span must be that long. Never shortened.
var reach = ContourCuttingStrategy.CommonLineLeadInReach(ownedParameters.ExternalLeadIn ?? new NoLeadIn());
if (reach <= OpenNest.Math.Tolerance.Epsilon)
throw new NotSupportedException("A common-line member's remainder needs a lead-in with length: "
+ "its compensation starts in the shared kerf along the lead-in. Choose an external lead-in "
+ "with length, or cut the common lines twice.");
foreach (var piece in ContourCuttingStrategy.TabbedCutoutPieces(outside))
{
var index = outside.Entities.IndexOf(piece.Entities[0]);
var span = outside.Entities[(index + outside.Entities.Count - 1) % outside.Entities.Count];
if (span is not Line line || line.Length < reach + PostVerificationGeometry.Epsilon)
throw new NotSupportedException(FormattableString.Invariant(
$"One of its shared spans is {span.Length:0.####} long, shorter than the {reach:0.####} external lead-in its remainder starts along in that span's kerf. Shorten the lead-in, or cut the common lines twice."));
}
tabbedCutouts.Remove(outside);
contours.Add(outside);
foreach (var cut in breakCuts)
cut.UpdateBounds();
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, null, null,
commonLine);
}
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) if (current.Entities.Count != 0 || contours.Count == 0)
throw new ArgumentException("Preparation requires nonempty closed contours."); throw new ArgumentException("Preparation requires nonempty closed contours.");
var perimeter = 0; var perimeter = 0;
@@ -223,7 +531,15 @@ public sealed class PreparedContours
void FinishContour() void FinishContour()
{ {
current.UpdateBounds(); 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)) if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
throw new ArgumentException("A tabbed cutout has nothing to cut."); throw new ArgumentException("A tabbed cutout has nothing to cut.");
@@ -242,6 +558,8 @@ public sealed class PreparedContours
var shape = GetShape(contourOrdinal); var shape = GetShape(contourOrdinal);
if (pinned[contourOrdinal] is { } pin) if (pinned[contourOrdinal] is { } pin)
return pin; return pin;
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
return fixedEntry;
var point = shape.ClosestPointTo(approach, out var entity); var point = shape.ClosestPointTo(approach, out var entity);
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point); return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
} }
@@ -260,6 +578,8 @@ public sealed class PreparedContours
var shape = GetShape(contourOrdinal); var shape = GetShape(contourOrdinal);
if (pinned[contourOrdinal] is { } pin) if (pinned[contourOrdinal] is { } pin)
return new[] { pin }; return new[] { pin };
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
return new[] { fixedEntry };
var entries = new List<ContourChoice>(); var entries = new List<ContourChoice>();
entries.Add(ClosestEntry(contourOrdinal, approach)); entries.Add(ClosestEntry(contourOrdinal, approach));
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++) for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
@@ -269,8 +589,8 @@ public sealed class PreparedContours
if (entity is Circle circle) if (entity is Circle circle)
{ {
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++) for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4), Add(i, circle.Center + new Vector(PortableMath.Cos(angle * System.Math.PI / 4),
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius); PortableMath.Sin(angle * System.Math.PI / 4)) * circle.Radius);
} }
else else
{ {
@@ -285,8 +605,12 @@ public sealed class PreparedContours
void Add(int entityOrdinal, Vector point) void Add(int entityOrdinal, Vector point)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon)) if (entries.Count >= maxEntries)
entries.Add(Entry(contourOrdinal, entityOrdinal, point)); 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 +635,7 @@ public sealed class PreparedContours
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point) public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{ {
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this }; var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
ValidateChoice(choice); ValidateChoice(choice);
return choice; return choice;
@@ -323,12 +648,63 @@ public sealed class PreparedContours
/// emission reads only this choice and the perimeter, and the lead validator checks the /// 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. /// lead as usual. A pinned perimeter keeps its pin's entry.
/// </summary> /// </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 depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
ApplicableLeadInLength(contourOrdinal));
var found = ErodedPierce.Find(shape, depth,
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); PostVerificationGeometry.Validate(pierce);
var shape = GetShape(PerimeterOrdinal); var shape = GetShape(contourOrdinal);
var point = shape.ClosestPointTo(pierce, out var entity); 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, Owner = this,
Pierce = pierce, Pierce = pierce,
@@ -338,7 +714,7 @@ public sealed class PreparedContours
} }
/// <summary> /// <summary>
/// The uncapped preferred automatic start catalogue for one contour, in preference then /// The uncapped preferred automatic start catalog for one contour, in preference then
/// contour-travel order: convex corners of the contour's own winding, then straight-edge /// contour-travel order: convex corners of the contour's own winding, then straight-edge
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear /// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
/// line/line splits, circles and interior points never appear; each geometric point is /// line/line splits, circles and interior points never appear; each geometric point is
@@ -353,13 +729,15 @@ public sealed class PreparedContours
var shape = GetShape(contourOrdinal); var shape = GetShape(contourOrdinal);
if (pinned[contourOrdinal] is { } pin) if (pinned[contourOrdinal] is { } pin)
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) }; return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
return new[] { new ContourEntryCandidate(fixedEntry, AutomaticEntryKind.Pinned) };
if (IsSingleCircle(shape)) if (IsSingleCircle(shape))
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue."); throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalog.");
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token)); return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
} }
/// <summary> /// <summary>
/// The complete uncapped automatic start catalogue: the preferred points of /// The complete uncapped automatic start catalog: the preferred points of
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc /// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a /// midpoints, near-convex-corner points on straight edges, the eight compass points of a
/// whole circle, and the exact target-facing closest point toward /// whole circle, and the exact target-facing closest point toward
@@ -367,22 +745,63 @@ public sealed class PreparedContours
/// A pure-circle contour therefore yields compass points instead of refusing. Every /// A pure-circle contour therefore yields compass points instead of refusing. Every
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the /// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A /// preferred tier; fallbacks never replace a preferred point at the same geometry. A
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it. /// pinned contour's catalog is its pinned entry alone: nothing may relocate it.
/// </summary> /// </summary>
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks( internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
int contourOrdinal, int contourOrdinal,
Vector? lookAhead = null, Vector? lookAhead = null,
CancellationToken token = default) CancellationToken token = default,
SupportEntries supportEntries = SupportEntries.Steadying)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal); var shape = GetShape(contourOrdinal);
if (pinned[contourOrdinal] is { } pin) if (pinned[contourOrdinal] is { } pin)
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) }; return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
if (FixedCommonLineEntry(contourOrdinal) is { } fixedEntry)
return new[] { new ContourEntryCandidate(fixedEntry, AutomaticEntryKind.Pinned) };
var all = IsSingleCircle(shape) var all = IsSingleCircle(shape)
? new List<ContourEntryCandidate>() ? new List<ContourEntryCandidate>()
: PreferredCandidates(shape, contourOrdinal, token); : PreferredCandidates(shape, contourOrdinal, token);
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, 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 catalog 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) => private static bool IsSingleCircle(Shape shape) =>
@@ -390,8 +809,8 @@ public sealed class PreparedContours
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary> /// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token) private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
=> CataloguePoints(shape, token) => CatalogPoints(shape, token)
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind)) .Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
.ToList(); .ToList();
/// <summary> /// <summary>
@@ -423,8 +842,8 @@ public sealed class PreparedContours
// The legacy eight compass points, same native construction. // The legacy eight compass points, same native construction.
for (var angle = 0; angle < 8; angle++) for (var angle = 0; angle < 8; angle++)
fallbacks.Add(Fallback(i, circle.Center + new Vector( fallbacks.Add(Fallback(i, circle.Center + new Vector(
System.Math.Cos(angle * System.Math.PI / 4), PortableMath.Cos(angle * System.Math.PI / 4),
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius, PortableMath.Sin(angle * System.Math.PI / 4)) * circle.Radius,
AutomaticEntryKind.CircleCompass)); AutomaticEntryKind.CircleCompass));
break; break;
} }
@@ -436,7 +855,7 @@ public sealed class PreparedContours
// endpoint and so never lands on the reflex/cusp vertex at the far end. // endpoint and so never lands on the reflex/cusp vertex at the far end.
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle)) if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
{ {
foreach (var corner in CataloguePoints(shape, token) foreach (var corner in CatalogPoints(shape, token)
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner)) .Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -475,9 +894,10 @@ public sealed class PreparedContours
var point = inward ? line.StartPoint + offset : line.EndPoint - offset; var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
// Strictly inside the edge by projection parameter (distance alone loses the // Strictly inside the edge by projection parameter (distance alone loses the
// sign when a short edge is overshoot): never the corner, never the far // 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; 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; return;
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner)); fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
} }
@@ -549,14 +969,16 @@ public sealed class PreparedContours
}; };
/// <summary> /// <summary>
/// The preferred catalogue points for one contour: for each entity, its convex corner /// The preferred catalog points for one contour: for each entity, its convex corner
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint, /// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute /// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
/// nothing; a single whole circle yields no points at all. /// nothing; a single whole circle yields no points at all.
/// </summary> /// </summary>
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token) private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CatalogPoints(
Shape shape, CancellationToken token)
{ {
var found = new List<(int, Vector, AutomaticEntryKind)>(); var found = new List<(int, Vector, AutomaticEntryKind, double)>();
var count = shape.Entities.Count; var count = shape.Entities.Count;
if (count == 1 && shape.Entities[0] is Circle) if (count == 1 && shape.Entities[0] is Circle)
{ {
@@ -571,7 +993,7 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var entity = shape.Entities[i]; var entity = shape.Entities[i];
// Vertex reached by travelling along entity i (its end point), reported under // Vertex reached by traveling along entity i (its end point), reported under
// entity i. The closed contour guarantees every vertex appears exactly once // entity i. The closed contour guarantees every vertex appears exactly once
// this way; each is classified from the contour's own winding. // this way; each is classified from the contour's own winding.
var vertex = End(entity); var vertex = End(entity);
@@ -580,36 +1002,72 @@ public sealed class PreparedContours
switch (corner.Kind) switch (corner.Kind)
{ {
case ContourCuttingStrategy.CornerKind.Convex: case ContourCuttingStrategy.CornerKind.Convex:
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner)); found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
break; break;
case ContourCuttingStrategy.CornerKind.Smooth case ContourCuttingStrategy.CornerKind.Smooth
when entity is Line && Next(i) is Arc: when entity is Line && Next(i) is Arc:
// A line leaving into an arc: the tangent joint. The reverse travel // A line leaving into an arc: the tangent joint. The reverse travel
// order classifies the same joint from the arc, matched below. // order classifies the same joint from the arc, matched below.
found.Add((i, vertex, AutomaticEntryKind.TangentJoint)); found.Add(Joint(i, vertex, (i + 1) % count));
break; break;
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins // Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
// of a plain straight edge are not preferred automatic starts here. // of a plain straight edge are not preferred automatic starts here.
case ContourCuttingStrategy.CornerKind.Smooth case ContourCuttingStrategy.CornerKind.Smooth
when entity is Arc && Next(i) is Line: when entity is Arc && Next(i) is Line:
found.Add((i, vertex, AutomaticEntryKind.TangentJoint)); found.Add(Joint(i, vertex, i));
break; break;
} }
} }
if (entity is Line line) if (entity is Line line)
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint)); found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
} }
return found; return found;
Entity Next(int index) => shape.Entities[(index + 1) % count]; Entity Next(int index) => shape.Entities[(index + 1) % count];
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
FilletReach(shape, arcOrdinal) is { } reach
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
}
/// <summary>
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
/// than a half circle the convex way, and reaching back along each edge no further than the
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
/// </summary>
private static double? FilletReach(Shape shape, int arcOrdinal)
{
var count = shape.Entities.Count;
if (shape.Entities[arcOrdinal] is not Arc arc
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
return null;
var sweep = arc.SweepAngle();
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
return null;
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
return null;
var winding = ContourCuttingStrategy.DetermineWinding(shape);
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
return null;
var reach = arc.Radius * PortableMath.Tan(sweep / 2);
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
bool IsSmooth(Vector vertex, Entity entity) =>
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
} }
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary> /// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
public Program Emit(IReadOnlyList<ContourChoice> choices) public Program Emit(IReadOnlyList<ContourChoice> choices)
{ {
Validate(choices, true); Validate(choices, true);
return EmitPrefix(choices); return EmitOwned(choices, complete: true);
} }
/// <summary> /// <summary>
@@ -626,21 +1084,28 @@ public sealed class PreparedContours
throw new ArgumentException("Foreign contour choice."); throw new ArgumentException("Foreign contour choice.");
ValidateChoice(choice); ValidateChoice(choice);
// Same owned clones as a real emission; the source shapes/settings are never used // 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
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated( // reads only the chosen contour and the contour count, so only that contour is cloned.
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice, var owned = new Shape[shapes.Length];
contourParameters); owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
return new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitCandidateIsolated(
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal, commonLine);
} }
// Each prefix is a standalone owned program, including the same scribes and break cuts once. // Each prefix is a standalone owned program, including the same scribes and break cuts once.
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices) internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
{ {
Validate(choices, false); 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, PierceZones = pierceZones }.EmitPrepared(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices, shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(), 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, commonLine, slugSlices);
/// <summary>Open break cuts this preparation cuts before its contours.</summary> /// <summary>Open break cuts this preparation cuts before its contours.</summary>
public int BreakCutCount => breakCuts.Count; public int BreakCutCount => breakCuts.Count;
@@ -660,7 +1125,10 @@ public sealed class PreparedContours
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token); var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
// Only the outside is tabbed, with its own pin's tab when it is pinned. // Only the outside is tabbed, with its own pin's tab when it is pinned. A common-line
// member's shared spans are accounted as the material's tabs, never as a tab gap.
if (!hasCutPerimeter || commonLine != null)
return new(this, choices, expected, 0);
var perimeter = ParametersFor(PerimeterOrdinal); var perimeter = ParametersFor(PerimeterOrdinal);
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0); return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
} }
@@ -692,36 +1160,45 @@ public sealed class PreparedContours
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it. // A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice) if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
throw new ArgumentException("A pinned contour keeps its drawing pin's entry."); throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
// A common-line perimeter is cut from its one fixed entry.
if (commonLineEntry != null && choice.ContourOrdinal == PerimeterOrdinal
&& (choice.EntityOrdinal != commonLineEntry.EntityOrdinal || choice.Point != commonLineEntry.Point
|| choice.Pierce != commonLineEntry.Pierce))
throw new ArgumentException("A common-line member's perimeter is cut from its fixed entry.");
if (choice.Pierce is { } pierce) if (choice.Pierce is { } pierce)
{ {
PostVerificationGeometry.Validate(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) 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.");
} }
} }
private ContourChoice FixedCommonLineEntry(int contourOrdinal) =>
commonLineEntry != null && contourOrdinal == PerimeterOrdinal ? commonLineEntry : null;
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal]; ? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
/// <summary> /// <summary>
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing /// Representative POINTS per contour ordinal (bounding-box center) for hole routing
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the /// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points. /// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
/// </summary> /// </summary>
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default) internal IReadOnlyList<Vector?> HoleCenters(CancellationToken token = default)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var centres = new Vector?[Count]; var centers = new Vector?[Count];
for (var contour = 0; contour < PerimeterOrdinal; contour++) for (var contour = 0; contour < PerimeterOrdinal; contour++)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
centres[contour] = GetShape(contour).BoundingBox.Center; centers[contour] = GetShape(contour).BoundingBox.Center;
} }
return centres; return centers;
} }
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
internal Box ContourBounds(int contourOrdinal) => GetShape(contourOrdinal).BoundingBox;
/// <summary> /// <summary>
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>. /// 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. /// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
@@ -733,6 +1210,33 @@ public sealed class PreparedContours
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point); && 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 center 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 Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint(); private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
@@ -12,19 +12,50 @@ public sealed class ReleasedContourState
{ {
private readonly List<Obstacle> obstacles = new(); private readonly List<Obstacle> obstacles = new();
private readonly bool reportMissingLeadIns; private readonly bool reportMissingLeadIns;
// Parts (by caller part number) whose single tab may not hold them level: once cut, their
// tabbed perimeter is an obstacle as if cut free, since the part may tip into the head's path.
private readonly Func<int, bool> tabMayNotHold;
// Common-line members (by caller part number): their outside is cut as open remainder pieces
// that end in kerf their shared cuts opened, so once the part's program ends it is cut free.
private readonly Func<int, bool> commonLineMember;
// 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) { } public ReleasedContourState() : this(true) { }
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning /// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
/// may pierce directly on a contour and checks travel without requiring a lead.</param> /// may pierce directly on a contour and checks travel without requiring a lead.</param>
public ReleasedContourState(bool reportMissingLeadIns) public ReleasedContourState(bool reportMissingLeadIns) : this(reportMissingLeadIns, null) { }
/// <param name="tabMayNotHold">Part numbers whose support tab may not hold them level (no
/// single tab can steady them): the longest open cut a part leaves, its tabbed
/// perimeter, becomes an obstacle when the part ends, closed across its tab gap.</param>
public ReleasedContourState(bool reportMissingLeadIns, Func<int, bool> tabMayNotHold)
: this(reportMissingLeadIns, tabMayNotHold, null)
{
}
/// <param name="commonLineMember">Part numbers of common-line members (docs/common-line-cutting.md):
/// a member's open cuts are its remainder pieces, which end in kerf its shared cuts opened. They
/// need no retention proof, and when the part ends they become one obstacle, closed across its
/// shared spans, since the part is then cut free.</param>
public ReleasedContourState(bool reportMissingLeadIns, Func<int, bool> tabMayNotHold, Func<int, bool> commonLineMember)
{ {
this.reportMissingLeadIns = reportMissingLeadIns; this.reportMissingLeadIns = reportMissingLeadIns;
this.tabMayNotHold = tabMayNotHold;
this.commonLineMember = commonLineMember;
} }
public ReleasedContourState Copy() public ReleasedContourState Copy()
{ {
var copy = new ReleasedContourState(reportMissingLeadIns); var copy = new ReleasedContourState(reportMissingLeadIns, tabMayNotHold, commonLineMember)
{
partContours = partContours,
partClosed = partClosed,
};
copy.obstacles.AddRange(obstacles); copy.obstacles.AddRange(obstacles);
return copy; return copy;
} }
@@ -45,9 +76,11 @@ public sealed class ReleasedContourState
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a /// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
/// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is /// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is
/// returned apart: a fixed emission is accepted as given and only reported. The checker /// returned apart: a fixed emission is accepted as given and only reported. The checker
/// state advances exactly as it would without ownership. /// state advances exactly as it would without ownership. Crossings made by a rapid with the
/// head raised are returned apart as well, as Raised: they are the raise's reason, not a fault.
/// </summary> /// </summary>
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check( internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
IReadOnlyList<PostVerificationFinding> Raised) Check(
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed, OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
CancellationToken token) CancellationToken token)
{ {
@@ -58,14 +91,39 @@ public sealed class ReleasedContourState
moves[0] = moves[0].WithStart(arrival); moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>(); var findings = new List<PostVerificationFinding>();
var fixedFindings = new List<PostVerificationFinding>(); var fixedFindings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings); var raisedFindings = new List<PostVerificationFinding>();
return (findings.AsReadOnly(), fixedFindings.AsReadOnly()); AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
raisedFindings: raisedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.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, internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token, List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null, Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
bool proveOpenCuts = false) int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
{ {
var current = new CutGroup(); var current = new CutGroup();
var unfinished = new List<CutGroup>(); var unfinished = new List<CutGroup>();
@@ -78,31 +136,44 @@ public sealed class ReleasedContourState
CutGroup previous = null; CutGroup previous = null;
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>(); var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
var hasLead = false; var hasLead = false;
var contourNumber = 0; var contourNumber = contoursBefore;
var inBreakCut = false; var inBreakCut = false;
var member = commonLineMember?.Invoke(part) == true;
var reportedTab = false; var reportedTab = false;
// The contour last closed, and the one a lead-out has just left it into: a cutout's
// lead-out ends in its slug, so the rapid after it must leave over that cutout's own cut.
CutGroup lastClosed = null;
Obstacle lastObstacle = null;
Obstacle exited = null;
var exitPoint = Vector.Zero;
for (var index = 0; index < moves.Count; index++) for (var index = 0; index < moves.Count; index++)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var move = moves[index]; var move = moves[index];
var owned = isFixed?.Invoke(index) == true; var owned = isFixed?.Invoke(index) == true;
if (!move.Rapid && move.Layer == LayerType.Break) if (!move.Rapid && move.Layer != LayerType.Leadout)
exited = null;
if (!move.Rapid && move.Layer is LayerType.Break or LayerType.CommonLine)
{ {
// A break cut is intentionally open and retained by its uncut tabs: it is never a // A break cut is intentionally open and retained by its uncut tabs: it is never a
// rapid obstacle and never an unexplained fragment, but it is still pierced. // rapid obstacle and never an unexplained fragment, but it is still pierced. A
// common line's shared cut runs in the kerf between two parts: it bounds and
// retains nothing, so it is never an obstacle and takes no part in the open-cut proof.
if (!inBreakCut) if (!inBreakCut)
{ {
Finish(); Finish();
if (reportMissingLeadIns && !cutoff && !hasLead) if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
"A break cut has no nonzero placed lead-in motion.") move.Layer == LayerType.Break ? "A break cut is missing lead-in geometry."
: "A common-line shared cut is missing lead-in geometry.")
{ Location = move.Curve.Start }, owned); { Location = move.Curve.Start }, owned);
inBreakCut = true; inBreakCut = true;
hasLead = false; hasLead = false;
previous = new CutGroup(); previous = new CutGroup();
previous.Leads.AddRange(leadIns); previous.Leads.AddRange(leadIns);
leadIns.Clear(); leadIns.Clear();
otherOpen.Add(previous); if (move.Layer == LayerType.Break)
otherOpen.Add(previous);
} }
if (move.Curve.Length > PostVerificationGeometry.Epsilon) if (move.Curve.Length > PostVerificationGeometry.Epsilon)
previous.Curves.Add(move.Curve); previous.Curves.Add(move.Curve);
@@ -116,8 +187,15 @@ public sealed class ReleasedContourState
loose.Leads.AddRange(leadIns); loose.Leads.AddRange(leadIns);
leadIns.Clear(); leadIns.Clear();
previous = null; previous = null;
var leaving = exited;
exited = null;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon) if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue; continue;
// A raised rapid out of a slug its cutout's lead-out ended in is the designed way
// out: it may cross that cutout once, and passes over the rest with the head up.
if (move.HeadRaised && leaving != null && start.DistanceTo(exitPoint) <= PostVerificationGeometry.Epsilon
&& LeavesOnce(leaving, start, move.End, token))
continue;
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X), var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X), System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
System.Math.Max(start.Y, move.End.Y)); System.Math.Max(start.Y, move.End.Y));
@@ -127,16 +205,31 @@ public sealed class ReleasedContourState
// A rapid well clear of a contour's extent can neither cross nor touch it. // A rapid well clear of a contour's extent can neither cross nor touch it.
if (reach.IsClearOf(obstacle.Extent)) if (reach.IsClearOf(obstacle.Extent))
continue; 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
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, // cut only within the seam tolerance its arcs were matched at. Contact within that
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + // much of the departure is the departure itself.
$"of part {obstacle.Part}.") var from = start;
{ if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
Location = (start + move.End) * 0.5, && start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
RapidStart = start, from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
RapidEnd = move.End, if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token) continue;
}, owned); var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
+ (obstacle.TabMayNotHold
? $"the tabbed outside of part {obstacle.Part}, which its tab may not hold level."
: $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}."))
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
};
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
if (move.HeadRaised)
raisedFindings?.Add(crossing);
else
Add(crossing, owned);
} }
} }
else if (move.Layer == LayerType.Leadin) else if (move.Layer == LayerType.Leadin)
@@ -157,6 +250,11 @@ public sealed class ReleasedContourState
"A lead-out follows an open cutting contour and may cut through its retention gap. " + "A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."), owned); "Rapid safety for that contour requires manual review."), owned);
(previous ?? loose).Leads.Add(move.Curve); (previous ?? loose).Leads.Add(move.Curve);
if (previous != null && ReferenceEquals(previous, lastClosed))
{
exited = lastObstacle;
exitPoint = move.Curve.End;
}
} }
Finish(); Finish();
hasLead = false; hasLead = false;
@@ -179,7 +277,7 @@ public sealed class ReleasedContourState
contourNumber++; contourNumber++;
if (reportMissingLeadIns && !cutoff && !hasLead) if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, 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); { Location = move.Curve.Start }, owned);
hasLead = false; hasLead = false;
// A rapid can pause/reposition without leaving any material gap. // A rapid can pause/reposition without leaving any material gap.
@@ -201,19 +299,31 @@ public sealed class ReleasedContourState
// A completed contour becomes an obstacle immediately, not at part end. // A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(current.Curves)) if (PostVerificationGeometry.Closed(current.Curves))
Finish(); Finish();
else if (OvercutClosure(current.Curves) is { } closing)
{
current.OvercutEnd = current.Curves[^1].End;
current.Curves[^1] = closing;
Finish();
}
} }
} }
Finish(); Finish();
loose.Leads.AddRange(leadIns); loose.Leads.AddRange(leadIns);
// Open cuts that provably enclose nothing retain all their material, however many // Open cuts that provably enclose nothing retain all their material, however many
// pieces a tabbed cutout was cut in. Otherwise they need manual review. Planning proves // pieces a tabbed cutout was cut in, and so does a tabbed perimeter whose lead-out stays
// them too (its own tabbed cutouts are cut in pieces without lead-outs), but only // out of its tab: planning and pre-post review prove them alike. Otherwise they need
// pre-post review lets the proof clear a lead-out: planning keeps refusing to certify // manual review.
// its own tabs followed by a lead-out. // A common-line member's open cuts are its remainder pieces: they end in shared kerf, not at
// tabs, and the planner's contour accounting proves they cut its outside exactly once.
var remainders = member && !cutoff && unfinished.Count > 0;
// Slug slices (docs/support-tabs.md#slug-slices) cross one another inside one of the part's
// own holes: what they enclose is that hole's slug, scrap, so they take no part in the proof.
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0) var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
&& unfinished.All(LeadsClearOpenEnds) && (remainders || unfinished.All(LeadsClearOpenEnds)
&& OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token); && OpenCutsEncloseNothing(closed, unfinished
for (var i = deferred.Count - 1; i >= 0 && !(retained && proveOpenCuts); i--) .Concat(otherOpen.Where(group => !CutsOnlySlug(group, closed, unfinished, token)))
.Append(loose).ToList(), token));
for (var i = deferred.Count - 1; i >= 0 && !retained; i--)
deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding); deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding);
// Only one open fragment per part can be the planner's own certified tab, so a // Only one open fragment per part can be the planner's own certified tab, so a
// further fragment that a fixed emission cut makes this uncertainty that emission's. // further fragment that a fixed emission cut makes this uncertainty that emission's.
@@ -222,6 +332,34 @@ public sealed class ReleasedContourState
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " + "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."), "they cannot be assumed to be retained by tabs. Review rapid travel manually."),
unfinished.Any(chain => chain.Fixed)); unfinished.Any(chain => chain.Fixed));
// A part that may tip once cut is avoided like one cut free. The retention proof above is
// unchanged: the part is still attached by its tab, just not held level by it.
if (remainders)
{
// Its remainder pieces in cutting order, joined across the shared spans between them:
// the member's outside, now cut free.
var curves = new List<PostVerificationGeometry.Curve>();
foreach (var chain in unfinished)
{
if (curves.Count > 0 && curves[^1].End.DistanceTo(chain.Curves[0].Start) > PostVerificationGeometry.Epsilon)
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, chain.Curves[0].Start, null, false));
curves.AddRange(chain.Curves);
}
if (curves[^1].End.DistanceTo(curves[0].Start) > PostVerificationGeometry.Epsilon)
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, curves[0].Start, null, false));
obstacles.Add(new(part, contourNumber, curves, null));
}
else if (!cutoff && unfinished.Count > 0 && tabMayNotHold?.Invoke(part) == true)
{
var perimeter = unfinished.MaxBy(chain => chain.Curves.Sum(curve => curve.Length));
var curves = perimeter.Curves.ToList();
if (curves[^1].End.DistanceTo(curves[0].Start) > PostVerificationGeometry.Epsilon)
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, curves[0].Start, null, false));
obstacles.Add(new(part, contourNumber, curves, null) { TabMayNotHold = true });
}
partContours = contourNumber;
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
&& !inBreakCut && loose.Leads.Count == 0;
void Add(PostVerificationFinding finding, bool fixedEmission) void Add(PostVerificationFinding finding, bool fixedEmission)
{ {
@@ -244,10 +382,12 @@ public sealed class ReleasedContourState
return; return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale; // A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention. // 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())); lastObstacle = new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd);
obstacles.Add(lastObstacle);
closed.Add(current); closed.Add(current);
lastClosed = current;
} }
else if (!cutoff) else if (!cutoff)
unfinished.Add(current); unfinished.Add(current);
@@ -255,12 +395,47 @@ public sealed class ReleasedContourState
} }
} }
/// <summary>
/// Whether the rapid from <paramref name="start"/> inside <paramref name="cutout"/> to
/// <paramref name="end"/> outside it meets its cut just once, on the way out: a rapid that
/// leaves a re-entrant cutout and passes back over it crosses the cut where the slug may lie.
/// </summary>
private static bool LeavesOnce(Obstacle cutout, Vector start, Vector end, CancellationToken token) =>
PostVerificationGeometry.Inside(start, cutout.Curves, token)
&& !PostVerificationGeometry.Inside(end, cutout.Curves, token)
&& PostVerificationGeometry.ContactPoints(start, end, cutout.Curves, token).Count == 1;
/// <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> /// <summary>
/// Whether one part's open cuts, with their leads, can release no material. A closed /// 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. /// contour with its own leads already encloses whatever it releases and counts as one node.
/// Open cuts may touch any cut only at their own ends, and must form no loop through those /// Open cuts may touch any cut only at their own ends, and must form no loop through those
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and /// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
/// tab strength are not modelled, exactly as for a single open contour. /// tab strength are not modeled, exactly as for a single open contour.
/// </summary> /// </summary>
private const double JointTolerance = 1e-5; private const double JointTolerance = 1e-5;
@@ -290,6 +465,35 @@ public sealed class ReleasedContourState
|| lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon; || lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon;
} }
/// <summary>
/// Whether break cut <paramref name="group"/>, its leads included, lies wholly inside one of
/// the part's closed holes and touches none of it: whatever it frees is that hole's slug. Only
/// straight cuts qualify. The part's outside is its largest cut, never a hole.
/// </summary>
private static bool CutsOnlySlug(CutGroup group, IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> unfinished,
CancellationToken token)
{
var cuts = group.Curves.Concat(group.Leads).ToList();
if (cuts.Count == 0 || !cuts.TrueForAll(cut => cut.IsLine))
return false;
var outside = closed.Concat(unfinished).Where(cut => cut.Curves.Count > 0).MaxBy(cut =>
{
var extent = cut.Curves.Aggregate(PostVerificationGeometry.Extent.None, (sum, curve) => sum.Union(curve.Extent));
return (extent.MaxX - extent.MinX) * (extent.MaxY - extent.MinY);
});
foreach (var hole in closed)
{
token.ThrowIfCancellationRequested();
if (ReferenceEquals(hole, outside) || !PostVerificationGeometry.Closed(hole.Curves))
continue;
if (cuts.TrueForAll(cut => PostVerificationGeometry.Inside(cut.Start, hole.Curves, token)
&& PostVerificationGeometry.Inside(cut.End, hole.Curves, token)
&& PostVerificationGeometry.ContactPoints(cut.Start, cut.End, hole.Curves, token).Count == 0))
return true;
}
return false;
}
private static bool OpenCutsEncloseNothing(IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> open, private static bool OpenCutsEncloseNothing(IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> open,
CancellationToken token) CancellationToken token)
{ {
@@ -396,6 +600,9 @@ public sealed class ReleasedContourState
internal List<PostVerificationGeometry.Curve> Curves { get; } = new(); internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
internal List<PostVerificationGeometry.Curve> Leads { get; } = new(); internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
internal bool Fixed { get; set; } 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) private sealed class DisjointSet(int count)
@@ -412,9 +619,13 @@ public sealed class ReleasedContourState
internal void Union(int a, int b) => parents[Find(a)] = Find(b); 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; } = internal PostVerificationGeometry.Extent Extent { get; } =
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent)); Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
/// <summary>A tabbed perimeter closed across its tab gap, for a part its tab may not hold level.</summary>
internal bool TabMayNotHold { get; init; }
} }
} }
@@ -72,8 +72,8 @@ internal static class SeamOvercut
// Incremental coordinates only need a consistent origin within one contour. // Incremental coordinates only need a consistent origin within one contour.
var reference = incremental ? position ?? new Vector() : new Vector(); var reference = incremental ? position ?? new Vector() : new Vector();
var end = reference + motion.EndPoint; var end = reference + motion.EndPoint;
var cut = !motion.Suppressed && motion is LinearMove { 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 ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter };
if (!cut || position == null) if (!cut || position == null)
{ {
Finish(); Finish();
@@ -160,11 +160,11 @@ internal static class SeamOvercut
return true; return true;
} }
/// <summary>The angle travelled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary> /// <summary>The angle traveled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
private static double Travel(Vector center, Vector from, Vector to, bool clockwise) private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
{ {
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X); var start = PortableMath.Atan2(from.Y - center.Y, from.X - center.X);
var end = System.Math.Atan2(to.Y - center.Y, to.X - center.X); var end = PortableMath.Atan2(to.Y - center.Y, to.X - center.X);
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI); var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
return travel < 0 ? travel + 2 * System.Math.PI : travel; return travel < 0 ? travel + 2 * System.Math.PI : travel;
} }
@@ -83,16 +83,20 @@ internal static class StateFingerprint
Out(p.ExternalLeadOut); Out(p.ExternalLeadOut);
Lead(p.InternalLeadIn); Lead(p.InternalLeadIn);
Out(p.InternalLeadOut); Out(p.InternalLeadOut);
Lead(p.ArcCircleLeadIn);
Out(p.ArcCircleLeadOut);
Number(p.PierceClearance); Number(p.PierceClearance);
Number(p.PierceDiameter); Number(p.PierceDiameter);
Flag(p.RoundLeadInAngles); Flag(p.RoundLeadInAngles);
Number(p.LeadInAngleIncrement); Number(p.LeadInAngleIncrement);
Number(p.AutoTabMinSize);
Number(p.AutoTabMaxSize);
TabSettings(p.TabConfig); TabSettings(p.TabConfig);
Flag(p.TabsEnabled); Flag(p.TabsEnabled);
Flag(p.SupportTabs);
Flag(p.TabBySize);
Number(p.TabSizeMinWidth);
Number(p.TabSizeMaxWidth);
Number(p.TabHeldMaxLength);
// Only when on, so fingerprints taken before the setting existed still match.
if (p.SliceTippingSlugs)
text.Append("slugSlices;");
Sequence(p.Sequencing); Sequence(p.Sequencing);
Assignment(p.Assignment); Assignment(p.Assignment);
text.Append('}'); text.Append('}');
@@ -141,6 +145,7 @@ internal static class StateFingerprint
break; break;
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)): case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
Number(l.Radius); Number(l.Radius);
Number(l.SweepAngle);
break; break;
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)): case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
Number(l.LineLength); Number(l.LineLength);
@@ -10,6 +10,12 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
public CuttingParameters Parameters { get; set; } public CuttingParameters Parameters { get; set; }
/// <summary>
/// Remembers pierce-zone resolutions by contour ordinal across the emissions of one
/// preparation (see <see cref="PierceZoneMemo"/>); null resolves every emission afresh.
/// </summary>
internal PierceZoneMemo PierceZones { get; set; }
private record ContourEntry(Shape Shape, Vector Point, Entity Entity); private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
public CuttingResult Apply(Program partProgram, Vector approachPoint) public CuttingResult Apply(Program partProgram, Vector approachPoint)
@@ -20,6 +26,7 @@ namespace OpenNest.CNC.CuttingStrategy
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart) public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
{ {
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
RefuseDisplayPerimeter(entities);
var breakCuts = TakeBreakCuts(entities); var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -107,6 +114,14 @@ namespace OpenNest.CNC.CuttingStrategy
return new CuttingResult { Program = result, LastCutPoint = perimeterPt }; 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( public CuttingResult ApplySingle(
Program partProgram, Program partProgram,
Vector point, Vector point,
@@ -115,6 +130,7 @@ namespace OpenNest.CNC.CuttingStrategy
) )
{ {
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
RefuseDisplayPerimeter(entities);
var breakCuts = TakeBreakCuts(entities); var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -251,14 +267,23 @@ namespace OpenNest.CNC.CuttingStrategy
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes, internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingPlanning.ContourChoice> choices,
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null, IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
IReadOnlyList<Shape> tabbedCutouts = null) IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null,
CuttingPlanning.CommonLinePerimeter commonLine = null, IReadOnlyList<Support.SlugSlice> slugSlices = null)
{ {
var result = new Program(Mode.Absolute); var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes); EmitScribeContours(result, scribes);
EmitBreakCuts(result, breakCuts, shapes); EmitBreakCuts(result, breakCuts, shapes);
EmitSlugSlices(result, slugSlices);
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>()) foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
EmitTabbedCutout(result, cutout); EmitTabbedCutout(result, cutout);
EmitChosenContours(result, shapes, choices, contourParameters); EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1,
commonLine);
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; result.Mode = Mode.Incremental;
return result; return result;
} }
@@ -269,33 +294,78 @@ namespace OpenNest.CNC.CuttingStrategy
// perimeter never degrades to a hole because it was isolated. Output must not be // perimeter never degrades to a hole because it was isolated. Output must not be
// installed on a Part or accepted as a complete plan. // installed on a Part or accepted as a complete plan.
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes, 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, CuttingPlanning.CommonLinePerimeter commonLine = null)
{ {
var result = new Program(Mode.Absolute); var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes); EmitScribeContours(result, scribes);
EmitChosenContours(result, shapes, new[] { choice }, contourParameters); EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1,
commonLine);
result.Mode = Mode.Incremental; result.Mode = Mode.Incremental;
return result; 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 // Contour emission itself reads only the contour's own geometry and settings, never
// prior choices — the differential tests in ContourCandidateEmissionTests pin this. // prior choices — the differential tests in ContourCandidateEmissionTests pin this.
private void EmitChosenContours(Program result, Shape[] shapes, private void EmitChosenContours(Program result, Shape[] shapes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters) IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
int perimeterOrdinal, CuttingPlanning.CommonLinePerimeter commonLine = null)
{ {
var shared = Parameters; var shared = Parameters;
try try
{ {
var inSlug = false;
foreach (var choice in choices) foreach (var choice in choices)
{ {
if (commonLine is { StartsOnCommonLine: true } && choice.ContourOrdinal == perimeterOrdinal
&& !IsTabbedCutout(shapes[perimeterOrdinal]))
{
// Cut whole from its common line: the straight lead-in from the choice's
// pierce runs along the shared edge, and the perimeter ends where it began,
// beside its partner, so it has no lead-out and no tab.
var whole = CuttingPlanning.OwnedCuttingParameters.Copy(shared);
whole.ExternalLeadOut = new NoLeadOut();
whole.TabsEnabled = false;
Parameters = whole;
var start = result.Codes.Count;
EmitContour(result, shapes[perimeterOrdinal], choice.Point,
shapes[perimeterOrdinal].Entities[choice.EntityOrdinal], ContourType.External,
exactCirclePrograms: true, scrapPierce: choice.Pierce, stopAtCenterLine: false,
memoContour: choice.ContourOrdinal);
if (inSlug && start < result.Codes.Count && result.Codes[start] is RapidMove)
result.Codes.Insert(start, new Comment(HeadRaise.Marker));
inSlug = false;
continue;
}
if (commonLine != null && choice.ContourOrdinal == perimeterOrdinal)
{
// The rapid out of a cutout's slug before it is still made head up.
Parameters = shared;
var first = result.Codes.Count;
EmitCommonLinePerimeter(result, shapes[perimeterOrdinal], commonLine);
if (inSlug && first < result.Codes.Count && result.Codes[first] is RapidMove)
result.Codes.Insert(first, new Comment(HeadRaise.Marker));
inSlug = false;
continue;
}
// A contour's own settings apply to its emission alone; scribes and every // A contour's own settings apply to its emission alone; scribes and every
// other contour keep the shared settings. // other contour keep the shared settings.
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared; Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
var shape = shapes[choice.ContourOrdinal]; var shape = shapes[choice.ContourOrdinal];
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal], var arrival = result.Codes.Count;
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null, var leftInSlug = EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
exactCirclePrograms: true, scrapPierce: choice.Pierce); choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
exactCirclePrograms: true, scrapPierce: choice.Pierce,
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null,
memoContour: choice.ContourOrdinal);
// A cutout's lead-out ends in its slug, which may drop or tip once cut free, so
// the rapid out over the cut is made head up. An overcut or no lead-out leaves
// from the cut itself and stays head down.
if (inSlug && arrival < result.Codes.Count && result.Codes[arrival] is RapidMove or SubProgramCall)
result.Codes.Insert(arrival, new Comment(HeadRaise.Marker));
inSlug = leftInSlug;
} }
} }
finally finally
@@ -414,14 +484,17 @@ namespace OpenNest.CNC.CuttingStrategy
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y); private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b); private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
private void EmitContour( /// <returns>Whether the contour is a cutout left by a lead-out, which ends in its slug.</returns>
private bool EmitContour(
Program program, Program program,
Shape shape, Shape shape,
Vector point, Vector point,
Entity entity, Entity entity,
ContourType? forceType = null, ContourType? forceType = null,
bool exactCirclePrograms = false, bool exactCirclePrograms = false,
Vector? scrapPierce = null Vector? scrapPierce = null,
bool stopAtCenterLine = false,
int? memoContour = null
) )
{ {
var contourType = forceType ?? DetectContourType(shape); var contourType = forceType ?? DetectContourType(shape);
@@ -430,6 +503,9 @@ namespace OpenNest.CNC.CuttingStrategy
var leadIn = SelectLeadIn(contourType); var leadIn = SelectLeadIn(contourType);
var leadOut = SelectLeadOut(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) if (contourType == ContourType.ArcCircle && entity is Circle circle)
{ {
@@ -441,14 +517,21 @@ namespace OpenNest.CNC.CuttingStrategy
var outwardAngle = normal - System.Math.PI; var outwardAngle = normal - System.Math.PI;
point = new Vector( point = new Vector(
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle), circle.Center.X + circle.Radius * PortableMath.Cos(outwardAngle),
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle) circle.Center.Y + circle.Radius * PortableMath.Sin(outwardAngle)
); );
} }
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal, if (scrapPierce is { } holePierce)
Parameters.GetEffectivePierceClearance()); leadIn = StraightLeadIn(point, holePierce, out normal);
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius()); else
{
if (stopAtCenterLine)
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance());
leadIn = PierceZone(memoContour, shape, point, leadIn, normal, winding);
}
// Build hole sub-program relative to (0,0) // Build hole sub-program relative to (0,0)
var holeCenter = circle.Center; var holeCenter = circle.Center;
@@ -465,7 +548,8 @@ namespace OpenNest.CNC.CuttingStrategy
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle); var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint)); subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding)); var holeLeadOut = leadOut.Generate(relativePoint, normal, winding);
subPgm.Codes.AddRange(holeLeadOut);
subPgm.Mode = Mode.Incremental; subPgm.Mode = Mode.Incremental;
// Deduplicate: check if an identical sub-program already exists // Deduplicate: check if an identical sub-program already exists
@@ -484,10 +568,12 @@ namespace OpenNest.CNC.CuttingStrategy
} }
); );
return; return holeLeadOut.Count > 0;
} }
var turn = LeadTurn(contourType, winding);
double leadInNormal; double leadInNormal;
var configuredLeadIn = leadIn;
// A planner-placed scrap pierce replaces the configured lead-in with a straight one. // A planner-placed scrap pierce replaces the configured lead-in with a straight one.
if (scrapPierce is { } pierce) if (scrapPierce is { } pierce)
leadIn = StraightLeadIn(point, pierce, out leadInNormal); leadIn = StraightLeadIn(point, pierce, out leadInNormal);
@@ -496,9 +582,12 @@ namespace OpenNest.CNC.CuttingStrategy
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding, leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
Parameters.GetEffectivePierceClearance(), out leadInNormal); Parameters.GetEffectivePierceClearance(), out leadInNormal);
if (contourType == ContourType.Internal) if (contourType == ContourType.Internal)
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius()); {
if (stopAtCenterLine)
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
leadIn = PierceZone(memoContour, shape, point, leadIn, leadInNormal, turn);
}
} }
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
var reindexedShape = shape.ReindexAt(point, entity); var reindexedShape = shape.ReindexAt(point, entity);
@@ -511,6 +600,7 @@ namespace OpenNest.CNC.CuttingStrategy
var leadOutNormal = normal; var leadOutNormal = normal;
if (tabbed) if (tabbed)
{ {
var first = reindexedShape.Entities.Count > 0 ? reindexedShape.Entities[0] : null;
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size); reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
// Leave from where the trimmed cut actually ends, on that entity's normal: // Leave from where the trimmed cut actually ends, on that entity's normal:
@@ -520,6 +610,18 @@ namespace OpenNest.CNC.CuttingStrategy
var last = reindexedShape.Entities[^1]; var last = reindexedShape.Entities[^1];
leadOutPoint = EntityEndPoint(last); leadOutPoint = EntityEndPoint(last);
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding); leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
// Straight leads either side of a tab that wraps a corner share its bisector,
// as they share the edge normal on a straight edge.
if (ReferenceEquals(leadIn, configuredLeadIn) && leadIn is LineLeadIn lineIn
&& leadOut is LineLeadOut lineOut && first is Line or Arc
&& TabCornerNormal(shape, point, TravelTangent(first, point), leadInNormal, lineIn,
leadOutPoint, TravelTangent(last, leadOutPoint), leadOutNormal, lineOut, winding,
turn, Parameters.GetEffectivePierceClearance()) is { } tabNormal)
{
leadInNormal = tabNormal;
leadOutNormal = tabNormal;
}
} }
} }
else else
@@ -528,10 +630,24 @@ namespace OpenNest.CNC.CuttingStrategy
Parameters.GetEffectivePierceClearance(), out leadOutNormal); Parameters.GetEffectivePierceClearance(), out leadOutNormal);
} }
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point)); program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding)); var leadOutCodes = leadOut.Generate(leadOutPoint, leadOutNormal, turn);
program.Codes.AddRange(leadOutCodes);
return contourType != ContourType.External && leadOutCodes.Count > 0;
} }
/// <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) private void EmitScribeContours(Program program, List<Entity> scribeEntities)
{ {
if (scribeEntities.Count == 0) if (scribeEntities.Count == 0)
@@ -616,8 +732,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// <paramref name="pierceClearance"/> from the contour; the approach angle is /// <paramref name="pierceClearance"/> from the contour; the approach angle is
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a /// 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 /// 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 /// the two edges meeting at the corner was picked. A curved lead-in at a convex
/// types keep <see cref="ComputeLeadInNormal"/>. /// 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> /// </summary>
public static LeadIn ResolveLeadIn( public static LeadIn ResolveLeadIn(
Shape shape, Shape shape,
@@ -631,8 +749,19 @@ namespace OpenNest.CNC.CuttingStrategy
) )
{ {
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding); normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
if (contourType != ContourType.External || leadIn is not LineLeadIn line if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|| !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; return leadIn;
switch (ClassifyCorner(corner, winding)) switch (ClassifyCorner(corner, winding))
@@ -662,7 +791,8 @@ namespace OpenNest.CNC.CuttingStrategy
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner /// 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 /// 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 /// 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> /// </summary>
public static LeadOut ResolveLeadOut( public static LeadOut ResolveLeadOut(
Shape shape, Shape shape,
@@ -676,8 +806,18 @@ namespace OpenNest.CNC.CuttingStrategy
) )
{ {
normal = ComputeNormal(point, entity, contourType, winding); normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.External || leadOut is not LineLeadOut line if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|| !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; return leadOut;
switch (ClassifyCorner(corner, winding)) switch (ClassifyCorner(corner, winding))
@@ -804,10 +944,18 @@ namespace OpenNest.CNC.CuttingStrategy
/// A turn whose offset over the tangent is within chaining tolerance is smooth, /// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner. /// not a corner.
/// </summary> /// </summary>
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
/// <summary>
/// The corner a contour of <paramref name="winding"/> would make turning from travel
/// direction <paramref name="tangentIn"/> to <paramref name="tangentOut"/>, classified
/// exactly as a shared vertex is (see <see cref="TryClassifyAutomaticStartCorner"/>).
/// </summary>
internal static CornerKind ClassifyTurn(Vector tangentIn, Vector tangentOut, RotationType winding)
{ {
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X; var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
var dot = corner.TangentIn.DotProduct(corner.TangentOut); var dot = tangentIn.DotProduct(tangentOut);
var turn = winding == RotationType.CCW ? cross : -cross; var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon) if (System.Math.Abs(turn) <= Tolerance.Epsilon)
@@ -827,13 +975,53 @@ namespace OpenNest.CNC.CuttingStrategy
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding); var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
// Sum unit normals rather than averaging angles (which fails at 0/2Ï€). // Sum unit normals rather than averaging angles (which fails at 0/2Ï€).
// Winding makes this point into the scrap even at reflex corners. // Winding makes this point into the scrap even at reflex corners.
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal); var x = PortableMath.Cos(normal) + PortableMath.Cos(adjacentNormal);
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal); var y = PortableMath.Sin(normal) + PortableMath.Sin(adjacentNormal);
if (!double.IsFinite(x) || !double.IsFinite(y) if (!double.IsFinite(x) || !double.IsFinite(y)
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon) || x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
return null; // Opposing normals at a cusp have no unique bisector. return null; // Opposing normals at a cusp have no unique bisector.
return Angle.NormalizeRad(System.Math.Atan2(y, x)); return Angle.NormalizeRad(PortableMath.Atan2(y, x));
}
/// <summary>
/// The normal straight leads either side of a perimeter tab share when the tab wraps a
/// convex corner: the bisector of the normals where the cut starts and where it stops, so
/// the leads form the V they form on a straight edge, centered on the corner. Null when the
/// tab lies on one edge or wraps a reflex turn, or when either lead would not keep
/// <paramref name="clearance"/> from the part or would cross it.
/// </summary>
private static double? TabCornerNormal(
Shape shape,
Vector start,
Vector tangentStart,
double startNormal,
LineLeadIn leadIn,
Vector end,
Vector tangentEnd,
double endNormal,
LineLeadOut leadOut,
RotationType winding,
RotationType turn,
double clearance
)
{
if (!IsFinite(tangentStart) || !IsFinite(tangentEnd)
|| ClassifyTurn(tangentEnd, tangentStart, winding) != CornerKind.Convex)
return null;
var x = PortableMath.Cos(startNormal) + PortableMath.Cos(endNormal);
var y = PortableMath.Sin(startNormal) + PortableMath.Sin(endNormal);
if (x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
return null;
var normal = Angle.NormalizeRad(PortableMath.Atan2(y, x));
var pierce = leadIn.GetPiercePoint(start, normal, turn);
var leadOutEnd = ((LinearMove)leadOut.Generate(end, normal, turn)[0]).EndPoint;
return IsClearStraightLead(shape, start, pierce, clearance)
&& IsClearStraightLead(shape, end, leadOutEnd, clearance)
? normal
: null;
} }
/// <summary> /// <summary>
@@ -945,7 +1133,7 @@ namespace OpenNest.CNC.CuttingStrategy
if (leadIn is NoLeadIn || clearance <= 0) if (leadIn is NoLeadIn || clearance <= 0)
return leadIn; return leadIn;
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle); var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle, circle.Rotation);
var maxRadius = circle.Radius - clearance; var maxRadius = circle.Radius - clearance;
if (maxRadius <= 0) if (maxRadius <= 0)
return leadIn; return leadIn;
@@ -987,17 +1175,30 @@ namespace OpenNest.CNC.CuttingStrategy
/// <summary> /// <summary>
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall. /// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is /// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
/// returned as given. Otherwise the pierce moves onto the line from /// returned as given. Otherwise the pierce moves straight in from the wall to the hole's
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a /// center line, or the lead's own length in when that comes first (see
/// round hole), as close to the contour as the zone allows, and the lead becomes a /// <see cref="StopAtCenterLine"/>). A lead that cannot keep the zone there moves onto the
/// straight line from there, generated with <paramref name="normal"/>. A hole too small /// line from <paramref name="point"/> toward the nearest point that keeps it (see
/// <see cref="ErodedPierce"/>) or else the hole's pole of inaccessibility (the center of a
/// round hole), as close to the contour as the zone allows. Either way the lead becomes
/// a straight line from there, generated with <paramref name="normal"/>. A hole too small
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and /// 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 /// 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 /// 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> /// </summary>
// The memoized resolution for a contour the preparation emits by ordinal.
private LeadIn PierceZone(int? contour, Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
{
var radius = Parameters.GetPierceZoneRadius();
return PierceZones != null && contour is { } ordinal
? PierceZones.Resolve(ordinal, hole, point, leadIn, normal, radius, turn)
: ResolvePierceZone(hole, point, leadIn, normal, radius, turn);
}
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal, 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 if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|| !hole.IsClosed()) || !hole.IsClosed())
@@ -1007,35 +1208,48 @@ namespace OpenNest.CNC.CuttingStrategy
var polygon = circle == null var polygon = circle == null
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance)) ? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
: null; : null;
if (Clear(leadIn.GetPiercePoint(point, normal))) var configured = leadIn.GetPiercePoint(point, normal, turn);
if (Clear(configured))
return leadIn; return leadIn;
var depth = CenterLineDepth(hole, point, normal, configured.DistanceTo(point));
if (depth > Math.Tolerance.Epsilon && Clear(point + Inward(normal) * depth))
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
var pole = circle?.Center ?? PolyLabel.Find(polygon, null, var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells); System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
if (!Inside(pole)) if (!Inside(pole))
return leadIn; return leadIn;
// Clearance grows monotonically toward the pole of a convex hole, so the bisection // The pole of a letter can be a stroke away: head for the stroke beside the entry, on
// its center line when the lead reaches that far.
var reach = configured.DistanceTo(point);
var toward = pole;
if (circle == null && ErodedPierce.Find(hole, System.Math.Max(pierceRadius, reach), new[] { point }, 1)
.Pierces is [var eroded] && Clear(eroded))
toward = eroded;
// Clearance grows monotonically along this line in a convex hole, so the bisection
// finds the shortest clear lead; in other holes it still ends on a clear point. // finds the shortest clear lead; in other holes it still ends on a clear point.
var pierce = pole; var pierce = toward;
if (Clear(pole)) if (Clear(toward) && (reach < pierceRadius || toward == pole))
{ {
double low = 0, high = 1; double low = 0, high = 1;
for (var i = 0; i < 40; i++) for (var i = 0; i < 40; i++)
{ {
var middle = (low + high) / 2; var middle = (low + high) / 2;
if (Clear(point + (pole - point) * middle)) if (Clear(point + (toward - point) * middle))
high = middle; high = middle;
else else
low = middle; low = middle;
} }
pierce = point + (pole - point) * high; pierce = point + (toward - point) * high;
} }
var length = pierce.DistanceTo(point); var length = pierce.DistanceTo(point);
if (length <= Math.Tolerance.Epsilon) if (length <= Math.Tolerance.Epsilon)
return leadIn; return leadIn;
var direction = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X); var direction = PortableMath.Atan2(pierce.Y - point.Y, pierce.X - point.X);
return new LineLeadIn return new LineLeadIn
{ {
Length = length, Length = length,
@@ -1050,25 +1264,120 @@ namespace OpenNest.CNC.CuttingStrategy
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius; && hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
} }
/// <summary>
/// Keeps a cutout's lead-in out of the cutout's far half. A lead longer than the cutout is
/// deep at its entry, measured straight in from the wall to the center line where the walls
/// meet as the cutout erodes, reaches past the center line or runs along a narrow stroke, as
/// does one whose own line gets closer to a wall again. It becomes a straight one, generated
/// with <paramref name="normal"/>, from that center line (the center of a round hole) or
/// from the lead's own length in when that comes first. Any other lead keeps its shape, and
/// one that leaves a cutout other than a round hole is left for the lead check to refuse.
/// </summary>
public static LeadIn StopAtCenterLine(Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
{
if (leadIn is NoLeadIn || !hole.IsClosed())
return leadIn;
var pierce = leadIn.GetPiercePoint(point, normal, turn);
var length = pierce.DistanceTo(point);
if (!double.IsFinite(length) || length <= Math.Tolerance.Epsilon)
return leadIn;
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
var along = pierce - point;
var inward = Inward(normal);
// A pierce as far from every wall as it is deep has not come near another wall.
var inside = circle == null || pierce.DistanceTo(circle.Center) < circle.Radius;
if (inside && Clearance(hole, pierce) >= along.X * inward.X + along.Y * inward.Y - Math.Tolerance.Epsilon)
return leadIn;
var depth = CenterLineDepth(hole, point, normal, length);
if (depth <= Math.Tolerance.Epsilon
|| inside && depth >= length - Math.Tolerance.Epsilon && !PeaksBeforeItsEnd(hole, point, along))
return leadIn;
if (circle == null)
{
var polygon = hole.ToPolygonWithTolerance(Math.Tolerance.ChainTolerance);
for (var i = 1; i <= CenterLineSteps; i++)
if (!polygon.ContainsPoint(point + along * ((double)i / CenterLineSteps)))
return leadIn;
}
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
}
// How far a straight line in from the wall runs, up to maxDepth, before it reaches the
// center line: the distance to the wall grows at a steady rate until a second wall is as
// near, then levels off along a stroke or falls.
private static double CenterLineDepth(Shape hole, Vector point, double normal, double maxDepth)
{
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
return System.Math.Min(maxDepth, circle.Radius);
var inward = Inward(normal);
var step = maxDepth / CenterLineSteps;
var rate = Clearance(hole, point + inward * step) / step;
// A corner's curved lead keeps an edge's normal, which runs along the other edge.
if (rate < MinimumCenterLineRate)
return maxDepth;
bool Rising(double depth) => Clearance(hole, point + inward * (depth + step / CenterLineSteps))
- Clearance(hole, point + inward * depth) >= rate / 2 * (step / CenterLineSteps);
for (var i = 1; i < CenterLineSteps; i++)
{
if (Rising(step * i))
continue;
double low = step * (i - 1), high = step * i;
for (var refine = 0; refine < CenterLineRefinements; refine++)
{
var middle = (low + high) / 2;
if (Rising(middle))
low = middle;
else
high = middle;
}
return low;
}
return maxDepth;
}
private static bool PeaksBeforeItsEnd(Shape hole, Vector point, Vector along)
{
var peak = 0.0;
for (var i = 1; i <= CenterLineSteps; i++)
{
var clearance = Clearance(hole, point + along * ((double)i / CenterLineSteps));
if (clearance < peak - Math.Tolerance.Epsilon)
return true;
peak = System.Math.Max(peak, clearance);
}
return false;
}
private static double Clearance(Shape hole, Vector point) => hole.ClosestPointTo(point, out _).DistanceTo(point);
private static Vector Inward(double normal) => new(PortableMath.Cos(normal), PortableMath.Sin(normal));
/// <summary> /// <summary>
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the /// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
/// normal to generate it with. /// normal to generate it with.
/// </summary> /// </summary>
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal) private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
{ {
normal = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X); normal = PortableMath.Atan2(pierce.Y - point.Y, pierce.X - point.X);
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 }; return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
} }
// Bounds the pole search on long, thin holes; the result is checked before use. // Bounds the pole search on long, thin holes; the result is checked before use.
private const int PoleSearchCells = 4096; private const int PoleSearchCells = 4096;
// Samples along a lead when looking for the center line, then the steps narrowing in on it.
private const int CenterLineSteps = 64;
private const int CenterLineRefinements = 16;
private const double MinimumCenterLineRate = 0.01;
private LeadIn SelectLeadIn(ContourType contourType) private LeadIn SelectLeadIn(ContourType contourType)
{ {
return contourType switch return contourType switch
{ {
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn, ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadIn,
ContourType.Internal => Parameters.InternalLeadIn,
_ => Parameters.ExternalLeadIn, _ => Parameters.ExternalLeadIn,
}; };
} }
@@ -1077,14 +1386,29 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
return contourType switch return contourType switch
{ {
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut, ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadOut,
ContourType.Internal => Parameters.InternalLeadOut,
_ => Parameters.ExternalLeadOut, _ => Parameters.ExternalLeadOut,
}; };
} }
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize) private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
{ {
// A round perimeter is one circle entered at `center`: cut it as an arc from the entry
// that stops the tab's chord short of it, travelling the circle's own way.
if (shape.Entities is [Circle round] && round.Radius > tabSize / 2)
{
var entry = round.Center.AngleTo(center);
var gap = 2 * PortableMath.Asin(tabSize / (2 * round.Radius));
var reversed = round.Rotation == RotationType.CW;
var arc = new Arc(round.Center, round.Radius, entry, reversed ? entry + gap : entry - gap, reversed)
{
Layer = round.Layer,
};
var trimmed = new Shape();
trimmed.Entities.Add(arc);
return trimmed;
}
var tabCircle = new Circle(center, tabSize); var tabCircle = new Circle(center, tabSize);
var entities = new List<Entity>(shape.Entities); var entities = new List<Entity>(shape.Entities);
@@ -1254,14 +1578,30 @@ namespace OpenNest.CNC.CuttingStrategy
path = (Shape)cut.Clone(); path = (Shape)cut.Clone();
path.Reverse(); path.Reverse();
} }
var normal = Angle.NormalizeRad(System.Math.Atan2(scrap.Y, scrap.X)); var normal = Angle.NormalizeRad(PortableMath.Atan2(scrap.Y, scrap.X));
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW)); program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break)); program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
} }
} }
// Each slug slice is a break cut inside its cutout's slug: pierced on itself, led straight
// back along itself to its start, then cut. It has no lead-out and keeps the kerf from
// the cutout, so its slug's pieces stay attached until the cutout is cut.
private static void EmitSlugSlices(Program program, IReadOnlyList<Support.SlugSlice> slices)
{
if (slices == null)
return;
foreach (var slice in slices)
{
program.Codes.Add(new RapidMove(slice.Pierce));
if (slice.Pierce.DistanceTo(slice.Start) > Tolerance.Epsilon)
program.Codes.Add(new LinearMove(slice.Start) { Layer = LayerType.Leadin });
program.Codes.Add(new LinearMove(slice.End) { Layer = LayerType.Break });
}
}
/// <summary> /// <summary>
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more /// Where a break cut (traveling with its scrap on the left) is entered: the end with more
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and /// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
/// whether that is the cut's far end. /// whether that is the cut's far end.
/// </summary> /// </summary>
@@ -1277,6 +1617,78 @@ namespace OpenNest.CNC.CuttingStrategy
: (start, startScrap, false); : (start, startScrap, false);
} }
/// <summary>
/// A common-line member's outside around the shared spans earlier members cut (on the Tab
/// layer; docs/common-line-cutting.md), each piece in the perimeter's own direction. With a
/// common-line entry, the first piece starts there, pierced in scrap with a straight lead-in
/// along the shared edge, and runs to the next span; the stretch before the entry is cut
/// last and ends at it. Every other piece starts where a shared span ends. It is pierced in that span's opened
/// kerf on the centerline, the external lead-in's reach back along the span, and enters along
/// a straight lead-in that ends at the piece's start, so compensation starts in the kerf. It
/// has no lead-out: it ends in the kerf at the next shared span. Capture checks that every
/// lead fits; emission never shortens one.
/// </summary>
private void EmitCommonLinePerimeter(Program program, Shape perimeter, CuttingPlanning.CommonLinePerimeter commonLine)
{
var winding = DetermineWinding(perimeter);
var leadIn = Parameters?.ExternalLeadIn ?? new NoLeadIn();
var entities = perimeter.Entities;
var pieces = TabbedCutoutPieces(perimeter);
if (commonLine.Entry is { } start && commonLine.Pierce is { } pierce)
{
// The piece holding the entry is cut from it to its end first, then the pieces after
// it in travel order, and its stretch before the entry last.
var at = pieces.FindIndex(p => p.Entities.Exists(e => EntityStartPoint(e).DistanceTo(start) <= Tolerance.Epsilon));
var piece = pieces[at];
var split = piece.Entities.FindIndex(e => EntityStartPoint(e).DistanceTo(start) <= Tolerance.Epsilon);
var head = new Shape();
head.Entities.AddRange(piece.Entities.Skip(split));
program.Codes.Add(new RapidMove(pierce));
program.Codes.Add(new Comment(CommonLineCuts.RemainderMarker(winding)));
program.Codes.Add(new LinearMove(start) { Layer = LayerType.Leadin });
program.Codes.AddRange(ConvertShapeToMoves(head, start));
var rest = pieces.Skip(at + 1).Concat(pieces.Take(at)).ToList();
if (split > 0)
{
var tail = new Shape();
tail.Entities.AddRange(piece.Entities.Take(split));
rest.Add(tail);
}
pieces = rest;
}
foreach (var piece in pieces)
{
var first = piece.Entities[0];
var index = entities.IndexOf(first);
var span = (Line)entities[(index + entities.Count - 1) % entities.Count];
var entry = EntityStartPoint(first);
program.Codes.Add(new RapidMove(CommonLineRemainderPierce(span, winding, commonLine.Kerf,
CommonLineLeadInReach(leadIn))));
// Posts compensate a remainder on its perimeter's side: it encloses nothing itself.
program.Codes.Add(new Comment(CommonLineCuts.RemainderMarker(winding)));
program.Codes.Add(new LinearMove(entry) { Layer = LayerType.Leadin });
program.Codes.AddRange(ConvertShapeToMoves(piece, entry));
}
}
/// <summary>
/// The pierce of a common-line remainder entered at the end of <paramref name="span"/> (a
/// straight shared span of a perimeter wound <paramref name="winding"/>): on the shared
/// centerline, half the <paramref name="kerf"/> outside the span, <paramref name="reach"/>
/// back along it from the piece's start.
/// </summary>
internal static Vector CommonLineRemainderPierce(Line span, RotationType winding, double kerf, double reach)
{
var along = (span.EndPoint - span.StartPoint).Normalize();
// Material lies left of a counterclockwise perimeter's travel, so outside is right.
var outward = winding == RotationType.CCW ? new Vector(along.Y, -along.X) : new Vector(-along.Y, along.X);
return span.EndPoint - along * reach + outward * (kerf / 2);
}
/// <summary>How far a common-line lead-in reaches: the external lead-in's pierce distance.</summary>
internal static double CommonLineLeadInReach(LeadIn leadIn) =>
leadIn.GetPiercePoint(Vector.Zero, 0, RotationType.CW).DistanceTo(Vector.Zero);
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary> /// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab); internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
@@ -1351,7 +1763,7 @@ namespace OpenNest.CNC.CuttingStrategy
return mirrored; return mirrored;
} }
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance. // 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); return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
} }
@@ -1387,9 +1799,9 @@ namespace OpenNest.CNC.CuttingStrategy
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation) private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
{ {
var a0 = System.Math.Atan2(start.Y - center.Y, start.X - center.X); var a0 = PortableMath.Atan2(start.Y - center.Y, start.X - center.X);
var a1 = System.Math.Atan2(end.Y - center.Y, end.X - center.X); var a1 = PortableMath.Atan2(end.Y - center.Y, end.X - center.X);
var ap = System.Math.Atan2(point.Y - center.Y, point.X - center.X); var ap = PortableMath.Atan2(point.Y - center.Y, point.X - center.X);
var sign = rotation == RotationType.CCW ? 1 : -1; var sign = rotation == RotationType.CCW ? 1 : -1;
var sweep = Angle.NormalizeRad(sign * (a1 - a0)); var sweep = Angle.NormalizeRad(sign * (a1 - a0));
if (sweep <= Tolerance.Epsilon) if (sweep <= Tolerance.Epsilon)
@@ -1446,8 +1858,8 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
var angle = atEnd ? arc.EndAngle : arc.StartAngle; var angle = atEnd ? arc.EndAngle : arc.StartAngle;
return arc.IsReversed return arc.IsReversed
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle)) ? new Vector(PortableMath.Sin(angle), -PortableMath.Cos(angle))
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle)); : new Vector(-PortableMath.Sin(angle), PortableMath.Cos(angle));
} }
var line = (Line)entity; var line = (Line)entity;
return line.EndPoint - line.StartPoint; return line.EndPoint - line.StartPoint;
@@ -15,13 +15,14 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn(); public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut(); 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; } = public LeadIn InternalLeadIn { get; set; } =
new LineLeadIn { Length = 0.125, ApproachAngle = 90 }; new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut(); 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; public double PierceClearance { get; set; } = 0.0625;
/// <summary> /// <summary>
@@ -40,12 +41,78 @@ namespace OpenNest.CNC.CuttingStrategy
public bool RoundLeadInAngles { get; set; } public bool RoundLeadInAngles { get; set; }
public double LeadInAngleIncrement { get; set; } = 5.0; public double LeadInAngleIncrement { get; set; } = 5.0;
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
public Tab TabConfig { get; set; } public Tab TabConfig { get; set; }
/// <summary>
/// Whether a contour is cut with a tab at its entry. The cutting settings never set it for
/// every part: Plan Cutting, Place Lead-in and drawing pins set it on the copies of the
/// settings they give the parts they tab. Parameters recorded with it on replay as planned.
/// </summary>
public bool TabsEnabled { get; set; } public bool TabsEnabled { get; set; }
/// <summary>
/// Tabs the parts that need one (see docs/support-tabs.md): with <see cref="TabBySize"/>
/// off, 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, or on a
/// plate with no bed each part in the size range; with it on, each part in the size range.
/// Needs <see cref="TabConfig"/> for the tab size.
/// </summary>
public bool SupportTabs { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/>, ignore the support bed and tab every part whose width is
/// in <see cref="TabSizeMinWidth"/> to <see cref="TabSizeMaxWidth"/>
/// (<see cref="TabsBySizeAt"/>).
/// </summary>
public bool TabBySize { get; set; }
/// <summary>
/// The narrowest part width that is tabbed by size: with <see cref="TabBySize"/>, or with
/// <see cref="SupportTabs"/> on a plate that has no support bed (see <see cref="TabsBySizeAt"/>).
/// </summary>
public double TabSizeMinWidth { get; set; }
/// <summary>
/// The widest part width that is tabbed by size; 0 turns the rule off, so nothing is
/// tabbed by size.
/// </summary>
public double TabSizeMaxWidth { get; set; }
/// <summary>Whether <see cref="TabSizeMinWidth"/> to <see cref="TabSizeMaxWidth"/> is a usable range.</summary>
public bool HasTabSizeRange() =>
TabSizeMaxWidth > 0 && double.IsFinite(TabSizeMaxWidth)
&& TabSizeMinWidth >= 0 && TabSizeMinWidth <= TabSizeMaxWidth;
/// <summary>
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
/// bounding rectangle) is tabbed by size.
/// </summary>
public bool TabsBySizeAt(double width) =>
HasTabSizeRange()
&& width >= TabSizeMinWidth - Math.Tolerance.Epsilon
&& width <= TabSizeMaxWidth + 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. New settings start at 6; settings saved without it read as 0.
/// </summary>
public double TabHeldMaxLength { get; set; } = 6;
/// <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;
/// <summary>
/// Slices the slug of each cutout its plate's support bed cannot hold level along the
/// bed's contact lines before the cutout is cut, so the slug drops as pieces between the
/// supports instead of tipping (see docs/support-tabs.md#slug-slices). Each slice stops
/// <see cref="Kerf"/> short of the cutout, so nothing is sliced without a kerf.
/// </summary>
public bool SliceTippingSlugs { get; set; }
public SequenceParameters Sequencing { get; set; } = new SequenceParameters(); public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters(); public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
} }
@@ -0,0 +1,143 @@
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 caller asks when the cutout has room for it, else almost
/// to its pole of inaccessibility; a round hole is pierced at its center. 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 center 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 center 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 depth, IReadOnlyList<Vector> targets, int max)
{
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|| !double.IsFinite(depth))
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;
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
return new(targets.Take(max).Select(_ => circle.Center).ToArray(), circle.Radius);
// 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.
depth = System.Math.Min(System.Math.Max(depth, 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 center 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 System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy 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 class ArcLeadIn : LeadIn
{ {
public const double DefaultSweepAngle = 180.0;
public double Radius { get; set; } public double Radius { get; set; }
/// <summary>Degrees of arc, in (0, 180].</summary>
public double SweepAngle { get; set; } = DefaultSweepAngle;
public override List<ICode> Generate( public override List<ICode> Generate(
Vector contourStartPoint, Vector contourStartPoint,
double contourNormalAngle, double contourNormalAngle,
RotationType winding = RotationType.CW 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> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }, 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); // The contour point sits opposite the normal from the center; the pierce is the
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle); // 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( return new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle), center.X + Radius * PortableMath.Cos(angle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle) center.Y + Radius * PortableMath.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 * PortableMath.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * PortableMath.Sin(contourNormalAngle)
);
} }
} }
@@ -1,11 +1,12 @@
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public class CleanHoleLeadIn : LeadIn public class CleanHoleLeadIn : LeadIn
{ {
private const double ApproachAngle = 135.0;
public double LineLength { get; set; } public double LineLength { get; set; }
public double ArcRadius { get; set; } public double ArcRadius { get; set; }
public double Kerf { get; set; } public double Kerf { get; set; }
@@ -14,42 +15,11 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint, Vector contourStartPoint,
double contourNormalAngle, double contourNormalAngle,
RotationType winding = RotationType.CW RotationType winding = RotationType.CW
) ) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); RotationType winding = RotationType.CW) =>
var arcCenter = new Vector(arcCenterX, arcCenterY); Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
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 LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn new CleanHoleLeadIn
@@ -58,5 +28,8 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor, ArcRadius = ArcRadius * factor,
Kerf = Kerf, 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 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 class LeadIn
{ {
public abstract List<ICode> Generate( public abstract List<ICode> Generate(
@@ -11,7 +16,8 @@ namespace OpenNest.CNC.CuttingStrategy
RotationType winding = RotationType.CW 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; 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 * PortableMath.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + arcRadius * PortableMath.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 * PortableMath.Cos(lineAngle);
var arcStartY = arcCenterY + arcRadius * PortableMath.Sin(lineAngle);
return new LineArcApproach(
new Vector(arcCenterX, arcCenterY),
new Vector(arcStartX, arcStartY),
new Vector(
arcStartX + lineLength * PortableMath.Cos(lineAngle),
arcStartY + lineLength * PortableMath.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 System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -14,42 +13,11 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint, Vector contourStartPoint,
double contourNormalAngle, double contourNormalAngle,
RotationType winding = RotationType.CW RotationType winding = RotationType.CW
) ) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); RotationType winding = RotationType.CW) =>
var arcCenter = new Vector(arcCenterX, arcCenterY); Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
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 LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new LineArcLeadIn new LineArcLeadIn
@@ -58,5 +26,8 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor, ArcRadius = ArcRadius * factor,
ApproachAngle = ApproachAngle, ApproachAngle = ApproachAngle,
}; };
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
} }
} }
@@ -24,12 +24,13 @@ 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); var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
return new Vector( return new Vector(
contourStartPoint.X + Length * System.Math.Cos(approachAngle), contourStartPoint.X + Length * PortableMath.Cos(approachAngle),
contourStartPoint.Y + Length * System.Math.Sin(approachAngle) contourStartPoint.Y + Length * PortableMath.Sin(approachAngle)
); );
} }
@@ -21,8 +21,8 @@ namespace OpenNest.CNC.CuttingStrategy
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1); var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midPoint = new Vector( var midPoint = new Vector(
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle), contourStartPoint.X + Length2 * PortableMath.Cos(secondAngle),
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle) contourStartPoint.Y + Length2 * PortableMath.Sin(secondAngle)
); );
return new List<ICode> return new List<ICode>
@@ -33,16 +33,17 @@ 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 secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle); var midX = contourStartPoint.X + Length2 * PortableMath.Cos(secondAngle);
var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle); var midY = contourStartPoint.Y + Length2 * PortableMath.Sin(secondAngle);
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2); var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
return new Vector( return new Vector(
midX + Length1 * System.Math.Cos(firstAngle), midX + Length1 * PortableMath.Cos(firstAngle),
midY + Length1 * System.Math.Sin(firstAngle) midY + Length1 * PortableMath.Sin(firstAngle)
); );
} }
@@ -14,7 +14,8 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> { new RapidMove(contourStartPoint) }; 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; return contourStartPoint;
} }
@@ -1,8 +1,13 @@
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy 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 class ArcLeadOut : LeadOut
{ {
public double Radius { get; set; } public double Radius { get; set; }
@@ -13,13 +18,16 @@ namespace OpenNest.CNC.CuttingStrategy
RotationType winding = RotationType.CW RotationType winding = RotationType.CW
) )
{ {
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle); var arcCenterX = contourEndPoint.X + Radius * PortableMath.Cos(contourNormalAngle);
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle); var arcCenterY = contourEndPoint.Y + Radius * PortableMath.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY); 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( var endPoint = new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2), arcCenterX + Radius * PortableMath.Cos(endAngle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2) arcCenterY + Radius * PortableMath.Sin(endAngle)
); );
return new List<ICode> return new List<ICode>
@@ -17,8 +17,8 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle); var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
var endPoint = new Vector( var endPoint = new Vector(
contourEndPoint.X + Length * System.Math.Cos(overcutAngle), contourEndPoint.X + Length * PortableMath.Cos(overcutAngle),
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle) contourEndPoint.Y + Length * PortableMath.Sin(overcutAngle)
); );
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } }; return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// Remembers <see cref="ContourCuttingStrategy.ResolvePierceZone"/> for one preparation's
/// contours. The resolution reads only the cutout's own geometry, the entry, the configured
/// lead-in and the zone, so for a given contour it is a pure function of those, yet the
/// search emits every contour of a part's chain again on every expansion, and on a cutout
/// whose configured pierce is inside the zone each resolution flattens the cutout, finds its
/// pole and erodes it. One memo serves every emission of one <c>PreparedContours</c> and
/// its copies, which share the contours by ordinal. A lead-in a memo returns is shared and
/// must not be mutated.
/// </summary>
internal sealed class PierceZoneMemo
{
private readonly Dictionary<Key, LeadIn> resolved = new();
private readonly object gate = new();
/// <summary>How many resolutions the memo answered without computing; for tests.</summary>
internal int Hits { get; private set; }
internal LeadIn Resolve(int contour, Shape hole, Vector point, LeadIn leadIn, double normal,
double pierceRadius, RotationType turn)
{
if (KeyOf(leadIn) is not { } lead)
return ContourCuttingStrategy.ResolvePierceZone(hole, point, leadIn, normal, pierceRadius, turn);
var key = new Key(contour, point.X, point.Y, normal, pierceRadius, turn, lead);
lock (gate)
{
if (resolved.TryGetValue(key, out var known))
{
Hits++;
return known;
}
}
var result = ContourCuttingStrategy.ResolvePierceZone(hole, point, leadIn, normal, pierceRadius, turn);
lock (gate)
{
resolved[key] = result;
}
return result;
}
// The configured lead-in by value: an equal lead-in resolves the same way whichever
// instance carries it. A lead-in type this does not know is resolved every time.
private static LeadKey? KeyOf(LeadIn leadIn) => leadIn switch
{
NoLeadIn => new(0, 0, 0, 0, 0),
LineLeadIn line => new(1, line.Length, line.ApproachAngle, 0, 0),
ArcLeadIn arc => new(2, arc.Radius, arc.SweepAngle, 0, 0),
LineArcLeadIn lineArc => new(3, lineArc.LineLength, lineArc.ApproachAngle, lineArc.ArcRadius, 0),
CleanHoleLeadIn cleanHole => new(4, cleanHole.LineLength, cleanHole.ArcRadius, cleanHole.Kerf, 0),
LineLineLeadIn lineLine => new(5, lineLine.Length1, lineLine.ApproachAngle1, lineLine.Length2, lineLine.ApproachAngle2),
_ => null,
};
private readonly record struct LeadKey(int Kind, double A, double B, double C, double D);
private readonly record struct Key(int Contour, double X, double Y, double Normal, double PierceRadius,
RotationType Turn, LeadKey Lead);
}
}
@@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -23,8 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
var scoreAngle = contourNormalAngle + System.Math.PI; var scoreAngle = contourNormalAngle + System.Math.PI;
var scoreEnd = new Vector( var scoreEnd = new Vector(
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle), tabStartPoint.X + BreakerDepth * PortableMath.Cos(scoreAngle),
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle) tabStartPoint.Y + BreakerDepth * PortableMath.Sin(scoreAngle)
); );
codes.Add(new LinearMove(scoreEnd)); codes.Add(new LinearMove(scoreEnd));
codes.Add(new RapidMove(tabEndPoint)); codes.Add(new RapidMove(tabEndPoint));
+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. Plan cutting first (Plate > Plan Cutting..., or --plan-cutting) 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));
}
}
}
+84
View File
@@ -0,0 +1,84 @@
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) =>
PostMarkers.EnsurePostable(nest, hasCodes, IsMarked, program => LockedMask(program.Codes),
name => $"{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 "
+ $"\"Head height lock\" and \"Head height unlock\" in the {postName} Settings "
+ "dialog, which opens when you post, or delete the cut-off and add it again "
+ "with \"Lock head height and overcut the far edge\" unchecked.");
}
}
+87
View File
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.CNC
{
/// <summary>
/// A rapid the head must make raised because it passes over parts already cut, which may have
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing, and always
/// marks the rapid out of a cutout's slug after its lead-out (see docs/cutting-planner.md): a
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>, or before the
/// <see cref="SubProgramCall"/> whose arrival rapid it is, since a sub-program may be shared and
/// is never marked itself. Rapid checks accept a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
/// See docs/cutting-planner.md.
/// </summary>
public static class HeadRaise
{
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
public const string Marker = "HEAD RAISE";
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsMarker(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised, or a
/// sub-program call whose arrival rapid is. A marker must be followed directly by a rapid or
/// a call; anything else is a malformed program (<see cref="ArgumentException"/>), since a
/// post could raise before the wrong move.
/// </summary>
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
for (var i = 0; i < codes.Count; i++)
{
if (!IsMarker(codes[i]))
continue;
if (i + 1 >= codes.Count || codes[i + 1] is not (RapidMove or SubProgramCall))
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
mask[i + 1] = true;
}
return mask;
}
/// <summary>
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
/// <paramref name="rapids"/>, which must be rapids or sub-program calls not marked already.
/// </summary>
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(rapids);
var marked = RaisedMask(program.Codes);
var copy = (Program)program.Clone();
for (var i = copy.Codes.Count - 1; i >= 0; i--)
{
if (!rapids.Contains(i))
continue;
if (copy.Codes[i] is not (RapidMove or SubProgramCall) || marked[i])
throw new ArgumentException("Only an unmarked rapid or sub-program call can be marked for a head raise.");
copy.Codes.Insert(i, new Comment(Marker));
}
return copy;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
/// the post's head-raise code, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, string postName) =>
PostMarkers.EnsurePostable(nest, hasCode, IsMarked, program => RaisedMask(program.Codes),
name => $"{name} has rapids that must raise the head over parts already cut, but "
+ $"the {postName} post has no head-raise code. Enter it in \"Head raise\" in "
+ $"the {postName} Settings dialog, which opens when you post.");
}
}
+17
View File
@@ -21,5 +21,22 @@ namespace OpenNest.CNC
/// leaves it out, cutting the rest of the contour as open pieces. /// leaves it out, cutting the rest of the contour as open pieces.
/// </summary> /// </summary>
Tab, 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,
/// <summary>
/// A shared cut of a common-line group (see docs/common-line-cutting.md): an open cut on the
/// centerline between two parts placed one kerf apart, which cuts both parts' facing edges
/// at once. It is cut without kerf compensation, bounds no material and is never a rapid
/// obstacle. Posts that cannot write it refuse the nest.
/// </summary>
CommonLine,
} }
} }
+37
View File
@@ -0,0 +1,37 @@
using System;
namespace OpenNest.CNC
{
internal static class PostMarkers
{
/// <summary>
/// Refuses a nest whose marked programs a post cannot write: every marked part needs the
/// post's <paramref name="hasCode"/>, and <paramref name="validate"/> rejects a malformed
/// program with an <see cref="ArgumentException"/>.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, Func<Program, bool> isMarked,
Action<Program> validate, Func<string, string> missingCode)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !isMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCode)
throw new InvalidOperationException(missingCode(name));
try
{
validate(part.Program);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
+164
View File
@@ -0,0 +1,164 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.Posting;
/// <summary>
/// Which posted contour is the perimeter, and which compensation side keeps the kerf in the
/// scrap. Imported drawings are wound perimeter CW and holes CCW
/// (<see cref="ShapeProfile.NormalizeWinding"/>), which puts the part right of travel and gives
/// G41 throughout; a contour wound the other way (built in code or edited by hand) needs G42.
/// Paths that enclose no area (open break cuts) get G41, a defensive default.
/// </summary>
public static class ContourWinding
{
/// <summary>Signed shoelace area of the cut path; negative when clockwise, 0 when degenerate.</summary>
public static double SignedArea(PostContour contour)
{
var pts = SamplePoints(contour);
if (pts.Count < 3)
return 0.0;
var sum = 0.0;
for (var i = 0; i < pts.Count; i++)
{
var a = pts[i];
var b = pts[(i + 1) % pts.Count];
sum += a.X * b.Y - b.X * a.Y;
}
return sum / 2.0;
}
/// <summary>
/// Index of the perimeter: the material contour enclosing the largest bounding box, as
/// <see cref="ShapeProfile"/> picks it. -1 when none encloses any area.
/// </summary>
public static int PerimeterIndex(IReadOnlyList<PostContour> contours)
{
var best = -1;
var bestArea = 0.0;
for (var i = 0; i < contours.Count; i++)
{
if (contours[i].IsMark || !EnclosesArea(contours[i]))
continue;
var area = BoundingBoxArea(SamplePoints(contours[i]));
if (best < 0 || area > bestArea)
{
best = i;
bestArea = area;
}
}
return best;
}
/// <summary>
/// <see cref="KerfType.Left"/> (G41, part right of travel) for a CW perimeter, a CCW hole or
/// a path enclosing no area; otherwise <see cref="KerfType.Right"/> (G42).
/// </summary>
public static KerfType CompensationSide(PostContour contour, bool isPerimeter)
{
if (!EnclosesArea(contour))
return KerfType.Left;
var clockwise = SignedArea(contour) < 0.0;
return clockwise == isPerimeter ? KerfType.Left : KerfType.Right;
}
private static bool EnclosesArea(PostContour contour) =>
System.Math.Abs(SignedArea(contour)) > 1e-9;
private static double BoundingBoxArea(List<Vector> pts)
{
double minX = double.MaxValue, minY = double.MaxValue;
double maxX = double.MinValue, maxY = double.MinValue;
foreach (var p in pts)
{
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);
}
return (maxX - minX) * (maxY - minY);
}
/// <summary>
/// Ordered points along the path: the contour start, then each cut endpoint, with arc
/// interior samples at no more than 90-degree steps so full circles give a well-formed
/// polygon. Consecutive duplicates are dropped so zero-length lead-ins do not skew it.
/// </summary>
private static List<Vector> SamplePoints(PostContour contour)
{
var pts = new List<Vector>();
void Add(Vector v)
{
if (pts.Count == 0 || pts[^1].DistanceTo(v) > 1e-9)
pts.Add(v);
}
var prev = contour.LeadIn != null ? contour.LeadIn.EndPoint : contour.Pierce;
Add(prev);
foreach (var cut in contour.Cuts)
{
if (cut is ArcMove arc)
{
foreach (var mid in ArcSamplePoints(prev, arc))
Add(mid);
}
Add(cut.EndPoint);
prev = cut.EndPoint;
}
return pts;
}
/// <summary>
/// Interior points on the arc from <paramref name="prev"/> to its endpoint at no more than
/// ~90-degree steps. A full circle yields four, so its shoelace sign is its true winding.
/// </summary>
private static List<Vector> ArcSamplePoints(Vector prev, ArcMove arc)
{
var points = new List<Vector>();
var center = arc.CenterPoint;
var radius = center.DistanceTo(arc.EndPoint);
if (radius < 1e-12)
return points;
var a0 = PortableMath.Atan2(prev.Y - center.Y, prev.X - center.X);
var a1 = PortableMath.Atan2(arc.EndPoint.Y - center.Y, arc.EndPoint.X - center.X);
var sweep = arc.Rotation == RotationType.CW ? a0 - a1 : a1 - a0;
if (sweep <= 0)
sweep += 2.0 * System.Math.PI;
// Full circle (start == end) reads as zero sweep above.
if (
sweep < 1e-9
&& System.Math.Abs(prev.X - arc.EndPoint.X) < 1e-9
&& System.Math.Abs(prev.Y - arc.EndPoint.Y) < 1e-9
)
sweep = 2.0 * System.Math.PI;
var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0;
var steps = System.Math.Max(1, (int)System.Math.Ceiling(sweep / (System.Math.PI / 2.0)));
for (var k = 1; k <= steps; k++)
{
var angle = a0 + direction * sweep * k / (steps + 1);
points.Add(
new Vector(
center.X + radius * PortableMath.Cos(angle),
center.Y + radius * PortableMath.Sin(angle)
)
);
}
return points;
}
}
+79
View File
@@ -0,0 +1,79 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.Posting;
/// <summary>
/// One contour of a posted part: pierce, lead-in and cut path in a single absolute frame
/// (sheet coordinates from <see cref="PostContours.Extract(Part)"/>). Arc moves keep their
/// absolute centres; each post formats I/J its own way.
/// </summary>
public sealed class PostContour
{
/// <summary>Pierce point: the rapid target.</summary>
public Vector Pierce { get; set; }
/// <summary>
/// First lead-in move, ending on the contour, or null when the contour starts cutting at
/// the pierce.
/// </summary>
public Motion LeadIn { get; set; }
/// <summary>Lead-in moves after the first (e.g. the arc after a clean-hole line).</summary>
public List<Motion> LeadInExtra { get; } = new();
/// <summary>Moves made with the cut layer on: cut, lead-out, display and scribe.</summary>
public List<Motion> Cuts { get; } = new();
/// <summary>
/// Head-height lock changes in a cut-off's overcut: each comes before the cut at
/// <c>Before</c> (<see cref="Cuts"/>.Count: after the last cut), locking when <c>Lock</c>.
/// </summary>
public List<(int Before, bool Lock)> HeadLocks { get; } = new();
/// <summary>Whether the rapid to the pierce raises the head over parts already cut (<see cref="HeadRaise"/>).</summary>
public bool Raised { get; set; }
/// <summary>
/// True for a common line's shared cut (<see cref="LayerType.CommonLine"/>): an open
/// centerline cut made without kerf compensation.
/// </summary>
public bool IsCommonLine { get; set; }
/// <summary>
/// For a common-line remainder, the winding of the perimeter it belongs to
/// (<see cref="CommonLineCuts"/>): it is cut as the perimeter, compensated on that side.
/// Null for every other contour.
/// </summary>
public RotationType? RemainderWinding { get; set; }
/// <summary>
/// True for the part's outside edge, or a common-line remainder of it; false for holes,
/// marks, shared cuts and cut-offs.
/// </summary>
public bool IsPerimeter { get; set; }
/// <summary>
/// The compensation that keeps the kerf in the scrap: <see cref="KerfType.Left"/> (G41)
/// leaves the part right of travel, <see cref="KerfType.Right"/> (G42) left of it.
/// <see cref="KerfType.None"/> for marks, common-line shared cuts and cut-offs, which have
/// no inside.
/// </summary>
public KerfType Kerf { get; set; }
/// <summary>True when every cut is on the scribe layer: a mark, not material.</summary>
public bool IsMark
{
get
{
if (Cuts.Count == 0)
return false;
foreach (var cut in Cuts)
{
if (PostContours.LayerOf(cut) != LayerType.Scribe)
return false;
}
return true;
}
}
}
+217
View File
@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.Posting;
/// <summary>
/// Reduces a part program to the contours a post writes: hole calls and incremental programs
/// flattened to one absolute frame, each contour split into pierce, lead-in and cuts, the
/// perimeter picked out and the kerf side chosen. Posts format these; they do not redo the
/// geometry. Caller programs are never modified.
/// </summary>
public static class PostContours
{
/// <summary>
/// The part's contours in sheet coordinates. A display perimeter's tail is never cut and an
/// unplanned one is refused (<see cref="DisplayPerimeterTail.ForPosting"/>). Cut-off contours
/// are open lines on the beam centreline: no perimeter and no kerf.
/// </summary>
public static List<PostContour> Extract(Part part)
{
if (part == null)
throw new ArgumentNullException(nameof(part));
var program = DisplayPerimeterTail.ForPosting(part.Program, DisplayPerimeterTail.PartName(part));
var contours = Extract(program, part.Location);
if (part.BaseDrawing?.IsCutOff == true)
{
foreach (var contour in contours)
{
contour.IsPerimeter = false;
contour.Kerf = KerfType.None;
}
}
return contours;
}
/// <summary>
/// The program's contours with <paramref name="origin"/> added to every point: the part
/// location for sheet coordinates, <see cref="Vector.Zero"/> for the part's own frame.
/// </summary>
public static List<PostContour> Extract(Program program, Vector origin)
{
var contours = Flatten(program, origin);
Classify(contours);
return contours;
}
/// <summary>
/// The program's contours in the <paramref name="origin"/> frame, with no perimeter or kerf
/// side chosen yet. A post that splits one part across several calls gathers them here and
/// classifies the whole part with <see cref="Classify"/>.
/// </summary>
public static List<PostContour> Flatten(Program program, Vector origin)
{
var contours = new List<PostContour>();
FlattenInto(program, origin, contours);
return contours;
}
/// <summary>
/// Picks one part's perimeter (marks excluded) and each contour's kerf side. A common-line
/// member's outside is its remainders, each compensated on its perimeter's side, so its
/// closed contours are all holes; a shared cut has no kerf side.
/// </summary>
public static void Classify(IReadOnlyList<PostContour> contours)
{
var remainders = false;
foreach (var contour in contours)
remainders |= contour.RemainderWinding != null;
var perimeter = remainders ? -1 : ContourWinding.PerimeterIndex(contours);
for (var i = 0; i < contours.Count; i++)
{
var contour = contours[i];
if (contour.RemainderWinding is { } winding)
{
// G41 leaves the product right of travel: a clockwise perimeter's side.
contour.IsPerimeter = true;
contour.Kerf = winding == RotationType.CW ? KerfType.Left : KerfType.Right;
continue;
}
contour.IsPerimeter = i == perimeter;
contour.Kerf = contour.IsMark || contour.IsCommonLine
? KerfType.None
: ContourWinding.CompensationSide(contour, contour.IsPerimeter);
}
}
/// <summary>
/// Walks one program whose coordinates are relative to <paramref name="origin"/>; hole calls
/// recurse with their offset added. A contour starts at each rapid. Moves before the first
/// rapid start at the frame origin, where an absolute program begins. A cut after a run of
/// suppressed moves starts a new contour pierced where the run ends, so the gap is crossed
/// with the beam off rather than cut as a chord. A head-raise marker raises the head for
/// the next contour's rapid; <paramref name="raiseFirst"/> carries a marker before the hole
/// call that runs this program.
/// </summary>
private static void FlattenInto(Program program, Vector origin, List<PostContour> contours,
bool raiseFirst = false)
{
if (program == null)
return;
var codes = program.Codes;
if (program.Mode == Mode.Incremental)
{
var clone = (Program)program.Clone();
clone.Mode = Mode.Absolute;
codes = clone.Codes;
}
PostContour current = null;
Vector? resumeAt = null;
var raise = raiseFirst;
PostContour Start(Vector pierce)
{
var contour = new PostContour { Pierce = pierce, Raised = raise };
raise = false;
return contour;
}
void Flush()
{
if (current != null && (current.LeadIn != null || current.Cuts.Count > 0))
contours.Add(current);
current = null;
}
foreach (var code in codes)
{
if (code is Motion { Suppressed: true } suppressed)
{
resumeAt = suppressed.EndPoint;
continue;
}
if (HeadRaise.IsMarker(code))
{
raise = true;
continue;
}
if (CommonLineCuts.RemainderWinding(code) is { } winding)
{
current ??= Start(origin);
current.RemainderWinding = winding;
continue;
}
if (HeadHeightLock.IsLock(code) || HeadHeightLock.IsUnlock(code))
{
current ??= Start(origin);
current.HeadLocks.Add((current.Cuts.Count, HeadHeightLock.IsLock(code)));
continue;
}
switch (code)
{
case RapidMove rapid:
Flush();
resumeAt = null;
current = Start(rapid.EndPoint + origin);
break;
case SubProgramCall call:
// A hole call is its own contour.
Flush();
resumeAt = null;
FlattenInto(call.Program, origin + call.Offset, contours, raise);
raise = false;
break;
case Motion motion:
if (resumeAt.HasValue)
{
Flush();
current = Start(resumeAt.Value + origin);
resumeAt = null;
}
current ??= Start(origin);
var absolute = (Motion)motion.Clone();
absolute.Offset(origin);
var layer = LayerOf(motion);
if (layer == LayerType.Leadin)
{
if (current.LeadIn == null)
current.LeadIn = absolute;
else
current.LeadInExtra.Add(absolute);
}
else
{
// Contour moves are tagged Display by default and still cut.
current.Cuts.Add(absolute);
if (layer == LayerType.CommonLine)
current.IsCommonLine = true;
}
break;
}
}
Flush();
}
internal static LayerType LayerOf(Motion motion) =>
motion switch
{
LinearMove linear => linear.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Cut,
};
}
+28 -31
View File
@@ -37,6 +37,16 @@ namespace OpenNest.CNC
public double Rotation { get; protected set; } public double Rotation { get; protected set; }
/// <summary>
/// True once <see cref="Part.CloneAtOffset"/> has handed this instance to a second
/// part. A part must copy a shared program before changing it in place, whether or not
/// it owns it. Never cleared: the parts sharing it are not tracked, so a program that
/// was shared once is treated as shared for good, at the cost of one copy. It belongs
/// to the instance, not its content: <see cref="Clone"/> and <see cref="HeadCopy"/>
/// build a new program and leave it unset.
/// </summary>
internal bool SharedByParts { get; set; }
private void SetModeInc() private void SetModeInc()
{ {
if (mode == Mode.Incremental) if (mode == Mode.Incremental)
@@ -103,8 +113,8 @@ namespace OpenNest.CNC
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0) if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
{ {
var cos = System.Math.Cos(angle); var cos = PortableMath.Cos(angle);
var sin = System.Math.Sin(angle); var sin = PortableMath.Sin(angle);
var dx = subpgm.Offset.X - origin.X; var dx = subpgm.Offset.X - origin.X;
var dy = subpgm.Offset.Y - origin.Y; var dy = subpgm.Offset.Y - origin.Y;
subpgm.Offset = new Geometry.Vector( subpgm.Offset = new Geometry.Vector(
@@ -158,35 +168,7 @@ namespace OpenNest.CNC
SetModeInc(); SetModeInc();
} }
public virtual void Offset(Vector voffset) public virtual void Offset(Vector voffset) => Offset(voffset.X, voffset.Y);
{
var mode = Mode;
SetModeAbs();
for (int i = 0; i < Codes.Count; ++i)
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + voffset.X,
subpgm.Offset.Y + voffset.Y
);
}
if (code is Motion == false)
continue;
var code2 = (Motion)code;
code2.Offset(voffset);
}
if (mode == Mode.Incremental)
SetModeInc();
}
public void LineTo(double x, double y) public void LineTo(double x, double y)
{ {
@@ -508,6 +490,21 @@ namespace OpenNest.CNC
return true; 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() public object Clone()
{ {
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation }; 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) 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 // Draw the approach to the first rapid destination, or to the program
// origin when it starts cutting without a rapid. Only an initial rapid // origin when it starts cutting without a rapid. Only an initial rapid
// (or call arrival) is already represented by this approach; a later // (or call arrival) is already represented by this approach; a later
+2 -1
View File
@@ -1,6 +1,7 @@
using System.Linq; using System.Linq;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest namespace OpenNest
{ {
@@ -123,7 +124,7 @@ namespace OpenNest
// keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the // keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the
// interval edge so floating-point noise cannot flip the choice. // interval edge so floating-point noise cannot flip the choice.
var halfPi = System.Math.PI / 2.0; var halfPi = System.Math.PI / 2.0;
var direction = System.Math.Atan2(dy, dx); var direction = PortableMath.Atan2(dy, dx);
for (var turns = 0; turns < 4; turns++) for (var turns = 0; turns < 4; turns++)
{ {
var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin); var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin);
@@ -1,6 +1,6 @@
using System; using System;
namespace OpenNest namespace OpenNest.Clamps
{ {
/// <summary> /// <summary>
/// The sheet edge a <see cref="ClampRail"/> clamp sits on, named relative to the plate /// The sheet edge a <see cref="ClampRail"/> clamp sits on, named relative to the plate
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest namespace OpenNest.Clamps
{ {
/// <summary> /// <summary>
/// The machine's fixed clamp rail: identical rectangular clamps, each on one sheet edge /// The machine's fixed clamp rail: identical rectangular clamps, each on one sheet edge
@@ -26,14 +26,16 @@ namespace OpenNest
double width, double width,
double depth, double depth,
IEnumerable<double> centers, IEnumerable<double> centers,
double removalMinutes = DefaultRemovalMinutes double removalMinutes = DefaultRemovalMinutes,
double? clearance = null
) )
: this( : this(
width, width,
depth, depth,
(centers ?? throw new ArgumentNullException(nameof(centers))) (centers ?? throw new ArgumentNullException(nameof(centers)))
.Select(c => new RailClamp(ClampEdge.OriginSide, c)), .Select(c => new RailClamp(ClampEdge.OriginSide, c)),
removalMinutes removalMinutes,
clearance
) )
{ } { }
@@ -41,7 +43,8 @@ namespace OpenNest
double width, double width,
double depth, double depth,
IEnumerable<RailClamp> clamps, IEnumerable<RailClamp> clamps,
double removalMinutes = DefaultRemovalMinutes double removalMinutes = DefaultRemovalMinutes,
double? clearance = null
) )
{ {
ArgumentNullException.ThrowIfNull(clamps); ArgumentNullException.ThrowIfNull(clamps);
@@ -60,6 +63,11 @@ namespace OpenNest
nameof(removalMinutes), nameof(removalMinutes),
"Clamp removal time must be finite and zero or more." "Clamp removal time must be finite and zero or more."
); );
if (clearance is double c && (!double.IsFinite(c) || c < 0))
throw new ArgumentOutOfRangeException(
nameof(clearance),
"Clamp clearance must be finite and zero or more."
);
var sorted = new SortedSet<RailClamp>(); var sorted = new SortedSet<RailClamp>();
foreach (var clamp in clamps) foreach (var clamp in clamps)
@@ -77,6 +85,7 @@ namespace OpenNest
Width = width; Width = width;
Depth = depth; Depth = depth;
RemovalMinutes = removalMinutes; RemovalMinutes = removalMinutes;
Clearance = clearance;
Clamps = sorted.ToArray(); Clamps = sorted.ToArray();
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray(); Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
} }
@@ -93,6 +102,14 @@ namespace OpenNest
/// </summary> /// </summary>
public double RemovalMinutes { get; } public double RemovalMinutes { get; }
/// <summary>
/// The gap parts keep from every clamp footprint, independent of part spacing; null
/// keeps the sheet's bottom edge spacing (D6), the clearance before clamps had their
/// own. A clamp drawn as a support block under the sheet can use close to zero, so the
/// laser only stays off it. See <see cref="Plate.ClampClearance"/>.
/// </summary>
public double? Clearance { get; }
/// <summary> /// <summary>
/// Distinct clamps ordered by edge, then center. A clamp's position in this list /// Distinct clamps ordered by edge, then center. A clamp's position in this list
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids /// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
@@ -103,9 +120,9 @@ namespace OpenNest
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary> /// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
public IReadOnlyList<double> Centers { get; } public IReadOnlyList<double> Centers { get; }
/// <summary>A copy with the same size and removal time and these clamps.</summary> /// <summary>A copy with the same size, removal time and clearance and these clamps.</summary>
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) => public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
new(Width, Depth, clamps, RemovalMinutes); new(Width, Depth, clamps, RemovalMinutes, Clearance);
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary> /// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
public static string IdOf(int index) => $"clamp-{index + 1}"; public static string IdOf(int index) => $"clamp-{index + 1}";
@@ -225,20 +242,22 @@ namespace OpenNest
&& Width.Equals(other.Width) && Width.Equals(other.Width)
&& Depth.Equals(other.Depth) && Depth.Equals(other.Depth)
&& RemovalMinutes.Equals(other.RemovalMinutes) && RemovalMinutes.Equals(other.RemovalMinutes)
&& Nullable.Equals(Clearance, other.Clearance)
&& Clamps.SequenceEqual(other.Clamps); && Clamps.SequenceEqual(other.Clamps);
public override bool Equals(object obj) => Equals(obj as ClampRail); public override bool Equals(object obj) => Equals(obj as ClampRail);
public override int GetHashCode() public override int GetHashCode()
{ {
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count); var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clearance, Clamps.Count);
foreach (var clamp in Clamps) foreach (var clamp in Clamps)
hash = HashCode.Combine(hash, clamp); hash = HashCode.Combine(hash, clamp);
return hash; return hash;
} }
public override string ToString() => public override string ToString() =>
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]"; $"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]"
+ (Clearance is double clearance ? $" clearance {clearance:R}" : "");
private static string Describe(RailClamp clamp) => private static string Describe(RailClamp clamp) =>
clamp.Edge == ClampEdge.OriginSide clamp.Edge == ClampEdge.OriginSide
@@ -1,7 +1,7 @@
using System; using System;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest namespace OpenNest.Clamps
{ {
/// <summary> /// <summary>
/// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame: /// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest; namespace OpenNest.Clamps;
/// <summary> /// <summary>
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame /// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
@@ -0,0 +1,323 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CommonLines
{
/// <summary>
/// One shared cut of a common-line group: a straight span, or spans chained at corners (a Z or
/// L shared line). <see cref="Owner"/>, the member of the pair cut first, cuts it as part of its
/// own compensated perimeter, which lands on the kerf centerline. <see cref="Partner"/> is cut
/// later; its remainder ends in the kerf this cut opened.
/// </summary>
public sealed record CommonLineSharedCut(CommonLinePath Path, Part Owner, Part Partner);
/// <summary>
/// How a common-line group is cut once (see docs/common-line-cutting.md): its members in cutting
/// order, and each shared cut with the member that cuts it.
/// </summary>
public sealed record CommonLineCutOrder(CommonLineGroup Group, IReadOnlyList<Part> Members,
IReadOnlyList<CommonLineSharedCut> Cuts)
{
/// <summary>The shared cuts <paramref name="member"/> cuts.</summary>
public IEnumerable<CommonLineSharedCut> CutsOwnedBy(Part member) =>
Cuts.Where(cut => ReferenceEquals(cut.Owner, member));
/// <summary>
/// The shared paths an earlier member cuts and <paramref name="member"/> therefore never
/// does: its remainder ends in their kerf.
/// </summary>
public IEnumerable<CommonLinePath> PathsCutBefore(Part member) => Cuts
.Where(cut => ReferenceEquals(cut.Partner, member))
.Select(cut => cut.Path);
/// <summary>Every shared path <paramref name="member"/> shares, whoever cuts it.</summary>
public IEnumerable<CommonLinePath> PathsOf(Part member) => Cuts
.Where(cut => ReferenceEquals(cut.Owner, member) || ReferenceEquals(cut.Partner, member))
.Select(cut => cut.Path);
}
/// <summary>
/// Single-pass common-line cutting: which member cuts each shared span, and the planner's owned
/// clean perimeter with its shared spans marked as never cut by that member.
/// </summary>
public static class CommonLineCutting
{
/// <summary>
/// The cutting order of an intact group: members breadth-first from its first member along
/// shared spans (ties in member order), each shared cut owned by the member cut first. A
/// member's remainder therefore only ever ends in kerf cut before it.
/// </summary>
/// <exception cref="ArgumentException">The group is not intact.</exception>
public static CommonLineCutOrder Order(CommonLineGroup group, CommonLineCheck check)
{
ArgumentNullException.ThrowIfNull(group);
ArgumentNullException.ThrowIfNull(check);
if (!check.IsIntact)
throw new ArgumentException(check.Problems[0].Message, nameof(check));
var members = group.Members;
int IndexOf(Part part) => members.ToList().FindIndex(member => ReferenceEquals(member, part));
var order = new List<Part>();
var seen = new bool[members.Count];
var queue = new Queue<int>();
queue.Enqueue(0);
seen[0] = true;
while (queue.Count > 0)
{
var current = members[queue.Dequeue()];
order.Add(current);
var neighbors = check.Spans
.Select(span => ReferenceEquals(span.First, current) ? span.Second
: ReferenceEquals(span.Second, current) ? span.First : null)
.Where(part => part != null)
.Select(IndexOf)
.Distinct()
.Order();
foreach (var next in neighbors)
if (!seen[next])
{
seen[next] = true;
queue.Enqueue(next);
}
}
if (order.Count != members.Count)
throw new ArgumentException("The common-line group has split into separate pieces.", nameof(check));
int Rank(Part part) => order.FindIndex(member => ReferenceEquals(member, part));
var cuts = CommonLineGeometry.Paths(check.Spans, group.Kerf)
.Select(path => Rank(path.First) < Rank(path.Second)
? new CommonLineSharedCut(path, path.First, path.Second)
: new CommonLineSharedCut(path, path.Second, path.First))
.OrderBy(cut => Rank(cut.Owner))
.ToList();
return new CommonLineCutOrder(group, order, cuts);
}
/// <summary>
/// <see cref="MarkSharedSpans(Program, Vector, IReadOnlyList{CommonLinePath}, double)"/> with
/// every span a straight path of its own.
/// </summary>
public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList<CommonLineSpan> spans,
double kerf)
{
ArgumentNullException.ThrowIfNull(spans);
return MarkSharedSpans(clean, location, spans
.Select(span => new CommonLinePath(span.First, span.Second, [span], [span.Start, span.End]))
.ToArray(), kerf);
}
/// <summary>
/// A copy of <paramref name="clean"/> (a member's clean program, rotated as placed, in part
/// coordinates; placed at <paramref name="location"/>) whose perimeter edges are split at
/// every end of the shared <paramref name="paths"/> and whose shared portions are on the
/// <see cref="LayerType.Tab"/> layer: material that this member never cuts, because the
/// shared cut does. At a path's corner each leg's portion runs to the member's own vertex
/// there, so the legs' portions join. The result is absolute. Only straight top-level
/// moves can share.
/// </summary>
/// <exception cref="ArgumentException">A path's leg does not lie along the program's edges.</exception>
public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList<CommonLinePath> paths,
double kerf)
{
ArgumentNullException.ThrowIfNull(clean);
ArgumentNullException.ThrowIfNull(paths);
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf));
var program = (Program)clean.Clone();
program.Mode = Mode.Absolute;
var legs = paths.SelectMany(path => Legs(path, location, kerf)).ToArray();
var covered = new double[legs.Length];
var codes = new List<ICode>();
Vector? position = null;
foreach (var code in program.Codes)
{
if (code is LinearMove line && position is { } start
&& line.Layer is LayerType.Cut or LayerType.Display)
{
codes.AddRange(Split(line, start, legs, covered, kerf));
position = line.EndPoint;
continue;
}
codes.Add(code);
position = code switch
{
Motion motion => motion.EndPoint,
SubProgramCall => null,
_ => position,
};
}
for (var i = 0; i < legs.Length; i++)
{
var leg = legs[i];
// At a corner the member's portion ends at its own vertex, up to the snap off the centerline's.
var slack = leg.StartSnap + leg.EndSnap + CommonLineGeometry.GapTolerance;
if (covered[i] <= Math.Tolerance.Epsilon
|| System.Math.Abs(covered[i] - leg.Start.DistanceTo(leg.End)) > slack)
{
var (a, b) = (leg.Start + location, leg.End + location);
throw new ArgumentException(FormattableString.Invariant(
$"The shared span from ({a.X:0.####}, {a.Y:0.####}) to ({b.X:0.####}, {b.Y:0.####}) does not lie along one straight edge of the part's program."));
}
}
program.Codes.Clear();
program.Codes.AddRange(codes);
return program;
}
/// <summary>
/// Where a member that cuts <paramref name="paths"/> as part of its own perimeter can start,
/// so that each common line leads straight into the rest of the perimeter: for each path,
/// the start of the perimeter edge (a top-level straight move of <paramref name="clean"/>)
/// along which the member's own travel enters the path, a pierce <paramref name="reach"/>
/// back along that edge's axis, and the edge's end. Part coordinates (the program placed at
/// <paramref name="location"/>), in path order.
/// </summary>
public static IReadOnlyList<(Vector Entry, Vector Pierce, Vector Next)> PerimeterEntries(Program clean,
Vector location, IReadOnlyList<CommonLinePath> paths, double kerf, double reach)
{
ArgumentNullException.ThrowIfNull(clean);
ArgumentNullException.ThrowIfNull(paths);
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf));
var program = (Program)clean.Clone();
program.Mode = Mode.Absolute;
var edges = new List<(Vector Start, Vector End)>();
Vector? position = null;
foreach (var code in program.Codes)
{
if (code is LinearMove line && position is { } from && line.Layer is LayerType.Cut or LayerType.Display)
edges.Add((from, line.EndPoint));
position = code switch
{
Motion motion => motion.EndPoint,
SubProgramCall => null,
_ => position,
};
}
var entries = new List<(Vector Entry, Vector Pierce, Vector Next)>();
foreach (var path in paths)
{
var points = path.Points.Select(point => point - location).ToArray();
// Each end of the path, with the direction into the path from there.
foreach (var (end, inward) in new[]
{
(points[0], (points[1] - points[0]).Normalize()),
(points[^1], (points[^2] - points[^1]).Normalize()),
})
foreach (var (start, stop) in edges)
{
var length = start.DistanceTo(stop);
if (length <= Math.Tolerance.Epsilon)
continue;
var along = new Vector((stop.X - start.X) / length, (stop.Y - start.Y) / length);
if (System.Math.Abs(Cross(along, inward)) > 1e-6 || Dot(along, inward) <= 0)
continue;
if (System.Math.Abs(System.Math.Abs(Cross(along, end - start)) - kerf / 2) > CommonLineGeometry.GapTolerance)
continue;
var t = Dot(end - start, along);
if (t < -CommonLineGeometry.GapTolerance || t > length + CommonLineGeometry.GapTolerance)
continue;
var entry = (start, start - along * reach, stop);
if (!entries.Contains(entry))
entries.Add(entry);
}
}
return entries;
}
// One straight leg of a shared path in part coordinates. An end at a corner snaps to the
// edge's own vertex within kerf/2 · tan(turn/2) of the centerline corner's projection.
private readonly record struct Leg(Vector Start, Vector End, double StartSnap, double EndSnap);
private static IEnumerable<Leg> Legs(CommonLinePath path, Vector location, double kerf)
{
var points = path.Points.Select(point => point - location).ToArray();
var snaps = new double[points.Length];
for (var k = 1; k < points.Length - 1; k++)
{
var din = (points[k] - points[k - 1]).Normalize();
var dout = (points[k + 1] - points[k]).Normalize();
var turn = PortableMath.Atan2(System.Math.Abs(Cross(din, dout)), Dot(din, dout));
snaps[k] = kerf / 2 * PortableMath.Tan(turn / 2) + 2 * CommonLineGeometry.GapTolerance;
}
for (var k = 0; k + 1 < points.Length; k++)
yield return new Leg(points[k], points[k + 1], snaps[k], snaps[k + 1]);
}
// The line split where shared legs one half kerf off it begin and end; shared pieces on Tab.
private static IEnumerable<ICode> Split(LinearMove line, Vector start, Leg[] legs, double[] covered,
double kerf)
{
var end = line.EndPoint;
var length = start.DistanceTo(end);
if (length <= Math.Tolerance.Epsilon)
return [line];
var direction = new Vector((end.X - start.X) / length, (end.Y - start.Y) / length);
var intervals = new List<(double From, double To)>();
for (var i = 0; i < legs.Length; i++)
{
var (a, b, snapA, snapB) = legs[i];
var legLength = a.DistanceTo(b);
if (legLength <= Math.Tolerance.Epsilon)
continue;
var along = new Vector((b.X - a.X) / legLength, (b.Y - a.Y) / legLength);
if (System.Math.Abs(Cross(direction, along)) > 1e-6)
continue;
var offset = System.Math.Abs(Cross(direction, a - start));
if (System.Math.Abs(offset - kerf / 2) > CommonLineGeometry.GapTolerance)
continue;
var ta = Snap(Dot(a - start, direction), snapA, length);
var tb = Snap(Dot(b - start, direction), snapB, length);
var from = System.Math.Max(0, System.Math.Min(ta, tb));
var to = System.Math.Min(length, System.Math.Max(ta, tb));
if (to - from <= Math.Tolerance.Epsilon)
continue;
// An end within the snap tolerance of a vertex is that vertex: no sliver is left uncut.
if (from <= Math.Tolerance.Epsilon)
from = 0;
if (length - to <= Math.Tolerance.Epsilon)
to = length;
covered[i] += to - from;
intervals.Add((from, to));
}
if (intervals.Count == 0)
return [line];
var pieces = new List<ICode>();
var at = 0.0;
foreach (var (from, to) in intervals.OrderBy(interval => interval.From))
{
if (from < at - Math.Tolerance.Epsilon)
throw new ArgumentException("Shared spans overlap along one edge.");
if (from - at > Math.Tolerance.Epsilon)
pieces.Add(new LinearMove(Point(from)) { Layer = line.Layer });
pieces.Add(new LinearMove(to >= length ? end : Point(to)) { Layer = LayerType.Tab });
at = to;
}
if (length - at > Math.Tolerance.Epsilon)
pieces.Add(new LinearMove(end) { Layer = line.Layer });
else if (pieces[^1] is LinearMove last)
last.EndPoint = end;
return pieces;
Vector Point(double t) => new(start.X + direction.X * t, start.Y + direction.Y * t);
}
// A corner end within snap of either end of the edge is that end.
private static double Snap(double t, double snap, double length)
{
if (snap <= 0)
return t;
if (System.Math.Abs(t) <= snap)
return 0;
if (System.Math.Abs(t - length) <= snap)
return length;
return t;
}
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
}
}
@@ -0,0 +1,397 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CommonLines
{
/// <summary>One shared cut: the centerline between two parts' facing edges, one kerf apart.</summary>
public readonly record struct CommonLineSpan(Part First, Part Second, Vector Start, Vector End)
{
public double Length => Start.DistanceTo(End);
}
/// <summary>
/// One shared cut's path: the spans of one pair chained end to end at corners (a Z or L shared
/// line), or a single straight span. <see cref="Points"/> runs from the first span's free end
/// through each corner, where the spans' centerlines meet, to the last span's free end.
/// </summary>
public sealed record CommonLinePath(Part First, Part Second, IReadOnlyList<CommonLineSpan> Spans,
IReadOnlyList<Vector> Points)
{
public Vector Start => Points[0];
public Vector End => Points[^1];
/// <summary>The kerf-centerline length of the whole path, corners included.</summary>
public double Length
{
get
{
var length = 0.0;
for (var i = 1; i < Points.Count; i++)
length += Points[i - 1].DistanceTo(Points[i]);
return length;
}
}
}
/// <summary>A member of a group that no longer shares a span, or could not be read.</summary>
public readonly record struct CommonLineProblem(Part Part, string Message);
/// <summary>Spans a group's members share now, and what breaks the group.</summary>
public sealed record CommonLineCheck(IReadOnlyList<CommonLineSpan> Spans, IReadOnlyList<CommonLineProblem> Problems)
{
public bool IsIntact => Problems.Count == 0;
}
/// <summary>
/// Derives common-line spans from placed parts' clean perimeters (leads excluded, holes never
/// share). Two straight perimeter edges share a span when they face each other (antiparallel,
/// outward normals opposed), lie one kerf apart within <see cref="GapTolerance"/> and overlap
/// along the line by at least <see cref="MinSharedLength"/>. Arcs never share.
/// </summary>
public static class CommonLineGeometry
{
/// <summary>How far a shared edge pair's gap may differ from the group's kerf (inches).</summary>
public const double GapTolerance = 0.0005;
/// <summary>The shortest overlap that counts as a shared span (inches).</summary>
public const double MinSharedLength = 0.25;
private const double ParallelTolerance = 1e-6;
/// <summary>A placed perimeter edge with its outward unit normal.</summary>
internal readonly record struct Edge(Vector Start, Vector End, Vector Direction, Vector Outward, double Length);
/// <summary>A facing edge pair: <see cref="Gap"/> along the first edge's outward normal,
/// the overlap as distances along the first edge from its start.</summary>
internal readonly record struct Facing(Edge First, Edge Second, double Gap, double From, double To)
{
public double Overlap => To - From;
}
/// <summary>The spans <paramref name="first"/> and <paramref name="second"/> share at <paramref name="kerf"/>.</summary>
public static IReadOnlyList<CommonLineSpan> Spans(Part first, Part second, double kerf)
{
ArgumentNullException.ThrowIfNull(first);
ArgumentNullException.ThrowIfNull(second);
var cache = new ProfileCache();
return Spans(first, Edges(first, cache), second, Edges(second, cache), kerf);
}
/// <summary>
/// Checks that every member of <paramref name="group"/> shares at least one span with another
/// member at the group's kerf, and that the members form one connected group.
/// </summary>
public static CommonLineCheck Check(CommonLineGroup group)
{
ArgumentNullException.ThrowIfNull(group);
var members = group.Members;
var cache = new ProfileCache();
var edges = new IReadOnlyList<Edge>[members.Count];
var problems = new List<CommonLineProblem>();
for (var i = 0; i < members.Count; i++)
{
try
{
edges[i] = Edges(members[i], cache);
}
catch (Exception exception) when (IsInvalid(exception))
{
problems.Add(new(members[i], $"could not be read ({exception.Message})."));
}
}
var spans = new List<CommonLineSpan>();
var links = members.Select(_ => new List<int>()).ToArray();
for (var i = 0; i < members.Count; i++)
for (var j = i + 1; j < members.Count; j++)
{
if (edges[i] == null || edges[j] == null)
continue;
var shared = Spans(members[i], edges[i], members[j], edges[j], group.Kerf);
if (shared.Count == 0)
continue;
spans.AddRange(shared);
links[i].Add(j);
links[j].Add(i);
}
for (var i = 0; i < members.Count; i++)
if (edges[i] != null && links[i].Count == 0)
problems.Add(new(members[i],
$"no longer shares a straight edge one kerf ({group.Kerf:0.####}) apart with another part in its common-line group."));
if (problems.Count == 0 && !Connected(links))
problems.Add(new(members[0], "its common-line group has split into separate pieces."));
return new CommonLineCheck(spans, problems);
}
/// <summary>
/// The move that puts <paramref name="moving"/> one kerf from <paramref name="anchor"/> along
/// their best facing edge pair: the longest overlap, then the smallest gap. Only pairs closer
/// than <paramref name="maxGap"/> (or already overlapping by up to that much) are considered;
/// null when there are none.
/// </summary>
public static Vector? JoinOffset(Part anchor, Part moving, double kerf, double maxGap)
{
ArgumentNullException.ThrowIfNull(anchor);
ArgumentNullException.ThrowIfNull(moving);
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf));
var cache = new ProfileCache();
var anchorEdges = Edges(anchor, cache);
var movingEdges = Edges(moving, cache);
var best = FacingEdges(anchorEdges, movingEdges, -maxGap, maxGap)
.OrderByDescending(facing => System.Math.Round(facing.Overlap, 6))
.ThenBy(facing => System.Math.Abs(facing.Gap - kerf))
.Cast<Facing?>()
.FirstOrDefault();
if (best is not { } chosen)
return null;
var shift = kerf - chosen.Gap;
var offset = new Vector(chosen.First.Outward.X * shift, chosen.First.Outward.Y * shift);
return Slide(anchorEdges, movingEdges, chosen, offset, kerf, maxGap) ?? offset;
}
// The offset slid along the chosen edge pair so that a facing pair across it (the step of a
// Z or L shared line) lands one kerf apart too, keeping the chosen pair's gap and span; null
// when no such pair is within reach.
private static Vector? Slide(IReadOnlyList<Edge> anchor, IReadOnlyList<Edge> moving, Facing chosen,
Vector offset, double kerf, double maxGap)
{
var along = chosen.First.Direction;
var minimum = PortableMath.Sin(MinCornerTurn);
var shifted = Shift(moving, offset);
var slides = FacingEdges(anchor, shifted, -maxGap, maxGap)
.Select(facing => (Facing: facing, Across: Dot(along, facing.First.Outward)))
.Where(choice => System.Math.Abs(choice.Across) >= minimum)
.Select(choice => (choice.Facing, Slide: (kerf - choice.Facing.Gap) / choice.Across))
.Where(choice => System.Math.Abs(choice.Slide) <= maxGap)
.OrderByDescending(choice => System.Math.Round(choice.Facing.Overlap, 6))
.ThenBy(choice => System.Math.Abs(choice.Slide));
foreach (var (_, slide) in slides)
{
var candidate = new Vector(offset.X + along.X * slide, offset.Y + along.Y * slide);
var placed = Shift(moving, candidate);
var spans = FacingEdges(anchor, placed, kerf - GapTolerance, kerf + GapTolerance).ToList();
// The chosen pair must still share, and the slid pair must now share across it.
if (spans.Any(facing => System.Math.Abs(Cross(facing.First.Direction, along)) <= ParallelTolerance)
&& spans.Any(facing => System.Math.Abs(Dot(along, facing.First.Outward)) >= minimum))
return candidate;
}
return null;
}
private static IReadOnlyList<Edge> Shift(IReadOnlyList<Edge> edges, Vector offset) =>
edges.Select(edge => edge with { Start = edge.Start + offset, End = edge.End + offset }).ToArray();
/// <summary>The sharpest corner a shared path may turn (radians): 135°.</summary>
public const double MaxCornerTurn = 3 * System.Math.PI / 4;
/// <summary>The gentlest corner a shared path may turn (radians): 10°. Gentler meets are not corners.</summary>
public const double MinCornerTurn = System.Math.PI / 18;
/// <summary>
/// The shared cuts of <paramref name="spans"/> at <paramref name="kerf"/>: spans of one pair
/// whose ends meet at a corner (a Z or L shared line) chain into one path; every other span
/// is a path of its own. Two spans meet when their centerlines cross beyond both ends, each
/// end short of the crossing by what the kerf leaves there, <c>kerf/2 · tan(turn/2)</c>, and
/// the path turns between <see cref="MinCornerTurn"/> and <see cref="MaxCornerTurn"/>. Paths
/// keep the spans' order of first appearance.
/// </summary>
public static IReadOnlyList<CommonLinePath> Paths(IReadOnlyList<CommonLineSpan> spans, double kerf)
{
ArgumentNullException.ThrowIfNull(spans);
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf));
// links[i, end]: the span and end met at span i's end (0 = Start, 1 = End), and the corner.
var links = new (int Span, int End, Vector Corner)?[spans.Count, 2];
for (var i = 0; i < spans.Count; i++)
for (var j = i + 1; j < spans.Count; j++)
{
if (!SamePair(spans[i], spans[j]))
continue;
for (var ei = 0; ei < 2; ei++)
for (var ej = 0; ej < 2; ej++)
if (links[i, ei] == null && links[j, ej] == null
&& Corner(spans[i], ei, spans[j], ej, kerf) is { } corner)
{
links[i, ei] = (j, ej, corner);
links[j, ej] = (i, ei, corner);
}
}
var used = new bool[spans.Count];
var paths = new List<CommonLinePath>();
for (var i = 0; i < spans.Count; i++)
{
if (used[i])
continue;
// Walk back to a free end (or round a loop back to this span), then trace forward.
var (span, entry) = (i, links[i, 0] == null ? 0 : links[i, 1] == null ? 1 : 0);
for (var guard = 0; guard < spans.Count && links[span, entry] is { } back && back.Span != i; guard++)
(span, entry) = (back.Span, 1 - back.End);
var chain = new List<CommonLineSpan>();
var points = new List<Vector> { EndOf(spans[span], entry) };
while (true)
{
used[span] = true;
chain.Add(spans[span]);
var exit = 1 - entry;
if (links[span, exit] is not { } next || used[next.Span])
{
points.Add(EndOf(spans[span], exit));
break;
}
points.Add(next.Corner);
(span, entry) = (next.Span, next.End);
}
paths.Add(new CommonLinePath(spans[i].First, spans[i].Second, chain, points));
}
return paths;
}
// Where span a's end ea meets span b's end eb at a corner, or null.
private static Vector? Corner(CommonLineSpan a, int ea, CommonLineSpan b, int eb, double kerf)
{
var aEnd = EndOf(a, ea);
var aOther = EndOf(a, 1 - ea);
var bEnd = EndOf(b, eb);
var bOther = EndOf(b, 1 - eb);
var aLength = aEnd.DistanceTo(aOther);
var bLength = bEnd.DistanceTo(bOther);
if (aLength <= Math.Tolerance.Epsilon || bLength <= Math.Tolerance.Epsilon)
return null;
// Travel along a into its end, then out along b from its end.
var din = new Vector((aEnd.X - aOther.X) / aLength, (aEnd.Y - aOther.Y) / aLength);
var dout = new Vector((bOther.X - bEnd.X) / bLength, (bOther.Y - bEnd.Y) / bLength);
var cross = Cross(din, dout);
var turn = PortableMath.Atan2(System.Math.Abs(cross), Dot(din, dout));
if (turn < MinCornerTurn || turn > MaxCornerTurn)
return null;
// aEnd + din·s = bEnd − dout·t: the corner lies s past a's end and t before b's end.
var gap = bEnd - aEnd;
var s = Cross(gap, dout) / cross;
var t = Cross(din, gap) / cross;
var reach = kerf / 2 * PortableMath.Tan(turn / 2) + 2 * GapTolerance;
if (s < -GapTolerance || t < -GapTolerance || s > reach || t > reach)
return null;
return new Vector(aEnd.X + din.X * s, aEnd.Y + din.Y * s);
}
private static Vector EndOf(CommonLineSpan span, int end) => end == 0 ? span.Start : span.End;
private static bool SamePair(CommonLineSpan a, CommonLineSpan b) =>
(ReferenceEquals(a.First, b.First) && ReferenceEquals(a.Second, b.Second))
|| (ReferenceEquals(a.First, b.Second) && ReferenceEquals(a.Second, b.First));
private static IReadOnlyList<CommonLineSpan> Spans(Part first, IReadOnlyList<Edge> a, Part second,
IReadOnlyList<Edge> b, double kerf)
{
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf));
var spans = new List<CommonLineSpan>();
foreach (var facing in FacingEdges(a, b, kerf - GapTolerance, kerf + GapTolerance))
{
var edge = facing.First;
var half = facing.Gap / 2;
Vector At(double t) => new(edge.Start.X + edge.Direction.X * t + edge.Outward.X * half,
edge.Start.Y + edge.Direction.Y * t + edge.Outward.Y * half);
spans.Add(new CommonLineSpan(first, second, At(facing.From), At(facing.To)));
}
return spans;
}
internal static IEnumerable<Facing> FacingEdges(IReadOnlyList<Edge> a, IReadOnlyList<Edge> b,
double minGap, double maxGap)
{
foreach (var first in a)
foreach (var second in b)
{
// Antiparallel edges with opposed outward normals face each other.
if (System.Math.Abs(Cross(first.Direction, second.Direction)) > ParallelTolerance
|| Dot(first.Outward, second.Outward) > -1 + ParallelTolerance)
continue;
var gap = Dot(second.Start - first.Start, first.Outward);
if (gap < minGap || gap > maxGap)
continue;
var t0 = Dot(second.Start - first.Start, first.Direction);
var t1 = Dot(second.End - first.Start, first.Direction);
var from = System.Math.Max(0, System.Math.Min(t0, t1));
var to = System.Math.Min(first.Length, System.Math.Max(t0, t1));
if (to - from >= MinSharedLength)
yield return new Facing(first, second, gap, from, to);
}
}
/// <summary>The straight edges of a part's placed clean perimeter, with outward normals.</summary>
internal static IReadOnlyList<Edge> Edges(Part part, ProfileCache cache)
{
var profile = PartSpacingReview.Profile(part, cache.Profiles)
?? throw new ArgumentException("Part has no material.");
var location = part.Location;
var ring = profile.Perimeter.ToPolygon();
var edges = new List<Edge>();
foreach (var line in profile.Perimeter.Entities.OfType<Line>())
{
var start = line.StartPoint + location;
var end = line.EndPoint + location;
var length = start.DistanceTo(end);
if (length < MinSharedLength)
continue;
var direction = new Vector((end.X - start.X) / length, (end.Y - start.Y) / length);
var outward = new Vector(direction.Y, -direction.X);
// The probe is in part-local coordinates: the ring is not moved to the location.
var mid = new Vector((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2);
var step = System.Math.Min(0.001, length / 100);
if (Inside(ring.Vertices, new Vector(mid.X + outward.X * step, mid.Y + outward.Y * step)))
outward = new Vector(-outward.X, -outward.Y);
edges.Add(new Edge(start, end, direction, outward, length));
}
return edges;
}
internal sealed class ProfileCache
{
public readonly Dictionary<(Program, long), ShapeProfile> Profiles = new();
}
private static bool Connected(IReadOnlyList<List<int>> links)
{
var seen = new bool[links.Count];
var stack = new Stack<int>();
stack.Push(0);
seen[0] = true;
while (stack.Count > 0)
foreach (var next in links[stack.Pop()])
if (!seen[next])
{
seen[next] = true;
stack.Push(next);
}
return seen.All(s => s);
}
private static bool Inside(IReadOnlyList<Vector> ring, Vector point)
{
var inside = false;
for (int i = 0, j = ring.Count - 1; i < ring.Count; j = i++)
{
var a = ring[i];
var b = ring[j];
if ((a.Y > point.Y) != (b.Y > point.Y)
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
inside = !inside;
}
return inside;
}
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
}
}
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.CommonLines
{
/// <summary>
/// Parts placed exactly one kerf apart along collinear straight edges, so one centerline cut
/// makes both edges (see docs/common-line-cutting.md). Membership is by reference; the shared
/// spans are derived from the parts' placed geometry (<see cref="CommonLineGeometry"/>), never
/// stored, so a moved member breaks the group rather than leaving a stale span behind.
/// </summary>
public sealed class CommonLineGroup
{
private readonly List<Part> members;
/// <exception cref="ArgumentOutOfRangeException">The kerf is not finite and positive.</exception>
/// <exception cref="ArgumentException">Fewer than two distinct parts, a null part or a cut-off.</exception>
public CommonLineGroup(IEnumerable<Part> members, double kerf)
{
ArgumentNullException.ThrowIfNull(members);
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf), kerf, "A common line needs a finite, positive kerf.");
var distinct = new List<Part>();
foreach (var part in members)
{
if (part == null)
throw new ArgumentException("A common-line group cannot hold a null part.", nameof(members));
if (part.BaseDrawing?.IsCutOff == true)
throw new ArgumentException("A cut-off cannot share a common line.", nameof(members));
if (!distinct.Any(existing => ReferenceEquals(existing, part)))
distinct.Add(part);
}
if (distinct.Count < 2)
throw new ArgumentException("A common-line group needs at least two parts.", nameof(members));
this.members = distinct;
Kerf = kerf;
}
/// <summary>The parts in the group, in the order they were joined.</summary>
public IReadOnlyList<Part> Members => members;
/// <summary>The gap the members were placed at: the kerf width when the group was made.</summary>
public double Kerf { get; }
public bool Contains(Part part) => members.Any(member => ReferenceEquals(member, part));
/// <summary>Drops a member (a part removed from the plate); true when it was one.</summary>
internal bool Remove(Part part) => members.RemoveAll(member => ReferenceEquals(member, part)) > 0;
}
}
Loaded 100 of 1501 files, more files were not shown because too many files have changed in this diff. Show more