93 Commits
Author SHA1 Message Date
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
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
482 changed files with 16737 additions and 2900 deletions

No files matched your search

+3 -1
View File
@@ -19,9 +19,11 @@ jobs:
matrix:
project:
- OpenNest.Tests
- OpenNest.Desktop.Tests
- OpenNest.Engine.Tests
- OpenNest.IO.Tests
- OpenNest.Server.Tests
- OpenNest.FrontEnd.Tests
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
@@ -70,7 +72,7 @@ jobs:
windows-desktop:
# WinForms tests need a Windows desktop runtime; the Linux job can only
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
runs-on: windows-2022
timeout-minutes: 20
defaults:
+128
View File
@@ -0,0 +1,128 @@
name: Development build
# Every master push whose cross-platform tests pass becomes the rolling
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
# newest release tag and N the commit count. Installed copies take it only
# with Tools > Options > Include development builds.
on:
workflow_run:
workflows: ['Cross-platform tests']
types: [completed]
branches: [master]
permissions:
contents: read
concurrency:
group: dev-build
cancel-in-progress: true
jobs:
package:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: windows-2022
timeout-minutes: 30
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Resolve development version
id: version
run: |
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
$latest = @($releases)[-1]
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
Write-Host "Development version $version (after v$latest)"
"version=$version" >> $env:GITHUB_OUTPUT
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
- name: Upload development build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: OpenNest-dev-build
path: |
artifacts/*.zip
artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error
retention-days: 7
compression-level: 0
publish:
needs: package
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: read
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
VERSION: ${{ needs.package.outputs.version }}
steps:
- name: Skip if master has moved on
id: current
run: |
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
if [ "$head" != "$SOURCE_SHA" ]; then
echo "master is at $head; its own run publishes the newer build."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- name: Download development build
if: steps.current.outputs.skip != 'true'
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
- name: Verify checksums
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
sha256sum -c ./*.zip.sha256
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
- name: Replace the dev-build pre-release
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
if gh release view dev-build >/dev/null 2>&1; then
gh release delete dev-build --cleanup-tag --yes
fi
cat > notes.md <<EOF
Automatic development build of master at $SOURCE_SHA.
It may contain unfinished features. Installed copies receive it only with
**Tools > Options > Include development builds**; full releases are unaffected.
EOF
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
--target "$SOURCE_SHA" --prerelease --latest=false \
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
- name: Read back the pre-release
if: steps.current.outputs.skip != 'true'
run: |
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
grep -qx 'prerelease=true draft=false assets=8' readback.txt
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
# mini dump in TestResults, instead of holding the runner until the
# job timeout.
run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
}
+3
View File
@@ -225,3 +225,6 @@ OpenNest.Tests/test-config.json
# Vendor programming manuals: keep reference copies outside source control.
CINCINNATI LASER PROGRAMMING MANUAL.pdf
TF5200_programming_manual_en.pdf
# Python bytecode
__pycache__/
+13 -5
View File
@@ -22,13 +22,17 @@ dotnet build OpenNest.sln
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
dotnet test OpenNest.Desktop.Tests/OpenNest.Desktop.Tests.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
# Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
```
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
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).
Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); `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.
@@ -38,8 +42,9 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder; `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 the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
- `OpenNest.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
## Geometry and ownership safeguards
@@ -48,17 +53,20 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
- `Plate.Outline`/`NestPlateStock.Outline` (`SheetOutline`) make a sheet irregular; `Size` stays its bounding box. Only Irregular fills it (`PackBoundary` eroded by the largest edge spacing); every other engine and fill strategy must place nothing on it, validation must reject parts outside it or over its obstacles, and automatic cut-offs refuse it. See [irregular stock](docs/irregular-stock.md).
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [Irregular stock](docs/irregular-stock.md): outlined sheets, what honors the outline and what refuses it
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [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
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
@@ -70,6 +78,6 @@ Read the relevant contract before changing its behavior:
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
+4 -1
View File
@@ -17,7 +17,10 @@ namespace OpenNest.Avalonia
var args = desktop.Args ?? System.Array.Empty<string>();
if (args.Length > 0)
window.OpenNest(args[0]);
// --snapshot <png> [plate]: render the window to an image and exit, for checking the drawing.
// --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],
+6 -2
View File
@@ -8,10 +8,13 @@
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
<MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
<Separator />
<MenuItem Header="E_xit" Click="OnExit" />
</MenuItem>
<MenuItem Header="_Nest">
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
@@ -21,10 +24,11 @@
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="Open" Click="OnOpen" />
<Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
<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>
+119 -3
View File
@@ -1,10 +1,16 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using OpenNest.Desktop;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Forms;
using OpenNest.IO;
namespace OpenNest.Avalonia
@@ -13,12 +19,22 @@ namespace OpenNest.Avalonia
public partial class MainWindow : Window
{
private Nest nest;
private Document document;
private string fileName;
private string notice;
private CancellationTokenSource nesting;
private Task autoNest = Task.CompletedTask;
private Nest nest => document?.Nest;
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;
Canvas.ViewChanged += (_, _) => UpdateStatus();
KeyBindings.Add(new KeyBinding
{
@@ -31,9 +47,11 @@ namespace OpenNest.Avalonia
public void OpenNest(string path)
{
if (nesting != null)
return;
try
{
nest = new NestReader(path).Read();
document = new Document { Nest = new NestReader(path).Read() };
}
catch (Exception ex)
{
@@ -43,30 +61,44 @@ namespace OpenNest.Avalonia
fileName = Path.GetFileName(path);
Title = $"{fileName} - OpenNest (Avalonia prototype)";
notice = null;
Canvas.ClearCache();
RefreshPlateList(0);
}
private void RefreshPlateList(int selectIndex)
{
PlateList.ItemsSource = nest.Plates
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
.ToList();
PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1;
PlateList.SelectedIndex = nest.Plates.Count > 0 ? System.Math.Clamp(selectIndex, 0, nest.Plates.Count - 1) : -1;
}
/// <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)
{
Opened += async (_, _) =>
{
if (nest?.Plates.Count > 0)
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
await autoNest;
await System.Threading.Tasks.Task.Delay(500);
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
bitmap.Render(this);
bitmap.Save(path);
// The status line beside the image, so headless checks can read it.
File.WriteAllText(path + ".txt", Status.Text);
Close();
};
}
private async void OnOpen(object sender, RoutedEventArgs e)
{
if (nesting != null)
return;
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open Nest",
@@ -80,6 +112,88 @@ namespace OpenNest.Avalonia
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 on sheets like the shown plate through the
/// shared pipeline, off the UI thread. Open is refused until it finishes, so the nest
/// and its drawings stay stable from the request to the commit.
/// </summary>
private async Task RunAutoNestAsync()
{
if (nest == null || nesting != null)
return;
var engineName = EngineSelection.EngineName;
var template = Canvas.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();
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);
var kept = AutoNestJob.Keep(nest, result);
if (kept.Nested)
RefreshPlateList(firstNew);
ShowNotice(kept.Nested ? $"{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;
SetNesting(false);
}
}
private void SetNesting(bool running)
{
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
OpenButton.IsEnabled = !running;
OpenMenuItem.IsEnabled = !running;
}
private void ShowNotice(string text)
{
notice = text;
UpdateStatus();
if (Canvas.Plate == null)
Status.Text = text;
}
private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
@@ -120,6 +234,8 @@ namespace OpenNest.Avalonia
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
if (part != null)
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
if (notice != null)
text += $" | {notice}";
Status.Text = text;
}
@@ -11,6 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
+35 -3
View File
@@ -35,7 +35,7 @@ static class NestConsole
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
Console.Error.WriteLine("Nesting canceled; nothing saved.");
return 2;
}
catch (Exception ex)
@@ -132,14 +132,26 @@ static class NestConsole
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
|| options.OutlineFile != null || options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
{
Console.Error.WriteLine(
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size, --outline or --template."
);
return 1;
}
if (options.OutlineFile != null && !options.AutoNest)
{
Console.Error.WriteLine("Error: --outline needs --autonest; single-plate fill strategies fill rectangles only.");
return 1;
}
if (options.OutlineFile != null && !File.Exists(options.OutlineFile))
{
Console.Error.WriteLine($"Error: Outline file not found: {options.OutlineFile}");
return 1;
}
if (options.InputFiles.Count == 0)
{
PrintUsage();
@@ -197,6 +209,12 @@ static class NestConsole
ApplyTemplate(plate, options);
ApplyOverrides(plate, options);
if (!options.AutoNest && PlateFillService.Refusal(plate) is { } refusal)
{
Console.Error.WriteLine($"Error: {refusal} Use --autonest.");
return 2;
}
var drawing = ResolveDrawing(nest, options);
if (drawing == null)
@@ -276,6 +294,9 @@ static class NestConsole
if (Size.TryParse(args[++i], out var sz))
o.PlateSize = sz;
break;
case "--outline" when i + 1 < args.Length:
o.OutlineFile = args[++i];
break;
case "--check-overlaps":
o.CheckOverlaps = true;
break;
@@ -526,6 +547,13 @@ static class NestConsole
if (options.PlateSize.HasValue && !hasDxfOnly)
plate.Size = options.PlateSize.Value;
if (options.OutlineFile != null)
{
// The outline is the sheet: its bounds replace any size given.
plate.Outline = SheetOutline.FromGeometry(CadImporter.Import(options.OutlineFile).Entities);
Console.WriteLine($"Outline: {options.OutlineFile} ({plate.Outline})");
}
}
static Drawing ResolveDrawing(Nest nest, Options options)
@@ -996,6 +1024,9 @@ static class NestConsole
--quantity <n> Max parts to place (default: 0 = unlimited)
--spacing <value> Override part spacing
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--outline <dxf> Make the plate an irregular sheet: the file's largest closed contour is
the sheet, other closed contours are obstacles; sets the size. Only
--autonest with the Irregular (or Default) engine nests on it
--output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
@@ -1037,6 +1068,7 @@ static class NestConsole
public int Quantity;
public double? Spacing;
public Size? PlateSize;
public string OutlineFile;
public bool CheckOverlaps;
public bool NoSave;
public bool KeepParts;
+12 -7
View File
@@ -196,7 +196,7 @@ public static class AutomaticCutOffPlanner
var y = ySign * horizontalDistance.Value;
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
var clamp = clamps.FirstOrDefault(zone =>
y >= zone.Bounds.Bottom - plate.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom);
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
if (limited || clamp != null)
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
@@ -371,7 +371,7 @@ public static class AutomaticCutOffPlanner
// A full-span line, or one with the limits this plan gives its own vertical lines.
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
horizontalY.HasValue &&
horizontalY.HasValue && cut.Gaps.Count == 0 &&
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
@@ -460,7 +460,7 @@ public static class AutomaticCutOffPlanner
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
$"The proposed cut crosses clamp '{blocked.Id}' (including its clearance); it was skipped.",
X: x));
if (isSeparator)
WarnNoSeparator(x);
@@ -473,7 +473,7 @@ public static class AutomaticCutOffPlanner
var candidate = existing == null
? NewVertical(x)
: new CutOff(existing.Position, existing.Axis)
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit, Gaps = existing.Gaps };
candidate.Regenerate(plate, settings, cache);
var program = candidate.Drawing.Program;
var usable = HasUsableSegments(program);
@@ -522,7 +522,7 @@ public static class AutomaticCutOffPlanner
// Share the exact placement-frame predicate with candidate exclusion and final
// certification. Infeasible gaps still use nominal lines through AddCandidate.
bool CrossesClamp(ClampZone zone, double x) =>
x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom;
x >= zone.Bounds.Left - plate.ClampClearance && x <= zone.Bounds.Right + plate.ClampClearance;
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
@@ -544,10 +544,12 @@ public static class AutomaticCutOffPlanner
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
"Sheet length and width must be positive and finite.", nameof(plate));
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
// Strips are planned across a rectangle's known span; an outlined sheet gets manual cut-offs only.
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", nameof(plate));
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
Nonnegative(plate.EdgeSpacing.Bottom)),
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
Nonnegative(plate.ClampClearance)),
"Clamp zones and clamp clearance must be finite and valid.", nameof(plate));
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
@@ -652,7 +654,9 @@ public static class AutomaticCutOffPlanner
}
}
// A gapped line never runs full span, whatever its limits.
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
cut.Gaps.Count == 0 &&
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
@@ -673,6 +677,7 @@ public static class AutomaticCutOffPlanner
}
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
cut.Gaps.Count == 0 &&
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
+1 -1
View File
@@ -8,7 +8,7 @@ namespace OpenNest.Breakoffs
{
/// <summary>
/// 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.
/// </summary>
public static class BreakoffBuilder
+1 -1
View File
@@ -49,7 +49,7 @@ namespace OpenNest.Breakoffs
/// <summary>
/// <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>
public double ClampTabCenter(int index, double center)
{
@@ -53,7 +53,7 @@ internal enum AutomaticEntryKind
/// <summary>
/// Which automatic entries a perimeter tabbed for support offers (see
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue.
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalog.
/// </summary>
internal enum SupportEntries
{
@@ -70,9 +70,11 @@ internal enum SupportEntries
/// <summary>
/// One automatic start candidate: the owned contour choice plus its preference kind and a
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
/// owner; nothing here exposes or mutates the underlying shape.
/// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
/// ranking can count it on both of that corner's sides.
/// </summary>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
{
/// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
@@ -7,7 +7,7 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <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,
/// then a stable geometric key. It adds no
/// 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;
}
// Facing sides from the target relative to the centre, matching the source plan:
// horizontal right when the target is right of centre else left; vertical likewise.
var centreX = minX + (maxX - minX) * 0.5;
var centreY = minY + (maxY - minY) * 0.5;
var facingRight = target != null && target.Value.X > centreX;
var facingTop = target != null && target.Value.Y > centreY;
// Facing sides from the target relative to the center, matching the source plan:
// horizontal right when the target is right of center else left; vertical likewise.
var centerX = minX + (maxX - minX) * 0.5;
var centerY = minY + (maxY - minY) * 0.5;
var facingRight = target != null && target.Value.X > centerX;
var facingTop = target != null && target.Value.Y > centerY;
return candidates
.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)
{
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
// sides within tolerance); count how many of them are facing sides.
// sides within tolerance, a fillet's joint within its reach of the corner it
// rounds); count how many of them are facing sides.
var left = p.X - minX;
var right = maxX - p.X;
var bottom = p.Y - minY;
var top = maxY - p.Y;
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
if (facingRight && right <= near) facing++;
if (!facingRight && left <= near) facing++;
if (facingTop && top <= near) facing++;
if (!facingTop && bottom <= near) facing++;
}
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
@@ -62,7 +62,7 @@ public enum CuttingCommitStatus
Applied,
Stale,
InvalidInput,
Cancelled,
Canceled,
Failed
}
@@ -107,7 +107,7 @@ internal static class CuttingPlanCommit
Action<Plate, Part> beforeInstall)
{
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
var scope = plans?.ToArray();
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
return Invalid(null, "A nonempty set of captured plate plans is required.");
@@ -126,7 +126,7 @@ internal static class CuttingPlanCommit
}
catch (OperationCanceledException)
{
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
}
if (difference != null)
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
@@ -200,7 +200,7 @@ internal static class CuttingPlanCommit
// Last cancellation point. The boundary below has no await, search or geometry work.
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,
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
@@ -41,8 +41,10 @@ public static class ExecutionMotionReader
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
var raised = HeadRaise.RaisedMask(current.Codes);
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
{
var code = current.Codes[codeIndex];
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
@@ -106,7 +108,8 @@ public static class ExecutionMotionReader
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex],
ReferenceEquals(current, program) ? codeIndex : null));
pos = end;
first = false;
}
@@ -79,7 +79,7 @@ internal static class IncrementalClosureDrift
var endPoint = last.EndPoint + correction;
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());
if (chord <= Tolerance.Epsilon)
return false;
@@ -121,11 +121,16 @@ public sealed class LeadMaterialSnapshot
? "A display perimeter must enclose every other contour."
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
// A ring encloses a point only inside the union of its curves' extents (each at least
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
(extent, curve) => extent.Union(curve.Extent))).ToArray();
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
var outer = -1;
for (var i = 0; i < rings.Count; i++)
{
token.ThrowIfCancellationRequested();
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
{
if (outer >= 0)
throw new ArgumentException("Material perimeter is ambiguous.");
@@ -142,10 +147,11 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("A display perimeter must enclose every other contour.");
if (ringDisplay[outer] && rings.Count == 1)
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
var holes = rings.Where((_, i) => i != outer).ToArray();
for (var i = 0; i < holes.Length; i++)
for (var j = i + 1; j < holes.Length; j++)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
var holes = holeRings.Select(i => rings[i]).ToArray();
for (var i = 0; i < holeRings.Length; i++)
for (var j = i + 1; j < holeRings.Length; j++)
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
@@ -294,13 +294,25 @@ public static class LeadPathValidator
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = curve.Contacts(boundary, out var overlap);
if (overlap || contacts.Any(p => permittedJoint is not { } joint
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
}
// With all other boundary contacts excluded, the connected open path
// has constant material membership. Use the native arc midpoint, not
// the chord midpoint or endpoints (which can both lie in scrap).
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
// A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a
// double root at the joint that rounding splits into two contacts a few 1e-8 apart; one
// within JointTolerance of a joint the boundary itself passes through is still the joint.
static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary)
{
var distance = point.DistanceTo(joint);
return distance <= PostVerificationGeometry.Epsilon
|| distance <= JointTolerance && boundary.Contains(joint);
}
}
// As SlideContact's: far below any kerf, far above a tangent root's rounding drift.
private const double JointTolerance = 1e-6;
}
@@ -38,20 +38,31 @@ public sealed class OwnedExecution
public sealed class ExecutionMotion
{
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve)
PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null)
{
Start = start;
End = end;
Rapid = rapid;
Layer = layer;
Curve = curve;
HeadRaised = headRaised;
Code = code;
}
public Vector? Start { get; }
public Vector End { get; }
public bool Rapid { get; }
public LayerType Layer { get; }
/// <summary>A rapid made with the head raised over parts already cut (<see cref="HeadRaise"/>).</summary>
public bool HeadRaised { get; }
/// <summary>
/// The index of the code this motion runs in the read program's own codes; null for a motion
/// inside a sub-program and for the rapid a program that opens with a cut is given.
/// </summary>
public int? Code { get; }
public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code);
}
@@ -82,7 +82,7 @@ public sealed class PlateCuttingState
}
var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit,
: 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);
@@ -157,6 +157,8 @@ public sealed class PlateCuttingState
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
// 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.";
}
@@ -206,5 +208,6 @@ public sealed class PlateCuttingState
List<ContourCutPin> Pins, string PinsFingerprint);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit, double HeadLockOvercut, bool FarEdgeFirst);
double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps, double HeadLockOvercut,
bool FarEdgeFirst);
}
@@ -180,7 +180,7 @@ public sealed class PreparedContours
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
public bool HasCutPerimeter => hasCutPerimeter;
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
/// <summary>Resolved pins this preparation could not honor: a tab on an internal contour.</summary>
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
@@ -465,7 +465,9 @@ public sealed class PreparedContours
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
return Array.Empty<ContourChoice>();
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
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++)
@@ -510,7 +512,7 @@ public sealed class PreparedContours
}
/// <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
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
/// line/line splits, circles and interior points never appear; each geometric point is
@@ -526,12 +528,12 @@ public sealed class PreparedContours
if (pinned[contourOrdinal] is { } pin)
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
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 SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
}
/// <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
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a
/// whole circle, and the exact target-facing closest point toward
@@ -539,7 +541,7 @@ public sealed class PreparedContours
/// 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
/// 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>
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
int contourOrdinal,
@@ -560,7 +562,7 @@ public sealed class PreparedContours
/// <summary>
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
/// its automatic catalogue then keeps to steadying points unless asked for a fallback.
/// its automatic catalog then keeps to steadying points unless asked for a fallback.
/// </summary>
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
@@ -601,8 +603,8 @@ public sealed class PreparedContours
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
=> CataloguePoints(shape, token)
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
=> CatalogPoints(shape, token)
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
.ToList();
/// <summary>
@@ -647,7 +649,7 @@ public sealed class PreparedContours
// endpoint and so never lands on the reflex/cusp vertex at the far end.
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))
{
token.ThrowIfCancellationRequested();
@@ -761,14 +763,16 @@ public sealed class PreparedContours
};
/// <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,
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
/// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
/// nothing; a single whole circle yields no points at all.
/// </summary>
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> 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;
if (count == 1 && shape.Entities[0] is Circle)
{
@@ -783,7 +787,7 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested();
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
// this way; each is classified from the contour's own winding.
var vertex = End(entity);
@@ -792,29 +796,65 @@ public sealed class PreparedContours
switch (corner.Kind)
{
case ContourCuttingStrategy.CornerKind.Convex:
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
break;
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Line && Next(i) is Arc:
// A line leaving into an arc: the tangent joint. The reverse travel
// order classifies the same joint from the arc, matched below.
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
found.Add(Joint(i, vertex, (i + 1) % count));
break;
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
// of a plain straight edge are not preferred automatic starts here.
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Arc && Next(i) is Line:
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
found.Add(Joint(i, vertex, i));
break;
}
}
if (entity is Line line)
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
}
return found;
Entity Next(int index) => shape.Entities[(index + 1) % count];
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
FilletReach(shape, arcOrdinal) is { } reach
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
}
/// <summary>
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
/// than a half circle the convex way, and reaching back along each edge no further than the
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
/// </summary>
private static double? FilletReach(Shape shape, int arcOrdinal)
{
var count = shape.Entities.Count;
if (shape.Entities[arcOrdinal] is not Arc arc
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
return null;
var sweep = arc.SweepAngle();
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
return null;
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
return null;
var winding = ContourCuttingStrategy.DetermineWinding(shape);
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
return null;
var reach = arc.Radius * System.Math.Tan(sweep / 2);
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
bool IsSmooth(Vector vertex, Entity entity) =>
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
@@ -925,20 +965,20 @@ public sealed class PreparedContours
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
/// <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
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
/// </summary>
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default)
internal IReadOnlyList<Vector?> HoleCenters(CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var centres = new Vector?[Count];
var centers = new Vector?[Count];
for (var contour = 0; contour < PerimeterOrdinal; contour++)
{
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>
@@ -960,7 +1000,7 @@ public sealed class PreparedContours
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
/// stored centre sits beside the chord's midpoint) is never cut.
/// stored center sits beside the chord's midpoint) is never cut.
/// </summary>
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
{
@@ -54,9 +54,11 @@ public sealed class ReleasedContourState
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
/// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is
/// returned apart: a fixed emission is accepted as given and only reported. The checker
/// state advances exactly as it would without ownership.
/// state advances exactly as it would without ownership. Crossings made by a rapid with the
/// head raised are returned apart as well, as Raised: they are the raise's reason, not a fault.
/// </summary>
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
IReadOnlyList<PostVerificationFinding> Raised) Check(
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
CancellationToken token)
{
@@ -67,8 +69,10 @@ public sealed class ReleasedContourState
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
var fixedFindings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
var raisedFindings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
raisedFindings: raisedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
}
/// <summary>
@@ -97,7 +101,7 @@ public sealed class ReleasedContourState
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
bool proveOpenCuts = false, int contoursBefore = 0)
bool proveOpenCuts = false, int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
{
var current = new CutGroup();
var unfinished = new List<CutGroup>();
@@ -166,16 +170,22 @@ public sealed class ReleasedContourState
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}.")
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
}, owned);
if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
continue;
var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
+ $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}.")
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
};
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
if (move.HeadRaised)
raisedFindings?.Add(crossing);
else
Add(crossing, owned);
}
}
else if (move.Layer == LayerType.Leadin)
@@ -333,7 +343,7 @@ public sealed class ReleasedContourState
/// 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
/// 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>
private const double JointTolerance = 1e-5;
@@ -160,7 +160,7 @@ internal static class SeamOvercut
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)
{
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
@@ -314,7 +314,8 @@ namespace OpenNest.CNC.CuttingStrategy
var shape = shapes[choice.ContourOrdinal];
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
exactCirclePrograms: true, scrapPierce: choice.Pierce);
exactCirclePrograms: true, scrapPierce: choice.Pierce,
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null);
}
}
finally
@@ -440,7 +441,8 @@ namespace OpenNest.CNC.CuttingStrategy
Entity entity,
ContourType? forceType = null,
bool exactCirclePrograms = false,
Vector? scrapPierce = null
Vector? scrapPierce = null,
bool stopAtCenterLine = false
)
{
var contourType = forceType ?? DetectContourType(shape);
@@ -472,6 +474,8 @@ namespace OpenNest.CNC.CuttingStrategy
leadIn = StraightLeadIn(point, holePierce, out normal);
else
{
if (stopAtCenterLine)
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance());
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
@@ -524,7 +528,11 @@ namespace OpenNest.CNC.CuttingStrategy
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
Parameters.GetEffectivePierceClearance(), out leadInNormal);
if (contourType == ContourType.Internal)
{
if (stopAtCenterLine)
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius(), turn);
}
}
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
@@ -867,10 +875,18 @@ namespace OpenNest.CNC.CuttingStrategy
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner.
/// </summary>
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
/// <summary>
/// The corner a contour of <paramref name="winding"/> would make turning from travel
/// direction <paramref name="tangentIn"/> to <paramref name="tangentOut"/>, classified
/// exactly as a shared vertex is (see <see cref="TryClassifyAutomaticStartCorner"/>).
/// </summary>
internal static CornerKind ClassifyTurn(Vector tangentIn, Vector tangentOut, RotationType winding)
{
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
var dot = tangentIn.DotProduct(tangentOut);
var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
@@ -1050,10 +1066,13 @@ namespace OpenNest.CNC.CuttingStrategy
/// <summary>
/// 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
/// returned as given. Otherwise the pierce moves onto the line from
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
/// round hole), as close to the contour as the zone allows, and the lead becomes a
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
/// returned as given. Otherwise the pierce moves straight in from the wall to the hole's
/// center line, or the lead's own length in when that comes first (see
/// <see cref="StopAtCenterLine"/>). A lead that cannot keep the zone there moves onto the
/// 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
/// 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
@@ -1071,29 +1090,42 @@ namespace OpenNest.CNC.CuttingStrategy
var polygon = circle == null
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
: null;
if (Clear(leadIn.GetPiercePoint(point, normal, turn)))
var configured = leadIn.GetPiercePoint(point, normal, turn);
if (Clear(configured))
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,
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
if (!Inside(pole))
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.
var pierce = pole;
if (Clear(pole))
var pierce = toward;
if (Clear(toward) && (reach < pierceRadius || toward == pole))
{
double low = 0, high = 1;
for (var i = 0; i < 40; i++)
{
var middle = (low + high) / 2;
if (Clear(point + (pole - point) * middle))
if (Clear(point + (toward - point) * middle))
high = middle;
else
low = middle;
}
pierce = point + (pole - point) * high;
pierce = point + (toward - point) * high;
}
var length = pierce.DistanceTo(point);
@@ -1114,6 +1146,97 @@ namespace OpenNest.CNC.CuttingStrategy
&& 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(System.Math.Cos(normal), System.Math.Sin(normal));
/// <summary>
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
/// normal to generate it with.
@@ -1127,6 +1250,11 @@ namespace OpenNest.CNC.CuttingStrategy
// Bounds the pole search on long, thin holes; the result is checked before use.
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)
{
return contourType switch
@@ -1323,7 +1451,7 @@ namespace OpenNest.CNC.CuttingStrategy
}
/// <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
/// whether that is the cut's far end.
/// </summary>
@@ -19,10 +19,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// <summary>
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
/// The erosion goes as deep as the pierce clearance when the cutout has room for it, else
/// almost to its pole of inaccessibility. Each target, an entry the planner ranked, gets the
/// 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 centre line next to the entry, and
/// 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>
@@ -30,7 +30,7 @@ namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
/// not fit: deep enough to stay near the centre line, with room for every place the
/// 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;
@@ -47,10 +47,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// too small to erode is pierced at its pole for every target. Nothing for an open or
/// degenerate contour.
/// </summary>
public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList<Vector> targets, int max)
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(clearance))
|| !double.IsFinite(depth))
return ErodedPierces.None;
// The inscribed radius, from the true wall: the deepest any erosion can go.
@@ -64,10 +64,12 @@ namespace OpenNest.CNC.CuttingStrategy
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.
var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction);
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)
@@ -84,7 +86,7 @@ namespace OpenNest.CNC.CuttingStrategy
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
// Each target gets the deepest level that reaches the wall beside it: a stroke
// narrower than the cutout's widest place is still pierced on its own centre line, near
// 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;
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.CNC
{
/// <summary>
/// A rapid the head must make raised because it passes over parts already cut, which may have
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>. Rapid checks accept
/// a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
/// See docs/cutting-planner.md.
/// </summary>
public static class HeadRaise
{
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
public const string Marker = "HEAD RAISE";
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsMarker(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised. A
/// marker must be followed directly by a rapid; anything else is a malformed program
/// (<see cref="ArgumentException"/>), since a post could raise before the wrong move.
/// </summary>
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
for (var i = 0; i < codes.Count; i++)
{
if (!IsMarker(codes[i]))
continue;
if (i + 1 >= codes.Count || codes[i + 1] is not RapidMove)
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
mask[i + 1] = true;
}
return mask;
}
/// <summary>
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
/// <paramref name="rapids"/>, which must be rapids not marked already.
/// </summary>
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(rapids);
var marked = RaisedMask(program.Codes);
var copy = (Program)program.Clone();
for (var i = copy.Codes.Count - 1; i >= 0; i--)
{
if (!rapids.Contains(i))
continue;
if (copy.Codes[i] is not RapidMove || marked[i])
throw new ArgumentException("Only an unmarked rapid can be marked for a head raise.");
copy.Codes.Insert(i, new Comment(Marker));
}
return copy;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
/// the post's head-raise code, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, string postName)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !IsMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCode)
throw new InvalidOperationException(
$"{name} has rapids that must raise the head over parts already cut, but the {postName} "
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
try
{
RaisedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
+26 -7
View File
@@ -26,14 +26,16 @@ namespace OpenNest
double width,
double depth,
IEnumerable<double> centers,
double removalMinutes = DefaultRemovalMinutes
double removalMinutes = DefaultRemovalMinutes,
double? clearance = null
)
: this(
width,
depth,
(centers ?? throw new ArgumentNullException(nameof(centers)))
.Select(c => new RailClamp(ClampEdge.OriginSide, c)),
removalMinutes
removalMinutes,
clearance
)
{ }
@@ -41,7 +43,8 @@ namespace OpenNest
double width,
double depth,
IEnumerable<RailClamp> clamps,
double removalMinutes = DefaultRemovalMinutes
double removalMinutes = DefaultRemovalMinutes,
double? clearance = null
)
{
ArgumentNullException.ThrowIfNull(clamps);
@@ -60,6 +63,11 @@ namespace OpenNest
nameof(removalMinutes),
"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>();
foreach (var clamp in clamps)
@@ -77,6 +85,7 @@ namespace OpenNest
Width = width;
Depth = depth;
RemovalMinutes = removalMinutes;
Clearance = clearance;
Clamps = sorted.ToArray();
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
}
@@ -93,6 +102,14 @@ namespace OpenNest
/// </summary>
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>
/// 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
@@ -103,9 +120,9 @@ namespace OpenNest
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
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) =>
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>
public static string IdOf(int index) => $"clamp-{index + 1}";
@@ -225,20 +242,22 @@ namespace OpenNest
&& Width.Equals(other.Width)
&& Depth.Equals(other.Depth)
&& RemovalMinutes.Equals(other.RemovalMinutes)
&& Nullable.Equals(Clearance, other.Clearance)
&& Clamps.SequenceEqual(other.Clamps);
public override bool Equals(object obj) => Equals(obj as ClampRail);
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)
hash = HashCode.Combine(hash, clamp);
return hash;
}
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) =>
clamp.Edge == ClampEdge.OriginSide
+131 -13
View File
@@ -11,9 +11,13 @@ namespace OpenNest
Vertical,
}
/// <summary>A stretch of a cut-off's line, along its axis, left uncut.</summary>
public readonly record struct CutOffGap(double Start, double End);
public class CutOff
{
private const double OffsetTolerance = 0.001;
private IReadOnlyList<CutOffGap> gaps = System.Array.Empty<CutOffGap>();
public Vector Position { get; set; }
public CutOffAxis Axis { get; set; }
@@ -21,6 +25,66 @@ namespace OpenNest
public double? EndLimit { get; set; }
public Drawing Drawing { get; private set; }
/// <summary>
/// Stretches between the limits left uncut, so the line splits into pieces as it does
/// around a part; a trim of a middle piece makes one from crossing line to crossing line
/// (<see cref="CutOffTrim"/>). Kept sorted along the axis, with touching gaps merged and
/// empty or nonfinite ones dropped.
/// </summary>
public IReadOnlyList<CutOffGap> Gaps
{
get => gaps;
set => gaps = Normalize(value);
}
/// <summary>
/// The nominal line from <paramref name="start"/> to <paramref name="end"/> with its gaps
/// taken out, in ascending order: the pieces a consumer of the limits treats as cut.
/// </summary>
public IReadOnlyList<(double Start, double End)> Pieces(double start, double end) =>
Pieces(start, end, gaps);
internal static IReadOnlyList<(double Start, double End)> Pieces(double start, double end,
IReadOnlyList<CutOffGap> gaps)
{
var (low, high) = (System.Math.Min(start, end), System.Math.Max(start, end));
var pieces = new List<(double Start, double End)>();
if (gaps.Count == 0)
{
pieces.Add((low, high));
return pieces;
}
var current = low;
foreach (var gap in gaps)
{
if (gap.End <= current || gap.Start >= high)
continue;
if (gap.Start > current)
pieces.Add((current, gap.Start));
current = System.Math.Max(current, gap.End);
}
if (current < high)
pieces.Add((current, high));
return pieces;
}
private static IReadOnlyList<CutOffGap> Normalize(IEnumerable<CutOffGap> value)
{
var sorted = (value ?? Enumerable.Empty<CutOffGap>())
.Where(g => double.IsFinite(g.Start) && double.IsFinite(g.End) && g.End > g.Start)
.OrderBy(g => g.Start)
.ToList();
var merged = new List<CutOffGap>();
foreach (var gap in sorted)
{
if (merged.Count > 0 && gap.Start <= merged[^1].End + Math.Tolerance.Epsilon)
merged[^1] = merged[^1] with { End = System.Math.Max(merged[^1].End, gap.End) };
else
merged.Add(gap);
}
return merged.Count == 0 ? System.Array.Empty<CutOffGap>() : merged.AsReadOnly();
}
/// <summary>
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
@@ -189,7 +253,7 @@ namespace OpenNest
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
/// one continuous straight line on the definition's axis across the whole sheet, so it
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
/// line on the sheet edge, a stale or unrecognized program) does not separate the sheet.
/// </summary>
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
{
@@ -262,7 +326,8 @@ namespace OpenNest
{
var bounds = plate.BoundingBox(includeParts: false);
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel;
var lockedFarEdge = LockedFarEdge(plate);
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
double lineStart,
lineEnd,
@@ -281,6 +346,13 @@ namespace OpenNest
lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
}
// An outlined sheet is cut only where the line crosses material: each crossing is a
// span of its own, started at the material's edge and overtraveled past the far
// one, within the user's limits.
var spans = plate.Outline == null
? new List<(double Start, double End)> { (lineStart, lineEnd) }
: MaterialSpans(plate, cutPosition, lineStart, lineEnd, settings.Overtravel, lockedFarEdge);
var exclusions = new List<(double Start, double End)>();
foreach (var part in plate.Parts)
@@ -313,6 +385,8 @@ namespace OpenNest
);
exclusions.AddRange(partExclusions);
}
foreach (var gap in gaps)
exclusions.Add((gap.Start, gap.End));
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
var merged = new List<(double Start, double End)>();
@@ -325,27 +399,71 @@ namespace OpenNest
}
var segments = new List<(double Start, double End)>();
var current = lineStart;
foreach (var ex in merged)
foreach (var (spanStart, spanEnd) in spans)
{
var clampedStart = System.Math.Max(ex.Start, lineStart);
var clampedEnd = System.Math.Min(ex.End, lineEnd);
var current = spanStart;
if (clampedStart > current)
segments.Add((current, clampedStart));
foreach (var ex in merged)
{
var clampedStart = System.Math.Max(ex.Start, spanStart);
var clampedEnd = System.Math.Min(ex.End, spanEnd);
current = System.Math.Max(current, clampedEnd);
if (clampedStart > current)
segments.Add((current, clampedStart));
current = System.Math.Max(current, clampedEnd);
}
if (current < spanEnd)
segments.Add((current, spanEnd));
}
if (current < lineEnd)
segments.Add((current, lineEnd));
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
return segments;
}
/// <summary>
/// Where the line at <paramref name="cutPosition"/> crosses the sheet's outlined material,
/// in ascending order along the axis and clipped to the line's limits. Each span starts at
/// the material edge and runs <paramref name="overtravel"/> past the far one unless a limit
/// or a head-locked far edge ends it there.
/// </summary>
private List<(double Start, double End)> MaterialSpans(Plate plate, double cutPosition,
double lineStart, double lineEnd, double overtravel, (double Edge, bool High)? lockedFarEdge)
{
var vertical = Axis == CutOffAxis.Vertical;
var crossings = new List<double>();
foreach (var path in plate.Outline.Material(plate.Outline.PlacementOrigin(plate.Quadrant)))
{
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
var (aAcross, aAlong) = vertical ? (a.x, a.y) : (a.y, a.x);
var (bAcross, bAlong) = vertical ? (b.x, b.y) : (b.y, b.x);
// Half-open on the across axis, so a line through a vertex counts one edge.
if (aAcross <= cutPosition == bAcross <= cutPosition)
continue;
crossings.Add(aAlong + (bAlong - aAlong) * (cutPosition - aAcross) / (bAcross - aAcross));
}
}
crossings.Sort();
var spans = new List<(double Start, double End)>();
for (var i = 0; i + 1 < crossings.Count; i += 2)
{
var start = System.Math.Max(crossings[i], lineStart);
var end = crossings[i + 1];
var limited = EndLimit != null && end >= EndLimit.Value
|| lockedFarEdge is var (edge, high) && high && end == edge;
end = System.Math.Min(limited ? end : end + overtravel, lineEnd);
if (end > start)
spans.Add((start, end));
}
return spans;
}
private static readonly List<(double Start, double End)> EmptyExclusions = new();
private List<(double Start, double End)> GetPartExclusions(
+11 -3
View File
@@ -8,9 +8,9 @@ namespace OpenNest
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
/// segment before it (or the line's start) to the start of the one after it (or the line's
/// end). The parts the nominal line passes through lie in the gaps between segments, so each
/// is inside the span of the segment on either side of it; a segment is cut before exactly
/// those parts.
/// end), stopping short of a <see cref="CutOff.Gaps">gap</see> between them. The parts the
/// nominal line passes through lie in the gaps between segments, so each is inside the span
/// of the segment on either side of it; a segment is cut before exactly those parts.
/// </summary>
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
@@ -55,6 +55,14 @@ namespace OpenNest
var (part, start, end) = measured[i];
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
// A gap between two segments belongs to neither: each span stops at its edge.
foreach (var gap in definition.Gaps)
{
if (i > 0 && gap.End <= start + Math.Tolerance.Epsilon && gap.End > spanStart)
spanStart = gap.End;
if (i < measured.Count - 1 && gap.Start >= end - Math.Tolerance.Epsilon && gap.Start < spanEnd)
spanEnd = gap.Start;
}
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
}
result.AddRange(whole);
+412
View File
@@ -0,0 +1,412 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// The limits and gaps one cut-off takes when a <see cref="CutOffEdit"/> is applied; null
/// <paramref name="Gaps"/> keeps the cut-off's gaps.
/// </summary>
public sealed record CutOffLimitChange(CutOff CutOff, double? StartLimit, double? EndLimit,
IReadOnlyList<CutOffGap> Gaps = null)
{
/// <summary>The gaps the cut-off has once the change is applied.</summary>
public IReadOnlyList<CutOffGap> NewGaps => Gaps ?? CutOff.Gaps;
}
/// <summary>
/// A worked-out trim, extend or swap: the new limits and gaps of each cut-off it changes, or
/// why it was refused. Nothing changes until <see cref="Apply"/>; the caller then regenerates
/// the plate's cut-offs and places their segments in the cutting order.
/// </summary>
public sealed class CutOffEdit
{
private CutOffEdit(IReadOnlyList<CutOffLimitChange> changes, string refusal, string notice)
{
Changes = changes;
Refusal = refusal;
Notice = notice;
}
public IReadOnlyList<CutOffLimitChange> Changes { get; }
/// <summary>Why the edit cannot be made; null when it can.</summary>
public string Refusal { get; }
/// <summary>Something the operator should know once it is made, such as a head-locked overcut turning off.</summary>
public string Notice { get; }
public bool Succeeded => Refusal == null;
internal static CutOffEdit Refused(string reason) => new(System.Array.Empty<CutOffLimitChange>(), reason, null);
internal static CutOffEdit Of(IReadOnlyList<CutOffLimitChange> changes, string notice) => new(changes, null, notice);
public void Apply()
{
if (!Succeeded)
throw new System.InvalidOperationException(Refusal);
foreach (var change in Changes)
{
change.CutOff.StartLimit = change.StartLimit;
change.CutOff.EndLimit = change.EndLimit;
if (change.Gaps != null)
change.CutOff.Gaps = change.Gaps;
}
}
}
/// <summary>
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
/// start and end limits and their gaps (see docs/automatic-scrap-cutoffs.md, "Trim and
/// extend"). A trim stops on the crossing line's center, inside its kerf; trimming a piece
/// between two crossing lines leaves a gap from one center to the other. An extend closes the
/// gap at the picked end, or runs the line's end to the next crossing line beyond it, or to
/// the sheet edge, which clears the limit.
/// </summary>
public static class CutOffTrim
{
public const string NotOnPlate = "That cut-off is not on this plate.";
public const string NothingToTrimTo = "No cut-off crosses this line to trim it to; delete it instead.";
public const string InGap = "That stretch of the line is already uncut.";
public const string TooShort = "Trimming here would leave a line shorter than the minimum segment length.";
public const string AtSheetEdge = "This end already runs to the sheet edge.";
public const string NotAJunction = "These cut-offs do not meet with one running through and the other stopping at it.";
public const string GapAtJunction = "A gap in one of these lines meets the junction; extend to close it before swapping.";
public const string OvercutOff = "The head-locked overcut is off while this line stops short of the far edge.";
public const string OvercutOn = "The head-locked overcut is back on at the far edge.";
/// <summary>
/// Removes the piece of <paramref name="target"/> picked at <paramref name="pick"/>, between
/// the crossing cut-offs on either side of it, or a gap or the line's end where there is no
/// crossing on that side. An end piece moves that end's limit; a piece between two crossing
/// lines becomes a gap, merged with any gap it touches.
/// </summary>
public static CutOffEdit Trim(Plate plate, CutOff target, Vector pick, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = System.Math.Clamp(Along(target, pick), start, end);
var pieces = target.Pieces(start, end);
var piece = pieces.Where(p => at >= p.Start - Tolerance.Epsilon && at <= p.End + Tolerance.Epsilon)
.Cast<(double Start, double End)?>().FirstOrDefault();
if (piece is not var (pieceStart, pieceEnd))
return CutOffEdit.Refused(InGap);
var crossings = Crossings(plate, target)
.Where(c => c > pieceStart + Tolerance.Epsilon && c < pieceEnd - Tolerance.Epsilon)
.ToList();
var low = crossings.Where(c => c <= at).DefaultIfEmpty(pieceStart).Max();
var high = crossings.Where(c => c > at).DefaultIfEmpty(pieceEnd).Min();
if (pieces.Count == 1 && low == start && high == end)
return CutOffEdit.Refused(NothingToTrimTo);
var remaining = new List<(double Start, double End)>();
foreach (var (from, to) in pieces)
{
if (from < low)
remaining.Add((from, System.Math.Min(to, low)));
if (to > high)
remaining.Add((System.Math.Max(from, high), to));
}
var minimum = System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon);
if (remaining.Count == 0
|| remaining.Any(p => (p.End == low || p.Start == high) && p.End - p.Start < minimum))
return CutOffEdit.Refused(TooShort);
return Single(plate, Change(target, start, end, remaining));
}
/// <summary>
/// Lengthens <paramref name="target"/> at the end of the piece nearer <paramref name="pick"/>:
/// a gap there closes; the line's own end runs to the next crossing cut-off beyond it, or to
/// the sheet edge when there is none. A cut-off sitting at the current end is skipped, so
/// picking again extends one more step. A pick inside a gap closes that gap.
/// </summary>
public static CutOffEdit Extend(Plate plate, CutOff target, Vector pick)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = Along(target, pick);
var gaps = target.Gaps;
var inside = gaps.Where(g => at > g.Start && at < g.End).ToList();
if (inside.Count == 0)
{
var pieces = target.Pieces(start, end);
var piece = pieces.OrderBy(p => Distance(at, p)).First();
var high = System.Math.Abs(at - piece.End) <= System.Math.Abs(at - piece.Start);
var edge = high ? piece.End : piece.Start;
inside = gaps.Where(g => System.Math.Abs((high ? g.Start : g.End) - edge) <= Tolerance.Epsilon).ToList();
if (inside.Count == 0)
{
return ExtendEnd(plate, target, high, out var limit)
? Single(plate, new CutOffLimitChange(target,
high ? target.StartLimit : limit, high ? limit : target.EndLimit))
: CutOffEdit.Refused(AtSheetEdge);
}
}
return Single(plate, new CutOffLimitChange(target, target.StartLimit, target.EndLimit,
gaps.Except(inside).ToList()));
}
/// <summary>
/// Stops a new <paramref name="target"/> at the first crossing cut-off from its start edge,
/// the origin side unless <paramref name="settings"/> cut toward the origin. It stays full
/// length, with no changes, when nothing crosses it or the line left would be shorter than
/// the minimum segment length. <paramref name="target"/> need not be on the plate yet.
/// </summary>
public static CutOffEdit StopAtFirstCrossing(Plate plate, CutOff target, CutOffSettings settings)
{
var (start, end) = NominalSpan(plate, target);
var crossings = Crossings(plate, target)
.Where(c => c > start + Tolerance.Epsilon && c < end - Tolerance.Epsilon)
.ToList();
if (crossings.Count == 0)
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
var towardOrigin = settings?.CutDirection == CutDirection.TowardOrigin;
var startsHigh = towardOrigin == CutOff.FarEdgeIsHigh(target.Axis, plate.Quadrant);
var stop = startsHigh ? crossings.Max() : crossings.Min();
var remaining = startsHigh ? end - stop : stop - start;
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
return startsHigh
? Single(plate, new CutOffLimitChange(target, stop, target.EndLimit))
: Single(plate, new CutOffLimitChange(target, target.StartLimit, stop));
}
/// <summary>
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
/// extends past it (as <see cref="Extend"/> does), and the one that ran through is trimmed
/// there, losing the side away from the plate's origin and any gaps on it. Only the
/// origin-side sheet edge is certain, so the trimmed line keeps starting there. Refused when
/// a gap in either line meets the junction.
/// </summary>
public static CutOffEdit Swap(Plate plate, CutOff first, CutOff second, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(first) || !plate.CutOffs.Contains(second))
return CutOffEdit.Refused(NotOnPlate);
if (first != second && first.Axis != second.Axis
&& (TouchesGap(first, Line(second)) || TouchesGap(second, Line(first))))
return CutOffEdit.Refused(GapAtJunction);
if (Junction(plate, first, second) is not var (through, stopper, point))
return CutOffEdit.Refused(NotAJunction);
var (start, end) = NominalSpan(plate, through);
var farHigh = CutOff.FarEdgeIsHigh(through.Axis, plate.Quadrant);
var kept = through.Pieces(start, end)
.Select(p => farHigh ? (p.Start, System.Math.Min(p.End, point)) : (System.Math.Max(p.Start, point), p.End))
.Where(p => p.Item2 > p.Item1)
.ToList();
var adjacent = kept.FirstOrDefault(p => (farHigh ? p.Item2 : p.Item1) == point);
if (kept.Count == 0
|| adjacent.Item2 - adjacent.Item1 < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Refused(TooShort);
var throughChange = farHigh
? new CutOffLimitChange(through, through.StartLimit, point, GapsBetween(kept))
: new CutOffLimitChange(through, point, through.EndLimit, GapsBetween(kept));
var (stopStart, stopEnd) = NominalSpan(plate, stopper);
var stopperPoint = Line(through);
var high = System.Math.Abs(stopperPoint - stopEnd) <= System.Math.Abs(stopperPoint - stopStart);
if (!ExtendEnd(plate, stopper, high, out var limit))
return CutOffEdit.Refused(NotAJunction);
var stopperChange = high
? new CutOffLimitChange(stopper, stopper.StartLimit, limit)
: new CutOffLimitChange(stopper, limit, stopper.EndLimit);
var notices = new[] { Notice(plate, throughChange), Notice(plate, stopperChange) }
.Where(n => n != null).Distinct().ToList();
return CutOffEdit.Of(new[] { stopperChange, throughChange },
notices.Count == 0 ? null : string.Join(" ", notices));
}
/// <summary>
/// The two cut-offs meeting within <paramref name="tolerance"/> of <paramref name="point"/>,
/// one running through the junction and the other stopping at it; null when there is none.
/// </summary>
public static (CutOff Through, CutOff Stopper)? JunctionAt(Plate plate, Vector point, double tolerance)
{
if (plate?.CutOffs == null)
return null;
(CutOff, CutOff)? best = null;
var bestDistance = double.MaxValue;
foreach (var vertical in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical))
foreach (var horizontal in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Horizontal))
{
if (Junction(plate, vertical, horizontal) is not var (through, stopper, _))
continue;
var distance = new Vector(vertical.Position.X, horizontal.Position.Y).DistanceTo(point);
if (distance <= tolerance && distance < bestDistance)
{
best = (through, stopper);
bestDistance = distance;
}
}
return best;
}
/// <summary>
/// Where <paramref name="cutOff"/>'s line runs along its axis: its limits, or the sheet
/// edges where it has none. Overtravel is not part of the nominal line, and neither does it
/// take out the gaps (see <see cref="CutOff.Pieces(double, double)"/>).
/// </summary>
public static (double Start, double End) NominalSpan(Plate plate, CutOff cutOff)
{
var sheet = plate.BoundingBox(includeParts: false);
var vertical = cutOff.Axis == CutOffAxis.Vertical;
return (cutOff.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
cutOff.EndLimit ?? (vertical ? sheet.Top : sheet.Right));
}
/// <summary>
/// The stretches of the nominal line a change removes and adds, along the axis, in ascending
/// order: what a preview marks.
/// </summary>
public static (IReadOnlyList<(double Start, double End)> Removed, IReadOnlyList<(double Start, double End)> Added)
Difference(Plate plate, CutOffLimitChange change)
{
var cutOff = change.CutOff;
var sheet = plate.BoundingBox(includeParts: false);
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var before = cutOff.Pieces(NominalSpan(plate, cutOff).Start, NominalSpan(plate, cutOff).End);
var after = CutOff.Pieces(change.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
change.EndLimit ?? (vertical ? sheet.Top : sheet.Right), change.NewGaps);
return (Subtract(before, after), Subtract(after, before));
}
private static List<(double Start, double End)> Subtract(
IReadOnlyList<(double Start, double End)> from, IReadOnlyList<(double Start, double End)> remove)
{
var result = new List<(double Start, double End)>();
foreach (var piece in from)
{
var current = piece.Start;
foreach (var (start, end) in remove.Where(r => r.End > piece.Start && r.Start < piece.End))
{
if (start > current + Tolerance.Epsilon)
result.Add((current, start));
current = System.Math.Max(current, end);
}
if (piece.End > current + Tolerance.Epsilon)
result.Add((current, piece.End));
}
return result;
}
private static (CutOff Through, CutOff Stopper, double Point)? Junction(Plate plate, CutOff first, CutOff second)
{
if (first == second || first.Axis == second.Axis)
return null;
var firstAt = Line(second);
var secondAt = Line(first);
if (TouchesGap(first, firstAt) || TouchesGap(second, secondAt))
return null;
var (firstStart, firstEnd) = NominalSpan(plate, first);
var (secondStart, secondEnd) = NominalSpan(plate, second);
if (!Within(firstAt, firstStart, firstEnd) || !Within(secondAt, secondStart, secondEnd))
return null;
var firstStops = AtEnd(firstAt, firstStart, firstEnd);
var secondStops = AtEnd(secondAt, secondStart, secondEnd);
if (firstStops == secondStops)
return null;
return firstStops ? (second, first, secondAt) : (first, second, firstAt);
}
// Whether one of the cut-off's gaps contains or ends at the value along its axis.
private static bool TouchesGap(CutOff cutOff, double along) =>
cutOff.Gaps.Any(g => Within(along, g.Start, g.End));
// The limit an end takes when extended: the next crossing line beyond it, or null for the
// sheet edge. False when that end already runs to the sheet edge.
private static bool ExtendEnd(Plate plate, CutOff target, bool high, out double? limit)
{
limit = null;
if ((high ? target.EndLimit : target.StartLimit) == null)
return false;
var (start, end) = NominalSpan(plate, target);
var crossings = Crossings(plate, target);
if (high)
{
var next = crossings.Where(c => c > end + Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Min();
limit = double.IsNaN(next) ? null : next;
}
else
{
var next = crossings.Where(c => c < start - Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Max();
limit = double.IsNaN(next) ? null : next;
}
return true;
}
// Where the perpendicular cut-offs whose cut pieces reach this one cross it, along its axis.
private static List<double> Crossings(Plate plate, CutOff target)
{
var line = Line(target);
return plate.CutOffs
.Where(other => other != target && other.Axis != target.Axis)
.Where(other =>
{
var (start, end) = NominalSpan(plate, other);
return other.Pieces(start, end).Any(p => Within(line, p.Start, p.End));
})
.Select(Line)
.ToList();
}
// The change that leaves exactly the given pieces of the nominal line from start to end: a
// limit stays null while its end stays at the sheet edge.
private static CutOffLimitChange Change(CutOff target, double start, double end,
IReadOnlyList<(double Start, double End)> pieces)
{
var first = pieces[0].Start;
var last = pieces[^1].End;
return new CutOffLimitChange(target,
target.StartLimit == null && first == start ? null : first,
target.EndLimit == null && last == end ? null : last,
GapsBetween(pieces));
}
private static List<CutOffGap> GapsBetween(IReadOnlyList<(double Start, double End)> pieces)
{
var gaps = new List<CutOffGap>();
for (var i = 1; i < pieces.Count; i++)
if (pieces[i].Start > pieces[i - 1].End)
gaps.Add(new CutOffGap(pieces[i - 1].End, pieces[i].Start));
return gaps;
}
private static CutOffEdit Single(Plate plate, CutOffLimitChange change) =>
CutOffEdit.Of(new[] { change }, Notice(plate, change));
private static string Notice(Plate plate, CutOffLimitChange change)
{
var cutOff = change.CutOff;
if (!(cutOff.HeadLockOvercut > 0))
return null;
var high = CutOff.FarEdgeIsHigh(cutOff.Axis, plate.Quadrant);
var before = (high ? cutOff.EndLimit : cutOff.StartLimit) == null;
var after = (high ? change.EndLimit : change.StartLimit) == null;
return before == after ? null : after ? OvercutOn : OvercutOff;
}
// The cut-off's own coordinate across its axis: X for a vertical line, Y for a horizontal one.
private static double Line(CutOff cutOff) =>
cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y;
private static double Along(CutOff cutOff, Vector point) =>
cutOff.Axis == CutOffAxis.Vertical ? point.Y : point.X;
private static double Distance(double value, (double Start, double End) piece) =>
value < piece.Start ? piece.Start - value : value > piece.End ? value - piece.End : 0;
private static bool Within(double value, double start, double end) =>
value >= start - Tolerance.Epsilon && value <= end + Tolerance.Epsilon;
private static bool AtEnd(double value, double start, double end) =>
System.Math.Abs(value - start) <= Tolerance.Epsilon || System.Math.Abs(value - end) <= Tolerance.Epsilon;
}
}
@@ -33,15 +33,21 @@ public static class ClampCutDiagnostics
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
var low = farHigh ? 0 : overcut;
var high = farHigh ? overcut : 0;
var start = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low)
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y);
var end = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
var vertical = cut.Axis == CutOffAxis.Vertical;
var from = cut.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left) - low;
var to = cut.EndLimit ?? (vertical ? sheet.Top : sheet.Right) + high;
// A clamp wholly inside a gap meets no cut.
foreach (var zone in zones)
if (Intersects(start, end, zone.Bounds, out var location))
findings.Add(new CutOffClampFinding(index, zone.Id, location));
foreach (var (pieceStart, pieceEnd) in cut.Pieces(from, to))
{
var start = vertical ? new Vector(cut.Position.X, pieceStart) : new Vector(pieceStart, cut.Position.Y);
var end = vertical ? new Vector(cut.Position.X, pieceEnd) : new Vector(pieceEnd, cut.Position.Y);
if (Intersects(start, end, zone.Bounds, out var location))
{
findings.Add(new CutOffClampFinding(index, zone.Id, location));
break;
}
}
}
return findings.AsReadOnly();
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
namespace OpenNest.Diagnostics;
@@ -12,7 +13,9 @@ public sealed class OverlapGeometryStamp
private readonly Plate plate;
private readonly Entry[] entries;
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End, long Overcut)[] cutoffs;
// Gaps are replaced, never edited in place, so their reference stands for their content.
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End,
IReadOnlyList<CutOffGap> Gaps, long Overcut)[] cutoffs;
private readonly (long Width, long Length, int Quadrant) sheet;
private OverlapGeometryStamp(Plate plate)
@@ -25,7 +28,7 @@ public sealed class OverlapGeometryStamp
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
for (var i = 0; i < zones.Length; i++)
zones[i] = ZoneState(plate.ClampZones[i]);
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, long)[plate.CutOffs.Count];
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long)[plate.CutOffs.Count];
for (var i = 0; i < cutoffs.Length; i++)
cutoffs[i] = CutOffState(plate.CutOffs[i]);
}
@@ -36,12 +39,12 @@ public sealed class OverlapGeometryStamp
zone?.Bounds is not { } box ? default
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
private static (CutOff, CutOffAxis, long, long, long?, long?, long) CutOffState(CutOff cut) =>
private static (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long) CutOffState(CutOff cut) =>
cut == null ? default
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
Bits(cut.HeadLockOvercut));
cut.Gaps, Bits(cut.HeadLockOvercut));
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
+3 -3
View File
@@ -48,13 +48,13 @@ internal static class OverlapMarker
point = default;
var minY = outline.Min(vertex => vertex.Y);
var maxY = outline.Max(vertex => vertex.Y);
var centre = (minY + maxY) / 2;
var center = (minY + maxY) / 2;
double below = minY, above = maxY;
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
{
if (vertex.Y <= centre && vertex.Y > below)
if (vertex.Y <= center && vertex.Y > below)
below = vertex.Y;
else if (vertex.Y > centre && vertex.Y < above)
else if (vertex.Y > center && vertex.Y < above)
above = vertex.Y;
}
var y = (below + above) / 2;
+74 -7
View File
@@ -31,11 +31,17 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
}
// Sampling can hide a crossing or tangency between curves. Reject native
// contour contact before asking the polygon approximation about containment.
// A contact lies within both entities' bounds, so shapes whose bounds are clear by more
// than any native query's allowance cannot touch: a part with thousands of holes does
// not query every pair.
var boxes = shapes.Select(shape => shape.BoundingBox).ToList();
for (var i = 0; i < shapes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
for (var j = 0; j < i; j++)
{
if (ClearOfNativeQueries(boxes[i], boxes[j]))
continue;
shapes[i].Intersects(shapes[j], out var intersections);
if (intersections.Count > 0)
throw new ArgumentException("Native material contours cross or touch.");
@@ -44,23 +50,70 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
var outer = ordered[0];
var holes = ordered.Skip(1).ToList();
var outerBounds = BoundsOf(outer);
var holeBounds = holes.Select(BoundsOf).ToList();
for (var i = 0; i < holes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
if (BoundariesTouch(outer, holes[i], cancellationToken)
|| !Inside(outer, holes[i].Vertices[0]))
if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken)
|| !Inside(outer, outerBounds, holes[i].Vertices[0]))
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
for (var j = 0; j < i; j++)
{
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|| Inside(holes[i], holes[j].Vertices[0])
|| Inside(holes[j], holes[i].Vertices[0]))
// Exact polygon tests: holes whose vertex bounds are apart can neither touch
// nor contain each other's vertices.
if (!Overlap(holeBounds[i], holeBounds[j]))
continue;
if (BoundariesTouch(holes[i], holeBounds[i], holes[j], holeBounds[j], cancellationToken)
|| Inside(holes[i], holeBounds[i], holes[j].Vertices[0])
|| Inside(holes[j], holeBounds[j], holes[i].Vertices[0]))
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
}
}
return new OverlapMaterial(outer, holes);
}
/// <summary>
/// The gap beyond which no native intersection query counts two entities as touching: ten
/// times the 0.00001 bounding-box allowance of native intersections, as the planner's
/// <c>PostVerificationGeometry.Extent.ClearMargin</c>.
/// </summary>
private const double NativeClearMargin = 1e-3;
// True only when both boxes are finite and apart by more than the native allowance on some axis.
private static bool ClearOfNativeQueries(Box a, Box b) =>
IsFinite(a) && IsFinite(b)
&& (a.Right + NativeClearMargin < b.Left || b.Right + NativeClearMargin < a.Left
|| a.Top + NativeClearMargin < b.Bottom || b.Top + NativeClearMargin < a.Bottom);
private static bool IsFinite(Box box) => double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top);
/// <summary>Exact vertex bounds of a sampled polygon, for skipping segment and winding tests.</summary>
private readonly record struct Bounds(double MinX, double MinY, double MaxX, double MaxY)
{
internal bool Contains(Vector point) =>
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
}
private static Bounds BoundsOf(Polygon polygon)
{
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
foreach (var vertex in polygon.Vertices)
{
minX = System.Math.Min(minX, vertex.X);
minY = System.Math.Min(minY, vertex.Y);
maxX = System.Math.Max(maxX, vertex.X);
maxY = System.Math.Max(maxY, vertex.Y);
}
return new(minX, minY, maxX, maxY);
}
// Closed-interval overlap: two segments that touch share a point inside both bounds.
private static bool Overlap(Bounds a, Bounds b) =>
a.MaxX >= b.MinX && b.MaxX >= a.MinX && a.MaxY >= b.MinY && b.MaxY >= a.MinY;
internal OverlapMaterial Transform(double rotation, Vector offset) =>
new(TransformPolygon(Outer, rotation, offset),
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
@@ -171,13 +224,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
throw new ArgumentException("Material contour could not be completely triangulated.");
}
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
// Exact: a segment of a whose bounds are clear of b's bounds touches no segment of b.
private static bool BoundariesTouch(Polygon a, Bounds aBounds, Polygon b, Bounds bBounds,
CancellationToken cancellationToken)
{
if (!Overlap(aBounds, bBounds))
return false;
for (var i = 0; i + 1 < a.Vertices.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var start = a.Vertices[i];
var end = a.Vertices[i + 1];
var segment = new Bounds(System.Math.Min(start.X, end.X), System.Math.Min(start.Y, end.Y),
System.Math.Max(start.X, end.X), System.Math.Max(start.Y, end.Y));
if (!Overlap(segment, bBounds))
continue;
for (var j = 0; j + 1 < b.Vertices.Count; j++)
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1]))
return true;
}
return false;
@@ -200,6 +263,10 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
// Boundary contact is rejected before this winding-number test is used for topology.
// A point outside the polygon's vertex bounds has winding zero.
private static bool Inside(Polygon polygon, Bounds bounds, Vector point) =>
bounds.Contains(point) && Inside(polygon, point);
private static bool Inside(Polygon polygon, Vector point)
{
var winding = 0;
@@ -348,10 +348,10 @@ public static class PlateOverlapAnalyzer
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
};
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
var center = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
centre);
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, center,
center);
}
private static Polygon RectanglePolygon(Box box, Vector offset)
@@ -95,7 +95,7 @@ public sealed class PlateOverlapPair
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
/// its conservative whole-clamp region, so its marker is the clamp's centre, within half
/// its conservative whole-clamp region, so its marker is the clamp's center, within half
/// the clamp's size of the material. Returned by value.
/// </summary>
public Vector Marker { get; }
@@ -49,7 +49,7 @@ internal static class PostVerificationGeometry
/// <summary>
/// The relative rounding allowance of native line/circle intersections. Their discriminant
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's center to the line's start), so a
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
/// small circle needs the full check; a short lead beside a nearby part does not.
@@ -68,6 +68,10 @@ internal static class PostVerificationGeometry
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
}
/// <summary>True when <paramref name="point"/> lies within the extent, its edges included.</summary>
internal bool Contains(Vector point) =>
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
@@ -397,9 +401,9 @@ internal static class PostVerificationGeometry
points.Add(point);
// Existing exact line/ray contact semantics guard native queries which
// suppress very short or nearly parallel intersections. Uncertainty refuses.
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other))
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other, points))
throw new NotSupportedException("Native contact query is numerically uncertain.");
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this))
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this, points))
throw new NotSupportedException("Native contact query is numerically uncertain.");
return points;
@@ -415,16 +419,35 @@ internal static class PostVerificationGeometry
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
/// native query missed, because rounding can drop the tangent root of its quadratic.
/// For the same reason a ray hit within <see cref="RaySeamTolerance"/> of a
/// <paramref name="recorded"/> contact is that contact: an arc lead ending tangent on a
/// straight edge, mid-edge, has its end recorded and its double root dropped.
/// </summary>
private static bool ExactLineContact(Curve line, Curve curve)
private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList<Vector> recorded)
{
var direction = (line.End - line.Start) * (1 / line.Length);
var startRecorded = curve.Contains(line.Start);
var endRecorded = curve.Contains(line.End);
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward))
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward));
return (forward > 0 && Unrecorded(line.Start, direction, forward))
|| (backward > 0 && Unrecorded(line.End, direction * -1, backward));
bool Unrecorded(Vector origin, Vector ray, double length)
{
var hits = new List<double>();
curve.ContactAfterStart(origin, ray, length, hits);
foreach (var distance in hits)
{
var hit = origin + ray * distance;
var known = false;
foreach (var point in recorded)
known |= point.DistanceTo(hit) <= RaySeamTolerance;
if (!known)
return true;
}
return false;
}
}
@@ -450,11 +473,11 @@ internal static class PostVerificationGeometry
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
// How far past r^2 a squared distance from the centre still counts as touching the arc.
// How far past r^2 a squared distance from the center still counts as touching the arc.
// For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0).
private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2);
// The largest distance from the centre that ContactAfterStart can count as contact.
// The largest distance from the center that ContactAfterStart can count as contact.
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
+1 -1
View File
@@ -7,7 +7,7 @@ namespace OpenNest.Geometry
/// <summary>
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair
/// checks. It was written as the reference for a GPU kernel; that port is on hold in
/// favour of Clipper2, starting with the material-overlap diagnostic. Engine callers
/// favor of Clipper2, starting with the material-overlap diagnostic. Engine callers
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate
/// pass.
/// <para>
+1 -1
View File
@@ -44,7 +44,7 @@ namespace OpenNest.Geometry
/// <summary>
/// Aligns <paramref name="newProgram"/> onto <paramref name="oldProgram"/>.
/// Both programs are treated as read-only. Throws
/// <see cref="OperationCanceledException"/> only when cancelled before any
/// <see cref="OperationCanceledException"/> only when canceled before any
/// measurable work; work limits reached mid-run are reported as reasons on
/// the result instead.
/// </summary>
+22 -43
View File
@@ -10,14 +10,14 @@ namespace OpenNest.Geometry
public static void Optimize(IList<Arc> arcs) =>
MergePass(
arcs,
(list, item, i) => list.GetCoradialArs(item, i),
(a, b) => a.IsCoradialTo(b),
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined)
);
public static void Optimize(IList<Line> lines) =>
MergePass(
lines,
(list, item, i) => list.GetCollinearLines(item, i),
(a, b) => a.IsCollinearTo(b),
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)
);
@@ -61,7 +61,7 @@ namespace OpenNest.Geometry
private static void MergePass<T>(
IList<T> items,
Func<IList<T>, T, int, List<T>> findCandidates,
Func<T, T, bool> related,
TryJoin<T> tryJoin
)
where T : class
@@ -69,7 +69,7 @@ namespace OpenNest.Geometry
for (var i = 0; i < items.Count; ++i)
{
var item = items[i];
var candidates = findCandidates(items, item, i);
var candidates = FindRelated(items, item, i, related);
var index = 0;
while (index < candidates.Count)
@@ -214,62 +214,41 @@ namespace OpenNest.Geometry
return true;
}
private static List<Line> GetCollinearLines(
this IList<Line> lines,
Line line,
int startIndex
/// <summary>
/// The items from <paramref name="startIndex"/> on, other than <paramref name="item"/>
/// itself, that are <paramref name="related"/> to it.
/// </summary>
private static List<T> FindRelated<T>(
IList<T> items,
T item,
int startIndex,
Func<T, T, bool> related
)
where T : class
{
var collinearLines = new List<Line>();
var matches = new List<T>();
Parallel.For(
startIndex,
lines.Count,
items.Count,
index =>
{
var compareLine = lines[index];
var candidate = items[index];
if (Object.ReferenceEquals(line, compareLine))
if (Object.ReferenceEquals(item, candidate))
return;
if (!line.IsCollinearTo(compareLine))
if (!related(item, candidate))
return;
lock (collinearLines)
lock (matches)
{
collinearLines.Add(compareLine);
matches.Add(candidate);
}
}
);
return collinearLines;
}
private static List<Arc> GetCoradialArs(this IList<Arc> arcs, Arc arc, int startIndex)
{
var coradialArcs = new List<Arc>();
Parallel.For(
startIndex,
arcs.Count,
index =>
{
var compareArc = arcs[index];
if (Object.ReferenceEquals(arc, compareArc))
return;
if (!arc.IsCoradialTo(compareArc))
return;
lock (coradialArcs)
{
coradialArcs.Add(compareArc);
}
}
);
return coradialArcs;
return matches;
}
}
}
+4 -1
View File
@@ -192,7 +192,10 @@ namespace OpenNest.Geometry
double t;
pts = new List<Vector>();
if (det.IsEqualTo(0))
// A small circle's whole discriminant is tiny: tangent also means the line stays at its rim.
var halfChord = det / (4 * a);
var depth = circle.Radius - System.Math.Sqrt(System.Math.Max(circle.Radius * circle.Radius - halfChord, 0));
if (det.IsEqualTo(0) && depth <= Tolerance.Epsilon)
{
t = -b / (2 * a);
var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
+4 -4
View File
@@ -69,7 +69,7 @@ public static class KeepOutOverlap
/// <summary>
/// True when the material (perimeter minus holes) shares positive area with the open
/// rectangle: some boundary edge runs through its interior, or its interior lies wholly
/// inside the material (tested at its centre).
/// inside the material (tested at its center).
/// </summary>
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box)
{
@@ -80,12 +80,12 @@ public static class KeepOutOverlap
if (RingEnters(hole, box))
return true;
var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
if (!Contains(perimeter, centre))
var center = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
if (!Contains(perimeter, center))
return false;
if (holes != null)
foreach (var hole in holes)
if (Contains(hole, centre))
if (Contains(hole, center))
return false;
return true;
}
+3 -3
View File
@@ -19,7 +19,7 @@ public static class MaximalRectangles
/// <remarks>
/// The grid has a line through every vertex coordinate plus <paramref name="divisions"/>
/// evenly spaced lines per axis. A cell is free only when no edge passes through its
/// interior and its centre is inside the region, so results are exact for regions whose
/// interior and its center is inside the region, so results are exact for regions whose
/// edges are all horizontal or vertical. Slanted and curved edges are followed as a
/// staircase: results stay inside, but can fall short of the true maximum by up to about
/// one cell on each side. Rotate the region to search other rectangle angles.
@@ -64,9 +64,9 @@ public static class MaximalRectangles
var crossings = new List<double>();
for (var r = 0; r < rows; r++)
{
// Even-odd scan along the row's centre line. No vertex lies on it, and an edge that
// Even-odd scan along the row's center line. No vertex lies on it, and an edge that
// meets it strictly inside a cell has already marked that cell crossed, so each
// uncrossed cell is on the same side as its centre.
// uncrossed cell is on the same side as its center.
var y = (ys[r] + ys[r + 1]) / 2;
crossings.Clear();
foreach (var path in paths)
+2 -2
View File
@@ -17,7 +17,7 @@ namespace OpenNest.Geometry
/// <summary>
/// Bounded search for callers that cannot afford refinement along a long thin shape:
/// stops after about <paramref name="maxCells"/> distance evaluations and returns the best
/// cell centre so far. When the starting grid would exceed a quarter of the budget it is
/// cell center so far. When the starting grid would exceed a quarter of the budget it is
/// coarsened so the whole shape is sampled first. The result can lie outside a shape
/// thinner than the remaining refinement, so callers must check it.
/// </summary>
@@ -157,7 +157,7 @@ namespace OpenNest.Geometry
halfCell = cell.HalfSize / 2;
// Far from the origin a half-step may no longer move the centre; such a cell
// Far from the origin a half-step may no longer move the center; such a cell
// cannot be split, and its identical children would multiply without end.
if (cell.X - halfCell == cell.X || cell.X + halfCell == cell.X
|| cell.Y - halfCell == cell.Y || cell.Y + halfCell == cell.Y)
+12 -51
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
@@ -750,64 +750,25 @@ namespace OpenNest.Geometry
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
/// making the method independent of the original contour winding direction.
/// </summary>
internal Shape OffsetOutward(double distance)
{
var poly = ToPolygon();
if (
poly == null
|| poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CW
)
return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Shape is CCW — reverse to CW so Left offset goes outward.
var copy = new Shape();
for (var i = Entities.Count - 1; i >= 0; i--)
{
switch (Entities[i])
{
case Line l:
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break;
case Arc a:
copy.Entities.Add(
new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed)
{
Layer = a.Layer,
}
);
break;
case Circle c:
copy.Entities.Add(
new Circle(c.Center, c.Radius)
{
Layer = c.Layer,
Rotation = RotationType.CW,
}
);
break;
}
}
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
}
internal Shape OffsetOutward(double distance) =>
OffsetLeftWithWinding(distance, RotationType.CW);
/// <summary>
/// Offsets the shape inward by the given distance.
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
/// making the method independent of the original contour winding direction.
/// </summary>
internal Shape OffsetInward(double distance)
internal Shape OffsetInward(double distance) =>
OffsetLeftWithWinding(distance, RotationType.CCW);
/// <summary>
/// Offsets Left after normalizing the contour to <paramref name="winding"/>.
/// </summary>
private Shape OffsetLeftWithWinding(double distance, RotationType winding)
{
var poly = ToPolygon();
if (
poly == null
|| poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CCW
)
if (poly == null || poly.Vertices.Count < 3 || poly.RotationDirection() == winding)
return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Create a reversed copy to avoid mutating shared entity objects.
@@ -833,7 +794,7 @@ namespace OpenNest.Geometry
new Circle(c.Center, c.Radius)
{
Layer = c.Layer,
Rotation = RotationType.CCW,
Rotation = winding,
}
);
break;
+8 -3
View File
@@ -37,10 +37,15 @@ namespace OpenNest.Geometry
internal const double IncidenceTolerance = 1e-7;
// Drawing data fixes arc centers to finite precision, so a tangent joint's arc end
// can sit a little past its neighbour's end. A contact this close to a joint takes
// the joint's tangents; otherwise the neighbour reads as a second boundary there.
// can sit a little past its neighbor's end. A contact this close to a joint takes
// the joint's tangents; otherwise the neighbor reads as a second boundary there.
internal const double JointTolerance = 1e-6;
// Loops are recovered with ShapeBuilder's chaining tolerance: a source contour that
// closes within it (PEP rounding leaves 1e-6 gaps) is a loop everywhere else, and an
// unrecovered loop leaves every contact with the whole part unresolved and blocking.
internal const double ChainTolerance = Tolerance.ChainTolerance;
private readonly List<Entity> entities;
private readonly int[] loopOf;
private readonly int[] previous;
@@ -373,7 +378,7 @@ namespace OpenNest.Geometry
}
}
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
private static double SignedArea(List<Entity> entities, int first, int last)
{
+5 -1
View File
@@ -791,7 +791,11 @@ namespace OpenNest.Geometry
return vertices.ToArray();
}
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
/// <summary>
/// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°)
/// if they fall within the arc's angular span.
/// </summary>
internal static void AddArcExtremeVertices(ICollection<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
+128 -32
View File
@@ -98,6 +98,9 @@ namespace OpenNest
// A turned sheet is the same sheet; any other size is no longer the coded item.
if (stockItemCodes.Count > 0 && !SameSheet(size, value))
stockItemCodes = Array.Empty<string>();
// An outline is a particular sheet; any other size is a rectangle again.
if (outline != null && (outline.Size.Width != value.Width || outline.Size.Length != value.Length))
outline = null;
size = value;
ResolveClampRail();
}
@@ -105,6 +108,26 @@ namespace OpenNest
private Size size;
/// <summary>
/// The sheet's material when it is not a rectangle (see <see cref="SheetOutline"/>), in
/// plate coordinates; null for a rectangular sheet. Setting one makes <see cref="Size"/> its
/// bounding box, and setting any other size drops it. Only the Irregular whole-job engine
/// nests on an outlined sheet; cut-offs are trimmed to it, automatic cut-offs refuse it, and
/// <see cref="Area"/> is its material area.
/// </summary>
public SheetOutline Outline
{
get { return outline; }
set
{
outline = value;
if (value != null)
Size = value.Size;
}
}
private SheetOutline outline;
private static bool SameSheet(Size a, Size b) =>
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
@@ -170,6 +193,14 @@ namespace OpenNest
/// </summary>
public ClampRail ClampRail { get; private set; }
/// <summary>
/// The gap part material keeps from every clamp zone: the rail's own
/// <see cref="OpenNest.ClampRail.Clearance"/>, else the bottom edge spacing (D6), never
/// negative. Part spacing never applies to clamps.
/// </summary>
public double ClampClearance =>
System.Math.Max(0, ClampRail?.Clearance ?? EdgeSpacing.Bottom);
/// <summary>
/// The machine support bed this sheet rests on (its nest's, see
/// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support
@@ -303,11 +334,13 @@ namespace OpenNest
public void RegenerateCutOffs(CutOffSettings settings)
{
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
var places = new List<int>();
for (var i = Parts.Count - 1; i >= 0; i--)
{
if (!Parts[i].BaseDrawing.IsCutOff)
continue;
places.Add(i);
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
if (cutoff != null)
{
@@ -343,7 +376,7 @@ namespace OpenNest
indices[k] = previous = best >= 0 ? best : previous;
}
return indices;
});
}, _ => places);
}
/// <summary>
@@ -353,38 +386,78 @@ namespace OpenNest
/// from it are added at the end. Callers must remove existing cut-off parts first.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) =>
RegenerateCutOffs(settings, (cutoff, extents) =>
sequence != null && sequence.TryGetValue(cutoff, out var index)
? Enumerable.Range(index, extents.Count).ToArray() : null);
RegenerateCutOffs(settings, null, sequence);
/// <summary>
/// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>,
/// but each cut-off lists one index per segment in ascending order along its axis. A list
/// whose length differs from the regenerated segment count places the segments
/// consecutively from its smallest index.
/// consecutively at its smallest index.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
RegenerateCutOffs(settings, sequence, null);
/// <summary>
/// Places each cut-off's regenerated segments where its saved segments were, among the
/// parts saved around them: <paramref name="places"/> lists every saved segment's place;
/// <paramref name="firstPlaces"/> gives only the first, for files that recorded no more
/// (their segments were consecutive, but how many is unknown). Settings other than the
/// saved ones can regenerate a different number of segments; the parts keep their
/// places relative to the cut-offs either way. Where only a first place is known the
/// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> places,
IReadOnlyDictionary<CutOff, int> firstPlaces) =>
RegenerateCutOffs(settings, (cutoff, extents) =>
{
if (sequence == null || !sequence.TryGetValue(cutoff, out var indices) || indices == null || indices.Count == 0)
return null;
if (indices.Count != extents.Count)
return Enumerable.Range(indices.Min(), extents.Count).ToArray();
// Program order can run toward the origin; the file lists segments ascending.
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
var result = new int[extents.Count];
for (var rank = 0; rank < ascending.Length; rank++)
result[ascending[rank]] = indices[rank];
return result;
});
if (places != null && places.TryGetValue(cutoff, out var indices) && indices is { Count: > 0 })
{
if (indices.Count != extents.Count)
return Enumerable.Repeat(indices.Min(), extents.Count).ToArray();
// Program order can run toward the origin; the file lists segments ascending.
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
var result = new int[extents.Count];
for (var rank = 0; rank < ascending.Length; rank++)
result[ascending[rank]] = indices[rank];
return result;
}
return firstPlaces != null && firstPlaces.TryGetValue(cutoff, out var first)
? Enumerable.Repeat(first, extents.Count).ToArray() : null;
}, counts =>
{
var saved = new HashSet<int>();
foreach (var list in places?.Values ?? [])
saved.UnionWith(list ?? []);
if (firstPlaces == null)
return saved;
// A cut-off saved by its first place alone held consecutive places, as many as it
// has segments now unless that runs into the next cut-off's first place.
var starts = saved.Concat(firstPlaces.Values).Distinct().Order().ToArray();
foreach (var (cutoff, first) in firstPlaces)
{
var next = starts.FirstOrDefault(start => start > first, int.MaxValue);
var count = System.Math.Max(1, System.Math.Min(counts.GetValueOrDefault(cutoff, 1), next - first));
saved.UnionWith(Enumerable.Range(first, count));
}
return saved;
}, cutoff => firstPlaces != null && firstPlaces.ContainsKey(cutoff));
// sequence gives a cut-off's segment indices in program order from its segments' extents
// along its axis (NaN when unmeasurable), or null to append them.
// sequence gives a cut-off's saved place for each segment in program order, from its
// segments' extents along its axis (NaN when unmeasurable), or null to append them.
// savedPlaces gives every place cut-off segments held in the saved order, from each
// cut-off's regenerated segment count; the parts held the others, in their order.
// separateLast says which cut-offs go after everything when they split the sheet.
private void RegenerateCutOffs(CutOffSettings settings,
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence)
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence,
Func<IReadOnlyDictionary<CutOff, int>, IEnumerable<int>> savedPlaces,
Func<CutOff, bool> separateLast = null)
{
var cache = BuildPerimeterCache(this);
var sheet = BoundingBox(includeParts: false);
var placed = new List<(int Index, int Order, Part Part)>();
var separating = new List<Part>();
var counts = new Dictionary<CutOff, int>();
var partCount = Parts.Count;
// Regenerate and materialize each cut-off's segments
foreach (var cutoff in CutOffs)
@@ -399,20 +472,34 @@ namespace OpenNest
var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray();
var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray();
var indices = sequence?.Invoke(cutoff, extents);
counts[cutoff] = parts.Length;
for (var k = 0; k < parts.Length; k++)
{
if (indices != null && k < indices.Count)
// A line splitting the sheet comes after every part and every other cut-off.
if (separateLast?.Invoke(cutoff) == true && CutOff.SeparatesSheet(parts[k], cutoff, sheet))
separating.Add(parts[k]);
else if (indices != null && k < indices.Count)
placed.Add((indices[k], placed.Count, parts[k]));
else
Parts.Add(parts[k]);
}
}
// Lowest index first: each insert then lands on its saved index, because every part
// sequenced before it is already in place. Segments sharing an index keep their
// program order (a later insert at the same index lands before an earlier one).
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenByDescending(p => p.Order))
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part);
// Saved places count every cut-off segment then on the plate, which may now be more or
// fewer. A segment goes after the parts saved before it and the segments placed before
// it, so a changed count never shifts a later cut-off or part. Segments sharing a place
// keep their program order.
var saved = (savedPlaces?.Invoke(counts) ?? []).Distinct().Order().ToArray();
var inserted = 0;
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenBy(p => p.Order))
{
var savedBefore = Array.BinarySearch(saved, index);
savedBefore = savedBefore < 0 ? ~savedBefore : savedBefore;
var partsBefore = System.Math.Clamp(index - savedBefore, 0, partCount);
Parts.Insert(partsBefore + inserted++, part);
}
foreach (var part in separating)
Parts.Add(part);
}
/// <summary>
@@ -456,10 +543,13 @@ namespace OpenNest
// Leads are cutting paths in scrap, not boundary edges. Chaining them
// into the outline can make a closed part appear open and replace its
// recesses with a convex hull. Keep the full geometry for the existing
// conservative fallback when the material contour really is open.
// recesses with a convex hull. A cut-off may cross a lead, so the open-
// contour hull also leaves them out: hulling a lead's pierce point would
// stop cut-offs far short of the part.
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
e.Layer != SpecialLayers.Leadout).ToList();
if (outline.Count == 0)
return null;
var profile = new Geometry.ShapeProfile(outline);
if (profile.Perimeter.IsClosed())
@@ -469,7 +559,7 @@ namespace OpenNest
return perimeter;
}
var points = entities.CollectPoints();
var points = outline.CollectPoints();
if (points.Count < 3)
return null;
@@ -526,7 +616,9 @@ namespace OpenNest
{
const double oneAndHalfPI = System.Math.PI * 1.5;
var turned = Outline?.Rotated90(rotationDirection);
Size = new Size(Size.Length, Size.Width);
Outline = turned;
// After Size swap above, new Size.Width = old Length (old X extent),
// new Size.Length = old Width (old Y extent).
@@ -632,10 +724,12 @@ namespace OpenNest
}
Rotate(System.Math.PI, centerpt);
Outline = Outline?.Rotated180();
}
else
{
Rotate(System.Math.PI);
Outline = Outline?.Rotated180();
Quadrant = (Quadrant + 2) % 4;
if (Quadrant == 0)
@@ -841,7 +935,8 @@ namespace OpenNest
/// </example>
public void AutoSize(double roundingFactor = 1.0)
{
if (Parts.Count == 0)
// An outlined sheet is a particular piece of material; it cannot be resized to fit.
if (Parts.Count == 0 || Outline != null)
return;
var bounds = Parts.GetBoundingBox();
@@ -890,7 +985,7 @@ namespace OpenNest
/// </summary>
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
{
if (Parts.Count == 0)
if (Parts.Count == 0 || Outline != null)
return default;
var bounds = Parts.GetBoundingBox();
@@ -942,12 +1037,13 @@ namespace OpenNest
}
/// <summary>
/// Gets the area of the top surface of the plate.
/// Gets the area of the top surface of the plate: the material inside its
/// <see cref="Outline"/> when it has one, otherwise the whole rectangle.
/// </summary>
/// <returns></returns>
public double Area()
{
return Size.Width * Size.Length;
return Outline?.Area ?? Size.Width * Size.Length;
}
/// <summary>
+1
View File
@@ -133,6 +133,7 @@ namespace OpenNest
var followsDefaults = trailing != null
&& trailing.Parts.Count == 0
&& trailing.CutOffs.Count == 0
&& trailing.Outline == null
&& trailing.Size.Width == defaults.Size.Width
&& trailing.Size.Length == defaults.Size.Length
&& trailing.Quadrant == defaults.Quadrant
+9 -4
View File
@@ -38,10 +38,14 @@ public sealed class PlateRemnant
/// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
/// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
/// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks.
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks. A plate
/// with an <see cref="Plate.Outline"/> reports none.
/// </summary>
public static PlateRemnant Expected(Plate plate, double minimumDimension)
{
// No rectangular strip is guaranteed inside an outlined sheet.
if (plate.Outline != null)
return null;
// A part with no material (marks only) bounds nothing.
var bounds = plate.Parts
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
@@ -148,8 +152,8 @@ public sealed class PlateRemnant
}
/// <summary>
/// Whether the nominal line runs edge to edge. A head-locked overcut only extends the
/// program past the far edge, so the nominal limits decide.
/// Whether the nominal line runs edge to edge with no gap on the sheet. A head-locked
/// overcut only extends the program past the far edge, so the nominal limits decide.
/// </summary>
private static bool SpansSheet(CutOff cutOff, Box sheet)
{
@@ -157,7 +161,8 @@ public sealed class PlateRemnant
var alongMin = vertical ? sheet.Bottom : sheet.Left;
var alongMax = vertical ? sheet.Top : sheet.Right;
return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon)
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon);
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon)
&& !cutOff.Gaps.Any(gap => gap.End > alongMin + Tolerance.Epsilon && gap.Start < alongMax - Tolerance.Epsilon);
}
private static bool Reaches(Box material, bool vertical, double from, double to)
+293
View File
@@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// The material of a sheet that is not a rectangle: a remnant, a drop, or a sheet with a
/// burned or damaged area. A closed polygon in plate coordinates (the sheet's lower-left
/// frame, as <see cref="Plate.ClampZones"/>) with optional obstacle polygons for areas inside
/// it that must stay empty. The polygon lies inside the true material and every obstacle
/// polygon contains its true area, so chording never adds room. Its bounding box starts at
/// the origin and is the sheet's <see cref="Size"/>; rectangular code paths keep using that
/// box. Immutable.
/// </summary>
public sealed class SheetOutline : IEquatable<SheetOutline>
{
/// <summary>Chord deviation when importing arcs; the outline is also eroded, and each
/// obstacle inflated, by this much so the stored polygons never reach past the true edges.</summary>
public const double ChordTolerance = SpacingOutline.ChordTolerance;
private const int Precision = ClipperBridge.Precision;
private const double ArcTolerance = 0.001;
private const double MiterLimit = 2.0;
private readonly PathD outline;
private readonly PathsD obstacles;
private readonly PathsD material;
/// <summary>
/// Builds an outline from its vertices and optional obstacles, each a simple polygon of at
/// least three finite vertices with a positive area. Winding is normalized and the whole
/// is translated so the outline's bounds start at the origin; an obstacle wholly outside
/// the outline is dropped.
/// </summary>
/// <exception cref="ArgumentException">A polygon is missing, degenerate, nonfinite or
/// self-intersecting.</exception>
public SheetOutline(IEnumerable<Vector> points, IEnumerable<IEnumerable<Vector>> obstacles = null)
{
ArgumentNullException.ThrowIfNull(points);
var path = SimplePolygon(points, "outline");
var box = Clipper.GetBounds(path);
var shift = new PointD(-box.left, -box.top); // RectD top is the minimum Y.
outline = Clipper.TranslatePath(path, shift.x, shift.y);
Size = new Size(box.Height, box.Width);
this.obstacles = new PathsD();
if (obstacles != null)
{
var index = 0;
foreach (var obstacle in obstacles)
{
index++;
var obstaclePath = Clipper.TranslatePath(SimplePolygon(obstacle, $"obstacle {index}"), shift.x, shift.y);
var inside = Clipper.Intersect(new PathsD { outline }, new PathsD { obstaclePath }, FillRule.NonZero, Precision);
if (inside.Sum(Clipper.Area) > 0)
this.obstacles.Add(obstaclePath);
}
}
material = Clipper.Difference(new PathsD { outline }, this.obstacles, FillRule.NonZero, Precision);
Area = material.Sum(Clipper.Area);
if (!(Area > 0))
throw new ArgumentException("The obstacles leave no material inside the outline.", nameof(obstacles));
Points = outline.Select(p => new Vector(p.x, p.y)).ToArray();
Obstacles = this.obstacles
.Select(o => (IReadOnlyList<Vector>)o.Select(p => new Vector(p.x, p.y)).ToArray())
.ToArray();
}
/// <summary>Vertices of the outline, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<Vector> Points { get; }
/// <summary>Vertices of each obstacle, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Obstacles { get; }
/// <summary>The bounding box of the outline: the plate's size.</summary>
public Size Size { get; }
/// <summary>Material area: the outline less its obstacles.</summary>
public double Area { get; }
/// <summary>
/// Where the plate's lower-left corner lies in the placement frame of its parts for
/// <paramref name="quadrant"/>: -Length on X in quadrants 2 and 3, -Width on Y in 3 and 4,
/// exactly as <see cref="ClampZoneFrame"/> and the engine stock translate.
/// </summary>
public Vector PlacementOrigin(int quadrant) =>
new(quadrant is 2 or 3 ? -Size.Length : 0, quadrant is 3 or 4 ? -Size.Width : 0);
/// <summary>
/// The outline less its obstacles, translated by <paramref name="origin"/>: outer paths
/// positive, holes negative.
/// </summary>
public PathsD Material(Vector origin) => Translate(material, origin);
/// <summary>
/// The material eroded by <paramref name="inset"/> (round joins) and translated by
/// <paramref name="origin"/>: where a part may lie when it must keep <paramref name="inset"/>
/// from every edge and obstacle. Empty when nothing is left. A nonpositive inset returns
/// the material itself.
/// </summary>
public PathsD Usable(double inset, Vector origin)
{
if (!(inset > 0))
return Material(origin);
var eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
return Translate(eroded, origin);
}
/// <summary>
/// Whether a region lies inside the material eroded by <paramref name="inset"/>, allowing
/// <paramref name="slack"/> outside it: nothing of <paramref name="region"/> (outer paths
/// positive, holes negative, in the frame of <paramref name="origin"/>) is left after
/// subtracting the usable area grown by the slack. The test is exact on the stored polygons;
/// it never loosens for arcs.
/// </summary>
public bool Holds(PathsD region, double inset, Vector origin, double slack)
{
var usable = Usable(inset, origin);
if (slack > 0)
{
usable = Clipper.InflatePaths(usable, slack, JoinType.Miter, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
}
var outside = Clipper.Difference(region, usable, FillRule.NonZero, Precision);
return outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0;
}
/// <summary>This outline turned a quarter turn about the origin, re-normalized to start
/// at the origin, as <see cref="Plate.Rotate90"/> turns the sheet.</summary>
public SheetOutline Rotated90(RotationType direction)
{
Vector Turn(Vector p) => direction == RotationType.CW ? new Vector(p.Y, -p.X) : new Vector(-p.Y, p.X);
return new SheetOutline(Points.Select(Turn), Obstacles.Select(o => o.Select(Turn)));
}
/// <summary>This outline turned half a turn, re-normalized to start at the origin.</summary>
public SheetOutline Rotated180() =>
new(Points.Select(p => new Vector(-p.X, -p.Y)), Obstacles.Select(o => o.Select(p => new Vector(-p.X, -p.Y))));
/// <summary>
/// An outline from imported geometry: the largest closed contour becomes the outline and
/// every other closed contour an obstacle. Arcs are chorded at <see cref="ChordTolerance"/>,
/// then the outline is eroded and each obstacle inflated by that much, so the stored
/// polygons stay within the true edges. Marks and open contours are ignored.
/// </summary>
/// <exception cref="ArgumentException">No closed contour with a positive area.</exception>
public static SheetOutline FromGeometry(IEnumerable<Entity> entities)
{
ArgumentNullException.ThrowIfNull(entities);
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
ShapeProfile profile;
try
{
profile = material.Count == 0 ? null : new ShapeProfile(material);
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
throw new ArgumentException("The geometry has no closed outline.", nameof(entities), exception);
}
if (profile?.Perimeter == null)
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var perimeter = ClipperBridge.ToPath(ClipperBridge.Flatten(profile.Perimeter, ChordTolerance, circumscribe: false), positive: true);
if (perimeter.Count < 3 || !(Clipper.Area(perimeter) > 0))
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var outer = Clipper.InflatePaths(new PathsD { perimeter },
-ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
var outline = outer.Where(p => p.Count >= 3).OrderByDescending(Clipper.Area).FirstOrDefault()
?? throw new ArgumentException("The outline is too thin to hold material.", nameof(entities));
var obstacles = new List<IEnumerable<Vector>>();
foreach (var cutout in profile.Cutouts)
{
var hole = ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, ChordTolerance, circumscribe: false), positive: true);
if (hole.Count < 3 || !(Clipper.Area(hole) > 0))
continue;
var grown = Clipper.InflatePaths(new PathsD { hole },
ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
foreach (var path in grown.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
obstacles.Add(path.Select(p => new Vector(p.x, p.y)).ToList());
}
return new SheetOutline(outline.Select(p => new Vector(p.x, p.y)), obstacles);
}
public bool Equals(SheetOutline other)
{
if (other is null)
return false;
if (ReferenceEquals(this, other))
return true;
if (Points.Count != other.Points.Count || Obstacles.Count != other.Obstacles.Count)
return false;
for (var i = 0; i < Points.Count; i++)
if (!Same(Points[i], other.Points[i]))
return false;
for (var k = 0; k < Obstacles.Count; k++)
{
if (Obstacles[k].Count != other.Obstacles[k].Count)
return false;
for (var i = 0; i < Obstacles[k].Count; i++)
if (!Same(Obstacles[k][i], other.Obstacles[k][i]))
return false;
}
return true;
}
public override bool Equals(object obj) => Equals(obj as SheetOutline);
public override int GetHashCode()
{
var hash = new System.HashCode();
hash.Add(Points.Count);
hash.Add(Obstacles.Count);
foreach (var p in Points)
{
hash.Add(BitConverter.DoubleToInt64Bits(p.X));
hash.Add(BitConverter.DoubleToInt64Bits(p.Y));
}
return hash.ToHashCode();
}
public override string ToString() =>
$"{Size} outline, {Points.Count} vertices" + (Obstacles.Count == 0 ? "" : $", {Obstacles.Count} obstacle(s)");
/// <summary>Exact bits: two outlines are the same stock only when nothing moved.</summary>
private static bool Same(Vector a, Vector b) =>
BitConverter.DoubleToInt64Bits(a.X) == BitConverter.DoubleToInt64Bits(b.X)
&& BitConverter.DoubleToInt64Bits(a.Y) == BitConverter.DoubleToInt64Bits(b.Y);
private static PathsD Translate(PathsD paths, Vector origin) =>
origin.X == 0 && origin.Y == 0
? new PathsD(paths.Select(p => new PathD(p)))
: Clipper.TranslatePaths(paths, origin.X, origin.Y);
/// <summary>A counterclockwise copy without the closing or repeated vertices, after
/// checking it is one simple polygon of positive area.</summary>
private static PathD SimplePolygon(IEnumerable<Vector> points, string what)
{
ArgumentNullException.ThrowIfNull(points, nameof(points));
var path = new PathD();
foreach (var p in points)
{
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y))
throw new ArgumentException($"The {what} has a nonfinite vertex.", nameof(points));
if (path.Count > 0 && path[^1].x == p.X && path[^1].y == p.Y)
continue;
path.Add(new PointD(p.X, p.Y));
}
if (path.Count > 1 && path[0].x == path[^1].x && path[0].y == path[^1].y)
path.RemoveAt(path.Count - 1);
if (path.Count < 3)
throw new ArgumentException($"The {what} needs at least three distinct vertices.", nameof(points));
var area = Clipper.Area(path);
if (!(System.Math.Abs(area) > 0))
throw new ArgumentException($"The {what} has no area.", nameof(points));
if (area < 0)
path.Reverse();
// Start at the lowest-left vertex so equal polygons list their vertices alike.
var start = 0;
for (var i = 1; i < path.Count; i++)
if (path[i].y < path[start].y || path[i].y == path[start].y && path[i].x < path[start].x)
start = i;
if (start != 0)
path = new PathD(path.Skip(start).Concat(path.Take(start)));
// A simple polygon unions to itself: one path of the same area. A figure eight or a
// bow tie does not, and a sheet edge that crosses itself is not a sheet.
var union = Clipper.Union(new PathsD { path }, new PathsD(), FillRule.NonZero, Precision);
var scale = System.Math.Pow(10, -Precision);
if (union.Count != 1 || System.Math.Abs(Clipper.Area(union[0]) - System.Math.Abs(area)) > scale * (path.Count + 1) * Perimeter(path))
throw new ArgumentException($"The {what} must be a simple polygon that does not cross itself.", nameof(points));
return path;
}
private static double Perimeter(PathD path)
{
var length = 0.0;
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
length += System.Math.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
}
return length;
}
}
}
+7 -3
View File
@@ -252,13 +252,17 @@ public static class DrawingSplitter
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
}
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds)
/// <summary>
/// The grid of pieces the split lines cut <paramref name="bounds"/> into, row by row
/// from the bottom-left.
/// </summary>
public static List<Box> BuildClipRegions(IEnumerable<SplitLine> lines, Box bounds)
{
var verticals = sortedLines
var verticals = lines
.Where(l => l.Axis == CutOffAxis.Vertical)
.OrderBy(l => l.Position)
.ToList();
var horizontals = sortedLines
var horizontals = lines
.Where(l => l.Axis == CutOffAxis.Horizontal)
.OrderBy(l => l.Position)
.ToList();
+4 -4
View File
@@ -360,19 +360,19 @@ namespace OpenNest.Support
yield break;
var step = perimeter / TabSamples;
var travelled = 0.0;
var traveled = 0.0;
var next = step;
for (var i = 0; i < outline.Count; i++)
{
var a = outline[i];
var b = outline[(i + 1) % outline.Count];
var length = a.DistanceTo(b);
while (length > 0 && next < travelled + length)
while (length > 0 && next < traveled + length)
{
yield return a + (b - a) * ((next - travelled) / length);
yield return a + (b - a) * ((next - traveled) / length);
next += step;
}
travelled += length;
traveled += length;
}
}
}
@@ -28,7 +28,7 @@ namespace OpenNest.TabbedCutouts
public double Length { get; internal set; }
/// <summary>
/// One open cut per span of the contour between tabs, in contour order, travelling
/// One open cut per span of the contour between tabs, in contour order, traveling
/// counterclockwise (the slug on the left).
/// </summary>
public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; }
@@ -65,7 +65,7 @@ namespace OpenNest.TabbedCutouts
/// <summary>
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a
/// gap from its neighbours around the contour and does not pass the contour's start
/// gap from its neighbors around the contour and does not pass the contour's start
/// (which would reorder the tabs).
/// </summary>
public double ClampTabCenter(int index, double center)
+2 -2
View File
@@ -17,7 +17,7 @@ public interface IBrowsePage
/// <summary>
/// Browse state shared by the paged server lists: the requested search, sort and page, and the
/// last page applied. Every change issues one bounded server query. A response that a later
/// request superseded is discarded (and the earlier request is cancelled), so an older result
/// request superseded is discarded (and the earlier request is canceled), so an older result
/// can never replace a newer one. Call from one thread (the UI thread).
/// </summary>
public abstract class BrowseSession<TPage, TSort> : IDisposable
@@ -130,7 +130,7 @@ public abstract class BrowseSession<TPage, TSort> : IDisposable
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// Supersede before canceling: a request that completes synchronously when canceled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
+13 -2
View File
@@ -19,6 +19,7 @@ public sealed class ClampRailEditor
Width = initial.Width;
Depth = initial.Depth;
RemovalMinutes = initial.RemovalMinutes;
Clearance = initial.Clearance;
_clamps.AddRange(initial.Clamps.Order());
}
@@ -35,6 +36,9 @@ public sealed class ClampRailEditor
/// length, so it is never converted between units.</summary>
public double RemovalMinutes { get; set; }
/// <summary>The gap parts keep from every clamp; null uses each sheet's bottom edge spacing.</summary>
public double? Clearance { get; set; }
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
public bool ApplyToNewNests { get; set; }
@@ -91,6 +95,7 @@ public sealed class ClampRailEditor
Width = converted.Width;
Depth = converted.Depth;
RemovalMinutes = converted.RemovalMinutes;
Clearance = converted.Clearance;
ApplyToNewNests = settings.Enabled;
_clamps.Clear();
_clamps.AddRange(converted.Clamps.Distinct().Order());
@@ -99,10 +104,10 @@ public sealed class ClampRailEditor
/// <summary>
/// The edited rail, or null when no clamp is listed. Throws
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
/// negative/non-finite removal time.
/// negative/non-finite removal time or clearance.
/// </summary>
public ClampRail? BuildRail() =>
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes);
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes, Clearance);
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
public void ApplyTo(Nest nest)
@@ -128,6 +133,11 @@ public sealed class ClampRailEditor
nameof(RemovalMinutes),
"Clamp removal time must be finite and zero or more."
);
if (Clearance is double clearance && (!double.IsFinite(clearance) || clearance < 0))
throw new ArgumentOutOfRangeException(
nameof(Clearance),
"Clamp clearance must be finite and zero or more."
);
var settings = new ClampSettings
{
Enabled = ApplyToNewNests && _clamps.Count > 0,
@@ -135,6 +145,7 @@ public sealed class ClampRailEditor
Width = Width,
Depth = Depth,
RemovalMinutes = RemovalMinutes,
Clearance = Clearance,
};
settings.SetClamps(_clamps);
return settings;
+26 -54
View File
@@ -65,6 +65,12 @@ public sealed class ClampSettings
/// </summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>
/// The gap parts keep from every clamp, in <see cref="Units"/>; null uses each sheet's
/// bottom edge spacing (<see cref="ClampRail.Clearance"/>).
/// </summary>
public double? Clearance { get; set; }
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
@@ -133,6 +139,7 @@ public sealed class ClampSettings
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
Clearance = rail?.Clearance,
};
if (rail != null)
settings.SetClamps(rail.Clamps);
@@ -150,11 +157,12 @@ public sealed class ClampSettings
return null;
var converted = ConvertedTo(units);
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes);
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes,
converted.Clearance);
}
/// <summary>
/// A copy with width, depth and centers expressed in <paramref name="units"/>
/// A copy with width, depth, clearance and centers expressed in <paramref name="units"/>
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
/// </summary>
@@ -168,6 +176,7 @@ public sealed class ClampSettings
Width = Width * scale,
Depth = Depth * scale,
RemovalMinutes = RemovalMinutes,
Clearance = Clearance * scale,
Centers = Centers.Select(c => c * scale).ToArray(),
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
@@ -207,7 +216,7 @@ public sealed class ClampSettings
{
json = File.ReadAllText(path);
}
catch (Exception ex) when (IsUnreadableFile(ex))
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
{
status = ClampSettingsStatus.Invalid;
return Fallback;
@@ -238,7 +247,7 @@ public sealed class ClampSettings
foreach (var property in document.RootElement.EnumerateObject())
values[property.Name] = property.Value.Clone();
}
catch (Exception ex) when (IsUnreadableFile(ex))
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
{
status = ClampSettingsStatus.Invalid;
return settings;
@@ -256,7 +265,7 @@ public sealed class ClampSettings
if (values.TryGetValue("units", out var units))
{
if (TryParseUnits(units, out var parsed))
if (SettingsFile.TryParseUnits(units, out var parsed))
settings.Units = parsed;
else
ignored = true;
@@ -290,6 +299,15 @@ public sealed class ClampSettings
ignored = true;
}
// Missing or null keeps the bottom edge spacing; anything else must be a length.
if (values.TryGetValue("clearance", out var clearance) && clearance.ValueKind != JsonValueKind.Null)
{
if (TryLength(clearance, out var value) && value >= 0)
settings.Clearance = value;
else
ignored = true;
}
if (values.TryGetValue("centers", out var centers))
settings.Centers = ReadCenters(centers, ref ignored);
if (values.TryGetValue("farSideCenters", out var farSide))
@@ -329,24 +347,7 @@ public sealed class ClampSettings
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var json = ToJson();
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
SettingsFile.Write(path, ToJson(), maxRetries);
}
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
@@ -362,6 +363,8 @@ public sealed class ClampSettings
["removalMinutes"] = RemovalMinutes,
["centers"] = Centers,
};
if (Clearance is double clearance)
dto["clearance"] = clearance;
// Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters;
@@ -392,35 +395,4 @@ public sealed class ClampSettings
&& double.IsFinite(value)
&& double.IsFinite(value * MillimetersPerInch);
}
/// <summary>
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
/// numeric strings and comma-joined names.
/// </summary>
private static bool TryParseUnits(JsonElement element, out Units units)
{
units = default;
if (element.ValueKind != JsonValueKind.String)
return false;
var text = element.GetString();
foreach (var candidate in Enum.GetValues<Units>())
{
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
{
units = candidate;
return true;
}
}
return false;
}
private static bool IsUnreadableFile(Exception ex) =>
ex
is JsonException
or IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException;
}
-174
View File
@@ -1,174 +0,0 @@
{
"id": "00000000-0000-0000-0000-000000980001",
"schemaVersion": 1,
"name": "CL-980",
"type": "laser",
"units": "inches",
"materials": [
{
"name": "Mild Steel",
"grade": "A36",
"density": 0.2836,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.075,
"kerf": 0.008,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.105,
"kerf": 0.010,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.135,
"kerf": 0.010,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.1875,
"kerf": 0.012,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.250,
"kerf": 0.012,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.375,
"kerf": 0.016,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.375, "angle": 90.0, "radius": 0.1875 },
"leadOut": { "type": "Line", "length": 0.1875, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
},
{
"value": 0.500,
"kerf": 0.020,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.5, "angle": 90.0, "radius": 0.25 },
"leadOut": { "type": "Line", "length": 0.25, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
}
]
},
{
"name": "Stainless Steel",
"grade": "304",
"density": 0.289,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.075,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.105,
"kerf": 0.010,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.250,
"kerf": 0.014,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
},
{
"name": "Aluminum",
"grade": "5052",
"density": 0.097,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.080,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.125,
"kerf": 0.010,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.250,
"kerf": 0.014,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.02, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
}
]
}
+101
View File
@@ -0,0 +1,101 @@
{
"id": "76974005-5de9-4de6-8ef0-4e4fa1ac6e98",
"schemaVersion": 1,
"name": "Generic Laser",
"type": "laser",
"units": "inches",
"materials": [
{
"name": "Mild Steel",
"grade": "CR",
"density": 0.2836,
"thicknesses": [
{"value": 0.0598, "name": "16 ga", "kerf": 0.006, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.0747, "name": "14 ga", "kerf": 0.006, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1046, "name": "12 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1196, "name": "11 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1345, "name": "10 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
]
},
{
"name": "Mild Steel",
"grade": "HR",
"density": 0.2836,
"thicknesses": [
{"value": 0.0747, "name": "14 ga", "kerf": 0.006, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1046, "name": "12 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1196, "name": "11 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1345, "name": "10 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.375, "name": "3/8", "kerf": 0.012, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.3125, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.3125, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.25, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.5, "name": "1/2", "kerf": 0.014, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.375, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.375, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
]
},
{
"name": "Mild Steel",
"grade": "HR P&O",
"density": 0.2836,
"thicknesses": [
{"value": 0.0747, "name": "14 ga", "kerf": 0.006, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1046, "name": "12 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1196, "name": "11 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1345, "name": "10 ga", "kerf": 0.008, "assistGas": "O2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
]
},
{
"name": "Stainless Steel",
"grade": "304",
"density": 0.289,
"thicknesses": [
{"value": 0.0625, "name": "16 ga", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.0781, "name": "14 ga", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1094, "name": "12 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.125, "name": "11 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1406, "name": "10 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
]
},
{
"name": "Stainless Steel",
"grade": "316",
"density": 0.289,
"thicknesses": [
{"value": 0.0625, "name": "16 ga", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.0781, "name": "14 ga", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1094, "name": "12 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.125, "name": "11 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1406, "name": "10 ga", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
]
},
{
"name": "Aluminum",
"grade": "5052",
"density": 0.097,
"thicknesses": [
{"value": 0.063, "name": "0.063", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.08, "name": "0.080", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.09, "name": "0.090", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.125, "name": "1/8", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
]
},
{
"name": "Aluminum",
"grade": "6061",
"density": 0.0975,
"thicknesses": [
{"value": 0.063, "name": "0.063", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.08, "name": "0.080", "kerf": 0.006, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.5, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.09, "name": "0.090", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.125, "name": "1/8", "kerf": 0.008, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 0.75, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.1875, "name": "3/16", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.15, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]},
{"value": 0.25, "name": "1/4", "kerf": 0.01, "assistGas": "N2", "leadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "leadOut": {"type": "Line", "length": 0.1, "angle": 60.0, "radius": 0.0}, "insideLeadIn": {"type": "Line", "length": 0.25, "angle": 60.0, "radius": 0.0}, "insideLeadOut": {"type": "None", "length": 0.0, "angle": 60.0, "radius": 0.0}, "cutOff": {"partClearance": 0.02, "overtravel": 0.75, "minSegmentLength": 1.0, "direction": "AwayFromOrigin"}, "plateSizes": ["48x96", "48x120", "48x144", "60x120", "60x144", "72x120", "72x144"]}
]
}
]
}
+4 -30
View File
@@ -27,20 +27,9 @@ public sealed class EngineSelectionSettings
/// <summary>Missing, unreadable or corrupt settings safely use Default without writing.</summary>
public static EngineSelectionSettings Load(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return new();
try
{
var settings = JsonSerializer.Deserialize<EngineSelectionSettings>(
File.ReadAllText(path), JsonOptions) ?? new();
settings.EngineName = NormalizeName(settings.EngineName);
return settings;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return new();
}
var settings = SettingsFile.Read<EngineSelectionSettings>(path, JsonOptions) ?? new();
settings.EngineName = NormalizeName(settings.EngineName);
return settings;
}
/// <summary>
@@ -81,22 +70,7 @@ public sealed class EngineSelectionSettings
{
var json = JsonSerializer.Serialize(
new EngineSelectionSettings { EngineName = NormalizeName(EngineName) }, JsonOptions);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
for (var attempt = 0; attempt < 3; attempt++)
{
try
{
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < 2)
{
Thread.Sleep(100);
}
}
SettingsFile.Write(path, json);
}
private static string NormalizeName(string? name) =>
+8
View File
@@ -79,6 +79,14 @@ public interface INestRepository
Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not allocate nest names.");
/// <summary>
/// The ledger item codes on the plates of every open (Draft, Quote or To Be Cut) nest,
/// except <paramref name="excludeNestId"/> when it is not empty.
/// </summary>
Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not list stock item codes.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
+15 -6
View File
@@ -44,7 +44,8 @@ public enum LibraryInsertKind
/// Decides how one library drawing revision enters a nest, before anything changes:
/// a new exact copy carrying its library link, more quantity on the copy already there,
/// or a refusal. Material, grade or thickness that differ from the nest still allow the
/// add, but the drawing's cut pins are dropped (D3). Different units, a different revision
/// add, but the drawing's cut pins are dropped (D3); a blank material or grade, or a zero
/// thickness, on the record suits any nest and is never a difference. Different units, a different revision
/// of the same drawing, or another drawing with the same name refuse it.
/// </summary>
public sealed class LibraryDrawingInsert
@@ -75,7 +76,7 @@ public sealed class LibraryDrawingInsert
/// <summary>
/// For a new copy, true when the library drawing's cut pins come with it: the library
/// material, grade and thickness match the nest's. Otherwise the copy has no pins. An
/// material, grade and thickness match the nest's or are unset. Otherwise the copy has no pins. An
/// added quantity leaves the existing copy as it is.
/// </summary>
public bool KeepPins { get; }
@@ -140,7 +141,12 @@ public sealed class LibraryDrawingInsert
var drawing = package.Drawing;
drawing.Name = record.Name;
drawing.Customer = record.Customer;
drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
// A field the record leaves unset means any material, so the copy takes the nest's.
var nestMaterial = nest.Material ?? new Material();
drawing.Material = new Material(
IsSet(record.MaterialName) ? record.MaterialName : nestMaterial.Name,
IsSet(record.MaterialGrade) ? record.MaterialGrade : nestMaterial.Grade,
drawing.Material?.Density ?? 0);
(record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
drawing.Quantity.Required = quantity;
drawing.Library = link;
@@ -177,21 +183,24 @@ public sealed class LibraryDrawingInsert
private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty<string>(), reason);
// A blank material or grade, or a zero thickness, on the record suits any nest.
private static List<string> Differences(Nest nest, DrawingLibraryRecord record)
{
var differences = new List<string>();
var material = nest.Material ?? new Material();
if (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
if (IsSet(record.MaterialName) && SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})");
if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
if (IsSet(record.MaterialGrade) && SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})");
var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
if (thickness != nestThickness)
if (thickness != 0 && thickness != nestThickness)
differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
return differences;
}
private static bool IsSet(string? value) => SharedListNames.Key(value).Length > 0;
private static string Describe(DrawingLibraryRecord record) =>
string.IsNullOrWhiteSpace(record.Customer)
? $"library drawing '{record.Name}' revision {record.Revision}"
+2 -18
View File
@@ -42,7 +42,7 @@ public class LocalJsonProvider : IDataProvider
{
var json = JsonSerializer.Serialize(machine, JsonOptions);
var path = GetPath(machine.Id);
WriteWithRetry(path, json);
SettingsFile.Write(path, json);
}
public void DeleteMachine(Guid id)
@@ -79,7 +79,7 @@ public class LocalJsonProvider : IDataProvider
var assembly = typeof(LocalJsonProvider).Assembly;
var resourceName = assembly
.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("CL-980.json"));
.FirstOrDefault(n => n.EndsWith("Generic-Laser.json"));
if (resourceName is null)
return;
@@ -97,20 +97,4 @@ public class LocalJsonProvider : IDataProvider
SaveMachine(config);
}
private static void WriteWithRetry(string path, string json, int maxRetries = 3)
{
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
}
}
+8 -1
View File
@@ -19,6 +19,9 @@ public sealed class MachineClamps
/// <summary>Minutes to remove one clamp and put it back after cutting; never scaled.</summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>The gap parts keep from every clamp; null uses each sheet's bottom edge spacing.</summary>
public double? Clearance { get; set; }
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public List<double> Centers { get; set; } = new();
@@ -49,6 +52,7 @@ public sealed class MachineClamps
Width = converted.Width,
Depth = converted.Depth,
RemovalMinutes = converted.RemovalMinutes,
Clearance = converted.Clearance,
Centers = converted.Centers.ToList(),
FarSideCenters = converted.FarSideCenters.ToList(),
OriginEndCenters = converted.OriginEndCenters.ToList(),
@@ -58,7 +62,8 @@ public sealed class MachineClamps
/// <summary>
/// The rail as clamp settings in the machine's <paramref name="units"/>. An invalid size or
/// removal time falls back to the default, and an invalid center is dropped, as when
/// removal time falls back to the default, an invalid clearance to the bottom edge spacing,
/// and an invalid center is dropped, as when
/// <see cref="ClampSettings.Parse"/> reads a file.
/// </summary>
public ClampSettings ToSettings(UnitSystem units)
@@ -70,6 +75,8 @@ public sealed class MachineClamps
defaults.Depth = Depth;
if (IsLength(RemovalMinutes) && RemovalMinutes >= 0)
defaults.RemovalMinutes = RemovalMinutes;
if (Clearance is double clearance && IsLength(clearance) && clearance >= 0)
defaults.Clearance = clearance;
defaults.Centers = Valid(Centers);
defaults.FarSideCenters = Valid(FarSideCenters);
defaults.OriginEndCenters = Valid(OriginEndCenters);
+117 -17
View File
@@ -3,12 +3,12 @@ using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.Data;
/// <summary>
/// The lead-in, lead-out, kerf and cut-off a machine configuration has on file for a nest's
/// material and thickness, converted to the nest's units. A machine has one lead-in and one
/// lead-out per thickness, so they apply to external, internal and arc/circle contours alike;
/// a Clean Hole lead-in is for holes only and leaves the external lead-in as it was, and a
/// Line + Line lead-in, whose second segment the configuration does not hold, leaves every
/// lead-in as it was.
/// The lead-ins, lead-outs, kerf and cut-off a machine configuration has on file for a nest's
/// material and thickness, converted to the nest's units. Outside cuts take the thickness's
/// lead-in and lead-out; inside cuts (circles included) take its inside ones, or the outside
/// ones when it has none. A Clean Hole lead-in is for holes only, so on outside cuts it leaves
/// the external lead-in as it was, and a Line + Line lead-in, whose second segment the
/// configuration does not hold, leaves the lead-in it would set as it was.
/// </summary>
public static class MachineCuttingDefaults
{
@@ -55,17 +55,8 @@ public static class MachineCuttingDefaults
return false;
var scale = Scale(machine!.Units, nest.Units);
var kerf = settings.Kerf * scale;
// Each lead set gets its own instance, as the cutting panel builds them.
if (ToLeadIn(settings.LeadIn, scale, kerf) is { } leadIn)
{
if (leadIn is not CleanHoleLeadIn)
parameters.ExternalLeadIn = leadIn;
parameters.InternalLeadIn = ToLeadIn(settings.LeadIn, scale, kerf);
}
parameters.ExternalLeadOut = ToLeadOut(settings.LeadOut, scale);
parameters.InternalLeadOut = ToLeadOut(settings.LeadOut, scale);
parameters.Kerf = kerf;
ApplyLeads(parameters, settings, scale);
parameters.Kerf = settings.Kerf * scale;
parameters.MachineName = machine.Name;
parameters.MaterialName = nest.Material.Name;
parameters.Grade = nest.Material.Grade;
@@ -73,6 +64,50 @@ public static class MachineCuttingDefaults
return true;
}
/// <summary>
/// The leads <paramref name="settings"/> give, in the machine's units, for previewing them:
/// a lead the machine leaves as it was (Line + Line, or Clean Hole on outside cuts) is none.
/// </summary>
public static CuttingParameters LeadsOf(ThicknessConfig settings)
{
ArgumentNullException.ThrowIfNull(settings);
var parameters = new CuttingParameters { ExternalLeadIn = new NoLeadIn(), InternalLeadIn = new NoLeadIn() };
ApplyLeads(parameters, settings, 1);
parameters.Kerf = settings.Kerf;
return parameters;
}
private static void ApplyLeads(CuttingParameters parameters, ThicknessConfig settings, double scale)
{
var kerf = settings.Kerf * scale;
// Each lead set gets its own instance, as the cutting panel builds them.
if (ToLeadIn(settings.LeadIn, scale, kerf) is { } leadIn and not CleanHoleLeadIn)
parameters.ExternalLeadIn = leadIn;
if (ToLeadIn(settings.InsideLeadIn ?? settings.LeadIn, scale, kerf) is { } insideLeadIn)
parameters.InternalLeadIn = insideLeadIn;
parameters.ExternalLeadOut = ToLeadOut(settings.LeadOut, scale);
parameters.InternalLeadOut = ToLeadOut(settings.InsideLeadOut ?? settings.LeadOut, scale);
}
/// <summary>
/// Whether <paramref name="parameters"/> are stamped with the machine, material, grade and
/// thickness <see cref="ApplyTo(CuttingParameters, MachineConfig?, Nest)"/> would record for
/// the nest: they started from the machine's settings for it, and any lead edits since are
/// the user's choice for that same job, which the machine's settings must not replace.
/// </summary>
public static bool IsRecordedFor(CuttingParameters parameters, MachineConfig? machine, Nest nest)
{
ArgumentNullException.ThrowIfNull(parameters);
ArgumentNullException.ThrowIfNull(nest);
if (Find(machine, nest) == null)
return false;
var tolerance = ThicknessToleranceInches * Scale(Units.Inches, nest.Units);
return string.Equals(parameters.MachineName, machine!.Name, StringComparison.Ordinal)
&& string.Equals(parameters.MaterialName, nest.Material.Name, StringComparison.Ordinal)
&& string.Equals(parameters.Grade ?? "", nest.Material.Grade ?? "", StringComparison.Ordinal)
&& System.Math.Abs(parameters.Thickness - nest.Thickness) <= tolerance;
}
/// <summary>
/// Replaces the part clearance, overtravel, minimum segment and direction of
/// <paramref name="settings"/> with the machine's for the nest; the head-lock choice is kept.
@@ -93,6 +128,71 @@ public static class MachineCuttingDefaults
return true;
}
/// <summary>
/// The lead-in <paramref name="lead"/> describes, in the machine's units, with
/// <paramref name="kerf"/> for a clean hole; null for a type it cannot build (Line + Line).
/// </summary>
public static LeadIn? LeadInOf(LeadConfig lead, double kerf) => ToLeadIn(lead, 1, kerf);
/// <summary>The lead-out <paramref name="lead"/> describes, in the machine's units.</summary>
public static LeadOut LeadOutOf(LeadConfig lead) => ToLeadOut(lead, 1);
/// <summary>
/// The machine configuration's form of <paramref name="leadIn"/>; what it cannot hold (an
/// arc's sweep, a clean hole's kerf, a second line) is left out. Unset fields keep
/// <paramref name="previous"/>'s values.
/// </summary>
public static LeadConfig ToConfig(LeadIn leadIn, LeadConfig? previous = null)
{
var config = Copy(previous);
switch (leadIn)
{
case LineLeadIn line:
(config.Type, config.Length, config.Angle) = ("Line", line.Length, line.ApproachAngle);
break;
case ArcLeadIn arc:
(config.Type, config.Radius) = ("Arc", arc.Radius);
break;
case LineArcLeadIn lineArc:
(config.Type, config.Length, config.Radius, config.Angle) =
("Line + Arc", lineArc.LineLength, lineArc.ArcRadius, lineArc.ApproachAngle);
break;
case CleanHoleLeadIn cleanHole:
(config.Type, config.Length, config.Radius) = ("Clean Hole", cleanHole.LineLength, cleanHole.ArcRadius);
break;
case LineLineLeadIn lineLine:
(config.Type, config.Length, config.Angle) = ("Line + Line", lineLine.Length1, lineLine.ApproachAngle1);
break;
default:
config.Type = "None";
break;
}
return config;
}
/// <summary>The machine configuration's form of <paramref name="leadOut"/>.</summary>
public static LeadConfig ToConfig(LeadOut leadOut, LeadConfig? previous = null)
{
var config = Copy(previous);
switch (leadOut)
{
case LineLeadOut line:
(config.Type, config.Length, config.Angle) = ("Line", line.Length, line.ApproachAngle);
break;
case ArcLeadOut arc:
(config.Type, config.Radius) = ("Arc", arc.Radius);
break;
default:
config.Type = "None";
break;
}
return config;
}
private static LeadConfig Copy(LeadConfig? lead) => lead == null
? new LeadConfig()
: new LeadConfig { Type = lead.Type, Length = lead.Length, Angle = lead.Angle, Radius = lead.Radius };
// Lead types are the names Tools > Machine Configuration offers; null when one cannot be built.
private static LeadIn? ToLeadIn(LeadConfig? lead, double scale, double kerf) => lead?.Type switch
{
@@ -0,0 +1,95 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>A plate's item code that is no longer the nest's to use, and why.</summary>
/// <param name="PlateNumber">The plate's 1-based position in the nest.</param>
public sealed record LedgerCodeConflict(int PlateNumber, string ItemCode, string Reason);
/// <summary>
/// The save and post re-check of a nest's <see cref="Plate.StockItemCodes"/>: finds codes the
/// ledger no longer has free for this nest or that other open nests are coded with, and takes
/// them off the plates when the programmer chooses to. Pure; the caller fetches the inputs.
/// </summary>
public static class LedgerCodeCheck
{
/// <summary>True when any plate has a coded sheet.</summary>
public static bool HasCodes(IEnumerable<Plate> plates) =>
(plates ?? throw new ArgumentNullException(nameof(plates))).Any(plate => plate.StockItemCodes.Count > 0);
/// <param name="plates">The nest's plates in order.</param>
/// <param name="freeItems">The ledger's free stock for the nest's material, after every hold.</param>
/// <param name="ownHolds">
/// The ledger's holds for this nest: an open one keeps its sheet for this nest, so the
/// sheet counts as available here although the ledger no longer lists it as free.
/// </param>
/// <param name="inUse">Other open nests' codes; an unknown answer checks the ledger alone.</param>
/// <returns>One conflict per plate and code, in plate order then the plate's code order.</returns>
public static IReadOnlyList<LedgerCodeConflict> Find(IList<Plate> plates, IEnumerable<LedgerStockItem> freeItems,
IEnumerable<LedgerHold>? ownHolds, LedgerCodesInUse inUse)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(freeItems);
ArgumentNullException.ThrowIfNull(inUse);
var available = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var item in freeItems.Where(item => item != null && item.Quantity > 0))
available[item.ItemCode] = available.GetValueOrDefault(item.ItemCode) + item.Quantity;
foreach (var hold in (ownHolds ?? Array.Empty<LedgerHold>()).Where(hold => hold != null && LedgerHoldStatuses.IsOpen(hold.Status)))
available[hold.ItemCode] = available.GetValueOrDefault(hold.ItemCode) + 1;
// A bundle's code repeats once per sheet across every plate it is used on.
var needed = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var code in plates.SelectMany(plate => plate.StockItemCodes))
needed[code] = needed.GetValueOrDefault(code) + 1;
var reasons = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (code, count) in needed)
{
var parts = new List<string>();
var free = available.GetValueOrDefault(code);
if (free == 0)
parts.Add("not free in MaterialLedger");
else if (free < count)
parts.Add($"only {free} of the {count} sheets needed free in MaterialLedger");
var nests = inUse.NestsUsing(code);
if (nests.Count > 0)
parts.Add((nests.Count == 1 ? "on nest " : "on nests ") + string.Join(", ", nests));
if (parts.Count > 0)
reasons[code] = Capitalize(string.Join("; ", parts));
}
var conflicts = new List<LedgerCodeConflict>();
for (var i = 0; i < plates.Count; i++)
{
foreach (var code in plates[i].StockItemCodes.Distinct(StringComparer.Ordinal))
{
if (reasons.TryGetValue(code, out var reason))
conflicts.Add(new LedgerCodeConflict(i + 1, code, reason));
}
}
return conflicts;
}
/// <summary>
/// Takes every copy of <paramref name="codes"/> off the plates, keeping the layout and the
/// other codes in order; the sheets left uncoded are matched by size in ML. Returns how
/// many sheets lost their code.
/// </summary>
public static int Remove(IEnumerable<Plate> plates, IEnumerable<string> codes)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(codes);
var remove = codes.ToHashSet(StringComparer.Ordinal);
var removed = 0;
foreach (var plate in plates)
{
var kept = plate.StockItemCodes.Where(code => !remove.Contains(code)).ToList();
if (kept.Count == plate.StockItemCodes.Count)
continue;
removed += plate.StockItemCodes.Count - kept.Count;
plate.StockItemCodes = kept;
}
return removed;
}
private static string Capitalize(string text) => text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text[1..];
}
@@ -0,0 +1,70 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// Takes item codes already on other open nests out of ledger stock rows, so two nests are
/// not planned on the same sheet before ML holds exist for either. Pure: rows are rebuilt,
/// never changed.
/// </summary>
public static class LedgerCodeFilter
{
/// <summary>
/// <paramref name="rows"/> without <paramref name="codesInUse"/>: every copy of a code
/// leaves the row's <see cref="LedgerStockRow.ItemCodes"/> and
/// <see cref="LedgerStockRow.ItemLocations"/>, its quantity drops by the copies removed, and
/// a row left with none is dropped. Row ids and order are kept. <paramref name="removed"/>
/// counts the sheets and drops taken out.
/// </summary>
public static IReadOnlyList<LedgerStockRow> Exclude(IEnumerable<LedgerStockRow> rows,
IEnumerable<string> codesInUse, out int removed)
{
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(codesInUse);
var exclude = codesInUse as IReadOnlySet<string> ?? codesInUse.ToHashSet(StringComparer.Ordinal);
removed = 0;
var kept = new List<LedgerStockRow>();
foreach (var row in rows)
{
if (row == null)
continue;
var codes = row.ItemCodes.Where(code => !exclude.Contains(code)).ToList();
var dropped = row.ItemCodes.Count - codes.Count;
if (dropped == 0)
{
kept.Add(row);
continue;
}
removed += dropped;
if (codes.Count == 0)
continue;
var itemLocations = row.ItemLocations
.Where(pair => !exclude.Contains(pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
// A location stays listed while an item left in the row is there, or when the row
// never said which item it held.
var locations = row.Locations
.Where(location => itemLocations.ContainsValue(location)
|| !row.ItemLocations.Values.Contains(location, StringComparer.Ordinal))
.ToList();
kept.Add(row with
{
Quantity = codes.Count,
ItemCodes = codes,
Locations = locations,
ItemLocations = itemLocations,
});
}
return kept;
}
/// <summary>
/// <paramref name="mapping"/> with <paramref name="codesInUse"/> taken out of its rows and
/// the sheets removed counted in <see cref="LedgerStockMapping.OnOtherNests"/>.
/// </summary>
public static LedgerStockMapping Exclude(LedgerStockMapping mapping, IEnumerable<string> codesInUse)
{
ArgumentNullException.ThrowIfNull(mapping);
var rows = Exclude(mapping.Rows, codesInUse, out var removed);
return mapping with { Rows = rows, OnOtherNests = mapping.OnOtherNests + removed };
}
}
@@ -0,0 +1,78 @@
using System.Net.Http;
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// The ledger item codes other open server nests are coded with, or why they could not be
/// read. ML's free counts drop only once a hold exists, so these codes are what another
/// Auto Nest run must leave out until then. Reading them never blocks: a failure is a result.
/// </summary>
public sealed record LedgerCodesInUse
{
/// <summary>The nest server gets this long to answer, so a stock load or save never waits on it.</summary>
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
private LedgerCodesInUse(IReadOnlyList<NestStockCodeUse> uses, string? unavailable)
{
Uses = uses;
Unavailable = unavailable;
Codes = uses.Select(use => use.ItemCode).ToHashSet(StringComparer.Ordinal);
}
/// <summary>One entry per code and nest; empty when <see cref="Unavailable"/>.</summary>
public IReadOnlyList<NestStockCodeUse> Uses { get; }
/// <summary>The distinct codes of <see cref="Uses"/>.</summary>
public IReadOnlySet<string> Codes { get; }
/// <summary>Why the codes could not be read; null when they were.</summary>
public string? Unavailable { get; }
public bool IsKnown => Unavailable == null;
public static LedgerCodesInUse Known(IEnumerable<NestStockCodeUse> uses) =>
new((uses ?? throw new ArgumentNullException(nameof(uses))).Where(use => use != null).ToList(), null);
public static LedgerCodesInUse Unknown(string reason) =>
new(Array.Empty<NestStockCodeUse>(), string.IsNullOrWhiteSpace(reason) ? "not available" : reason);
/// <summary>The names of the other nests using <paramref name="code"/>, in name order.</summary>
public IReadOnlyList<string> NestsUsing(string code) =>
Uses.Where(use => string.Equals(use.ItemCode, code, StringComparison.Ordinal))
.Select(use => use.NestName)
.Distinct(StringComparer.Ordinal)
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
/// <summary>
/// Reads the codes from <paramref name="repository"/>, leaving out the nest
/// <paramref name="excludeNestId"/> (the one being edited). A null repository is File
/// mode. Only the caller's own cancellation throws.
/// </summary>
public static async Task<LedgerCodesInUse> ReadAsync(INestRepository? repository, Guid excludeNestId,
CancellationToken cancellationToken, TimeSpan? timeout = null)
{
if (repository == null)
return Unknown("nests are saved as files");
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout ?? RequestTimeout);
try
{
return Known(await repository.GetStockCodesInUseAsync(excludeNestId, timeoutSource.Token));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Unknown("the nest server did not answer");
}
catch (NotSupportedException)
{
return Unknown("the nest server needs an update");
}
catch (Exception ex) when (ex is HttpRequestException or IOException or System.Text.Json.JsonException
or ArgumentException or InvalidOperationException)
{
return Unknown("the nest server could not be reached");
}
}
}
@@ -50,7 +50,7 @@ public sealed class LedgerHoldSummary
public int OpenHolds { get; }
/// <summary>Holds in any other status: released, closed, cancelled, exception or unknown.</summary>
/// <summary>Holds in any other status: released, closed, canceled, exception or unknown.</summary>
public int ClosedHolds { get; }
/// <summary>Sheets without an open hold.</summary>
+2 -2
View File
@@ -26,13 +26,13 @@ public static class LedgerHoldStatuses
public const string AwaitingCloseout = "AwaitingCloseout";
public const string ClosedConsumed = "ClosedConsumed";
public const string ClosedReturned = "ClosedReturned";
public const string Cancelled = "Cancelled";
public const string Canceled = "Cancelled";
public const string Exception = "Exception";
/// <summary>
/// True for holds that still keep a sheet for the nest and need closing out: the statuses ML
/// counts against free stock. Released means the hold was let go, not sent to cutting, so it
/// is not open; nor are closed, cancelled, exception and unknown statuses.
/// is not open; nor are closed, canceled, exception and unknown statuses.
/// </summary>
public static bool IsOpen(string? status) =>
string.Equals(status, Proposed, StringComparison.OrdinalIgnoreCase)
@@ -2,7 +2,7 @@ namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// Gives the plates Auto Nest committed on ledger stock their item codes. Runs only after the
/// proposal was validated, kept and committed, so a rejected or cancelled one never changes
/// proposal was validated, kept and committed, so a rejected or canceled one never changes
/// codes. Codes are hand-off data for MaterialLedger holds, not inventory accounting.
/// </summary>
public static class LedgerStockAssignment
@@ -51,7 +51,12 @@ public sealed record LedgerStockMapping(
public int Drops => Rows.Where(r => r.IsDrop).Sum(r => r.Quantity);
public int SubstituteRows => Rows.Count(r => r.IsSubstitute);
/// <summary>E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)".</summary>
/// <summary>Sheets and drops left out because other open nests are coded with them (<see cref="LedgerCodeFilter"/>).</summary>
public int OnOtherNests { get; init; }
/// <summary>
/// E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (3 on other nests) (2 skipped: 2 unknown shape)".
/// </summary>
public string Summary
{
get
@@ -64,6 +69,8 @@ public sealed record LedgerStockMapping(
var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}";
if (SubstituteRows > 0)
text += $", {Plural(SubstituteRows, "substitute row")}";
if (OnOtherNests > 0)
text += $" ({OnOtherNests} on other nests)";
return skipped.Count == 0
? text
: $"{text} ({SkippedUnknownShape + SkippedTooSmall} skipped: {string.Join(", ", skipped)})";
+4 -53
View File
@@ -106,7 +106,7 @@ public sealed class NestDefaults
{
json = File.ReadAllText(path);
}
catch (Exception ex) when (IsUnreadableFile(ex))
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
{
// A file that exists but cannot be read (locked, access-denied)
// must never block creating a nest.
@@ -129,7 +129,7 @@ public sealed class NestDefaults
{
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json ?? "", JsonOptions);
}
catch (Exception ex) when (IsUnreadableFile(ex))
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
{
// Defaults that cannot be parsed must never block creating a nest.
status = NestDefaultsStatus.Invalid;
@@ -144,7 +144,7 @@ public sealed class NestDefaults
status = NestDefaultsStatus.Ok;
if (TryParseUnits(dto.Units, out var units))
if (SettingsFile.TryParseUnits(dto.Units, out var units))
defaults.Units = units;
if (
@@ -261,24 +261,7 @@ public sealed class NestDefaults
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var json = ToJson();
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
SettingsFile.Write(path, ToJson(), maxRetries);
}
/// <summary>The defaults as <see cref="Save"/> writes them, for the shared copy.</summary>
@@ -304,38 +287,6 @@ public sealed class NestDefaults
return JsonSerializer.Serialize(dto, JsonOptions);
}
/// <summary>
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse
/// would also accept numeric strings ("7") and comma-joined names,
/// producing undefined or unintended values.
/// </summary>
private static bool TryParseUnits(JsonElement? element, out Units units)
{
units = default;
if (element is not { ValueKind: JsonValueKind.String } value)
return false;
var text = value.GetString();
foreach (var candidate in Enum.GetValues<Units>())
{
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
{
units = candidate;
return true;
}
}
return false;
}
private static bool IsUnreadableFile(Exception ex) =>
ex
is JsonException
or IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException;
private static bool IsValidSize(double width, double length) =>
!double.IsNaN(width)
&& !double.IsNaN(length)
+1 -1
View File
@@ -49,7 +49,7 @@ public sealed class NestDetailsSession : IDisposable
public async Task<bool> LoadAsync(Guid id)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a load that completes synchronously when cancelled
// Supersede before canceling: a load that completes synchronously when canceled
// must already see itself as superseded. Each source is released by its own load.
var cancellation = Supersede();
var generation = _generation;
+18
View File
@@ -0,0 +1,18 @@
namespace OpenNest.Data;
/// <summary>
/// A ledger item code an open server nest's plates are coded with, returned by
/// <c>GET /api/nests/stock-item-codes</c>. One entry per code and nest; a bundle code used
/// for several sheets appears once.
/// </summary>
public sealed class NestStockCodeUse
{
public string ItemCode { get; init; } = "";
public Guid NestId { get; init; }
public string NestName { get; init; } = "";
/// <summary>Draft, Quote or To Be Cut: the statuses whose sheets are still to be cut.</summary>
public NestStatus Status { get; init; }
}
+4 -30
View File
@@ -51,20 +51,9 @@ public sealed class NestStorageSettings
public static NestStorageSettings Load(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return new();
try
{
var settings = JsonSerializer.Deserialize<NestStorageSettings>(
File.ReadAllText(path), JsonOptions) ?? new();
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
return settings;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return new();
}
var settings = SettingsFile.Read<NestStorageSettings>(path, JsonOptions) ?? new();
settings.ServerUrl = NormalizeUrl(settings.ServerUrl);
return settings;
}
/// <summary>Writes camelCase JSON, creating the parent directory and retrying IO collisions.</summary>
@@ -73,22 +62,7 @@ public sealed class NestStorageSettings
var json = JsonSerializer.Serialize(
new NestStorageSettings { Mode = Mode, ServerUrl = NormalizeUrl(ServerUrl) },
JsonOptions);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
for (var attempt = 0; attempt < 3; attempt++)
{
try
{
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < 2)
{
Thread.Sleep(100);
}
}
SettingsFile.Write(path, json);
}
private static string NormalizeUrl(string? url) => (url ?? "").Trim().TrimEnd('/');
+1 -1
View File
@@ -10,6 +10,6 @@
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Defaults\CL-980.json" />
<EmbeddedResource Include="Defaults\Generic-Laser.json" />
</ItemGroup>
</Project>
+13
View File
@@ -151,6 +151,19 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return allocation.Number;
}
public async Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
CancellationToken cancellationToken = default)
{
var query = "api/nests/stock-item-codes" + (excludeNestId == Guid.Empty ? "" : "?exclude=" + excludeNestId.ToString("D"));
using var response = await _httpClient.GetAsync(Url(query), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotSupportedException("This nest server does not list stock item codes (HTTP 404). Update the nest server.");
await NestServerHttp.EnsureSuccessAsync(response, "list stock item codes", cancellationToken);
var uses = await response.Content.ReadFromJsonAsync<List<NestStockCodeUse>>(NestServerHttp.JsonOptions, cancellationToken);
return uses?.Where(use => use != null && !string.IsNullOrWhiteSpace(use.ItemCode)).ToList()
?? new List<NestStockCodeUse>();
}
public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
+1 -1
View File
@@ -148,7 +148,7 @@ public sealed class SetReleaseResult
/// matches its record, units the new nest does not use, an insert refusal) creates
/// nothing, and so does a server too old to record drawings released on their own, which is
/// checked first. Uploads then run in order; the first failure stops them, and cancellation is
/// honoured between uploads, never during one. Nests already uploaded stay as ordinary
/// honored between uploads, never during one. Nests already uploaded stay as ordinary
/// nests and are listed in the result; the release is posted only when every chosen group
/// was created. Nothing is rolled back.
/// </para>
+81
View File
@@ -0,0 +1,81 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>File handling shared by the JSON settings files under %APPDATA%\OpenNest.</summary>
internal static class SettingsFile
{
/// <summary>
/// Writes the file, creating the parent directory, and retries briefly on IO collisions.
/// The last failure propagates; the host handles a persistent write failure.
/// </summary>
public static void Write(string path, string json, int maxRetries = 3)
{
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
}
/// <summary>
/// Deserializes the file, or returns null when it is missing, unreadable or corrupt.
/// </summary>
public static T? Read<T>(string path, JsonSerializerOptions options)
where T : class
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return null;
try
{
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), options);
}
catch (Exception ex) when (IsUnreadableFile(ex))
{
return null;
}
}
public static bool IsUnreadableFile(Exception ex) =>
ex
is JsonException
or IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException;
/// <summary>
/// Accepts only a defined unit name (case-insensitive). Enum.TryParse would also accept
/// numeric strings ("7") and comma-joined names, producing undefined or unintended values.
/// </summary>
public static bool TryParseUnits(JsonElement? element, out Units units)
{
units = default;
if (element is not { ValueKind: JsonValueKind.String } value)
return false;
var text = value.GetString();
foreach (var candidate in Enum.GetValues<Units>())
{
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
{
units = candidate;
return true;
}
}
return false;
}
}
+15
View File
@@ -5,10 +5,25 @@ namespace OpenNest.Data;
public class ThicknessConfig
{
public double Value { get; set; }
/// <summary>Optional display name for the thickness, such as a gauge ("16 ga").</summary>
public string Name { get; set; } = "";
public double Kerf { get; set; }
public string AssistGas { get; set; } = "";
/// <summary>The lead-in of outside cuts (part perimeters), and of inside cuts while <see cref="InsideLeadIn"/> is null.</summary>
public LeadConfig LeadIn { get; set; } = new();
/// <summary>The lead-out of outside cuts, and of inside cuts while <see cref="InsideLeadOut"/> is null.</summary>
public LeadConfig LeadOut { get; set; } = new();
/// <summary>The lead-in of inside cuts (cutouts and holes); null uses <see cref="LeadIn"/>.</summary>
public LeadConfig? InsideLeadIn { get; set; }
/// <summary>The lead-out of inside cuts; null uses <see cref="LeadOut"/>.</summary>
public LeadConfig? InsideLeadOut { get; set; }
public CutOffConfig CutOff { get; set; } = new();
public List<string> PlateSizes { get; set; } = new();
}
@@ -0,0 +1,93 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Desktop.Tests;
public class AutoNestJobTests
{
private static Drawing Square(string name, double side, int required)
{
var program = new Program();
program.Codes.Add(new RapidMove(new Vector(0, 0)));
program.Codes.Add(new LinearMove(new Vector(side, 0)));
program.Codes.Add(new LinearMove(new Vector(side, side)));
program.Codes.Add(new LinearMove(new Vector(0, side)));
program.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing(name, program);
drawing.Quantity.Required = required;
return drawing;
}
private static Nest NestWith(params Drawing[] drawings)
{
var nest = new Nest { Name = "job" };
foreach (var drawing in drawings)
nest.Drawings.Add(drawing);
nest.Plates.Add(new Plate(new Size(48, 96)) { Quantity = 1 });
return nest;
}
[Fact]
public void ForRemaining_AsksForWhatIsNotPlacedYet_OnTheTemplateStock()
{
var square = Square("square", 10, 5);
var nest = NestWith(square);
nest.Plates[0].Parts.Add(new Part(square));
var request = AutoNestJob.ForRemaining(nest, nest.Plates[0], "Default");
var item = Assert.Single(request.Items);
Assert.Same(square, item.Drawing);
Assert.Equal(4, item.Quantity);
Assert.Equal("Default", request.EngineName);
Assert.Equal(nest.Plates[0].Size, Assert.Single(request.Stock).Size);
}
[Fact]
public void ForRemaining_SkipsCutOffsAndPlacedDrawings_AndIsNullWhenNothingRemains()
{
var placed = Square("placed", 10, 1);
var cutOff = Square("cut-off", 1, 3);
cutOff.IsCutOff = true;
var nest = NestWith(placed, cutOff);
nest.Plates[0].Parts.Add(new Part(placed));
Assert.Null(AutoNestJob.ForRemaining(nest, nest.Plates[0], "Default"));
}
[Fact]
public void ValidResult_IsCommittedOntoTheEmptyPlate()
{
var square = Square("square", 10, 6);
var nest = NestWith(square);
var request = AutoNestJob.ForRemaining(nest, nest.Plates[0], "Default");
var result = NestPipeline.Run(request);
var kept = AutoNestJob.Keep(nest, result);
Assert.True(kept.Nested, kept.Note);
Assert.Null(AutoNestJob.RefusalReason(result));
var plate = Assert.Single(nest.Plates);
Assert.Equal(6, plate.Parts.Count);
Assert.Equal(0, square.Quantity.Remaining);
}
[Fact]
public void ResultPlacingNothing_LeavesTheNestUnchanged()
{
var tooBig = Square("too big", 200, 1);
var nest = NestWith(tooBig);
var request = AutoNestJob.ForRemaining(nest, nest.Plates[0], "Default");
var result = NestPipeline.Run(request);
var kept = AutoNestJob.Keep(nest, result);
Assert.False(kept.Nested);
var reason = AutoNestJob.RefusalReason(result);
Assert.StartsWith("Auto Nest placed nothing; nothing was changed. Not placed: ", reason);
Assert.Contains("too big", reason);
Assert.Empty(Assert.Single(nest.Plates).Parts);
Assert.Equal(1, tooBig.Quantity.Remaining);
}
}
@@ -0,0 +1,81 @@
using System.Reflection;
using System.Text.Json;
using System.Xml.Linq;
namespace OpenNest.Desktop.Tests;
/// <summary>
/// OpenNest.Desktop is shared by the WinForms and Avalonia front ends, so it must stay free of
/// any UI toolkit: no WinForms/WindowsDesktop framework, System.Drawing.Common or Avalonia.
/// </summary>
public class DesktopDependencyTests
{
private static readonly string[] ForbiddenPrefixes =
[
"System.Windows.Forms",
"Microsoft.WindowsDesktop",
"WindowsBase",
"PresentationCore",
"PresentationFramework",
"System.Drawing.Common",
"Avalonia",
];
[Fact]
public void DesktopAssembly_ReferencesNoUiToolkit()
{
var assembly = Assembly.Load(new AssemblyName("OpenNest.Desktop"));
var references = assembly.GetReferencedAssemblies().Select(a => a.Name!);
Assert.Empty(references.Where(IsForbidden));
}
[Fact]
public void DesktopProject_TargetsPlainNet8WithoutUiFrameworks()
{
var project = XDocument.Load(Path.Combine(DesktopProjectDirectory(), "OpenNest.Desktop.csproj"));
var elements = project.Descendants().ToList();
var targets = elements
.Where(e => e.Name.LocalName is "TargetFramework" or "TargetFrameworks")
.SelectMany(e => e.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToList();
Assert.Equal(["net8.0"], targets);
Assert.DoesNotContain(elements, e =>
e.Name.LocalName is "UseWindowsForms" or "UseWPF" or "UseWindowsDesktop"
&& e.Value.Trim().Equals("true", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(elements, e => e.Name.LocalName == "FrameworkReference");
var packages = elements
.Where(e => e.Name.LocalName == "PackageReference")
.Select(e => (string?)e.Attribute("Include") ?? string.Empty);
Assert.Empty(packages.Where(IsForbidden));
}
[Fact]
public void DesktopProject_RestoresNoUiToolkitPackagesTransitively()
{
var assetsPath = Path.Combine(DesktopProjectDirectory(), "obj", "project.assets.json");
Assert.True(File.Exists(assetsPath), $"Restore output not found: {assetsPath}");
using var assets = JsonDocument.Parse(File.ReadAllText(assetsPath));
var libraries = assets.RootElement.GetProperty("libraries").EnumerateObject()
.Select(library => library.Name.Split('/')[0]);
Assert.Empty(libraries.Where(IsForbidden));
}
private static bool IsForbidden(string name) =>
ForbiddenPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
private static string DesktopProjectDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "OpenNest.sln")))
directory = directory.Parent;
Assert.NotNull(directory);
return Path.Combine(directory.FullName, "OpenNest.Desktop");
}
}
+54
View File
@@ -0,0 +1,54 @@
using OpenNest.IO;
using OpenNest.Undo;
namespace OpenNest.Desktop.Tests;
public sealed class DocumentTests : IDisposable
{
private readonly string directory =
Directory.CreateTempSubdirectory("opennest-desktop-document-").FullName;
public void Dispose() => Directory.Delete(directory, recursive: true);
[Fact]
public void SaveAs_WritesAReadableNestNamedAfterTheFile()
{
var document = new Document { Nest = TestNests.OneSquare() };
var path = Path.Combine(directory, "Renamed Job.nest");
document.SaveAs(path);
Assert.Equal("Renamed Job", document.Name);
Assert.Equal(path, document.LastSavePath);
Assert.True(document.HasSavePath);
var read = new NestReader(path).Read();
Assert.Equal("Renamed Job", read.Name);
Assert.Single(read.Plates);
Assert.Single(read.Plates[0].Parts);
}
[Fact]
public void SaveAs_MarksTheCurrentHistoryStateSaved()
{
var document = new Document { Nest = TestNests.OneSquare() };
document.History.Reset(new NestUndoState(NestSnapshot.Capture(document.Nest)));
document.Nest.Plates[0].Parts[0].Offset(1, 0);
document.History.Commit("Move", new NestUndoState(NestSnapshot.Capture(document.Nest)));
Assert.False(document.History.IsAtSavePoint);
document.SaveAs(Path.Combine(directory, "saved.nest"));
Assert.True(document.History.IsAtSavePoint);
}
[Fact]
public void Serialize_RoundTripsThroughTheNestReader()
{
var document = new Document { Nest = TestNests.OneSquare() };
using var stream = new MemoryStream(document.Serialize());
var read = new NestReader(stream).Read();
Assert.Equal(document.Nest.Plates[0].Parts.Count, read.Plates[0].Parts.Count);
}
}
@@ -0,0 +1,28 @@
using OpenNest.Forms;
namespace OpenNest.Desktop.Tests.Forms;
/// <summary>
/// Only the pure strategy mapping is covered here: setting EngineName or loading the saved
/// selection reads and writes the real per-user engine-selection.json.
/// </summary>
public class EngineSelectionTests
{
[Theory]
[InlineData("Fill")]
[InlineData("Strip")]
[InlineData("vertical remnant")]
[InlineData(" Horizontal Remnant ")]
public void BuiltInPlacementStrategies_AreFillStrategies(string name) =>
Assert.True(EngineSelection.IsFillStrategy(name));
[Theory]
[InlineData("Default")]
[InlineData("Irregular")]
[InlineData("Rectangles")]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void JobsOnlyEnginesAndBlanks_AreNotFillStrategies(string? name) =>
Assert.False(EngineSelection.IsFillStrategy(name!));
}
@@ -0,0 +1,100 @@
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Forms;
namespace OpenNest.Desktop.Tests.Forms;
public class JobEngineNestTests
{
private sealed class Sink<T> : IProgress<T>
{
public List<T> Reports { get; } = [];
public void Report(T value) => Reports.Add(value);
}
[Fact]
public void EvaluatingReport_BecomesAOneBasedPlateDescription()
{
var progress = new Sink<NestProgress>();
JobEngineNest.CreateProgress("Irregular", progress)
.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "48x96", 2, 2, 30));
var report = Assert.Single(progress.Reports);
Assert.Equal(NestPhase.Custom, report.Phase);
Assert.Equal(3, report.PlateNumber);
Assert.Equal("Irregular: evaluating plate 3 on stock 48x96", report.Description);
}
[Fact]
public void UnknownPlateIndex_CountsFromTheCommittedPlates()
{
var progress = new Sink<NestProgress>();
JobEngineNest.CreateProgress("Fill", progress)
.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "s", -1, 4, 0));
Assert.Equal(5, Assert.Single(progress.Reports).PlateNumber);
}
[Fact]
public void CommittedReport_NamesTheCommittedCounts()
{
var progress = new Sink<NestProgress>();
JobEngineNest.CreateProgress("Fill", progress)
.Report(new NestJobProgress(NestJobStage.PlateCommitted, "s", 0, 2, 17));
var report = Assert.Single(progress.Reports);
Assert.Equal(2, report.PlateNumber);
Assert.Equal("Fill: committed plate 2 (17 parts placed)", report.Description);
}
[Fact]
public void LegacyProgress_PassesThroughUnchanged()
{
var progress = new Sink<NestProgress>();
var legacy = new NestProgress { Phase = NestPhase.Linear, PlateNumber = 9 };
JobEngineNest.CreateProgress("Fill", progress)
.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "s", 0, 0, 0, legacy));
Assert.Same(legacy, Assert.Single(progress.Reports));
}
[Fact]
public void ImprovedPlan_GoesToThePlanSinkOnly()
{
var progress = new Sink<NestProgress>();
var plans = new Sink<IReadOnlyList<ProposedPlate>>();
IReadOnlyList<ProposedPlate> sheets = [];
JobEngineNest.CreateProgress("Irregular", progress, plans)
.Report(new NestJobProgress(NestJobStage.PlanImproved, "s", 0, 0, 0) { PlanSheets = sheets });
Assert.Empty(progress.Reports);
Assert.Same(sheets, Assert.Single(plans.Reports));
}
[Fact]
public void ImprovedPlan_WithoutAPlanSink_IsDescribed()
{
var progress = new Sink<NestProgress>();
JobEngineNest.CreateProgress("Irregular", progress)
.Report(new NestJobProgress(NestJobStage.PlanImproved, "s", 0, 0, 0));
Assert.Equal("Irregular: found a better plan (0 sheets)", Assert.Single(progress.Reports).Description);
}
[Fact]
public void NullReport_IsIgnored()
{
var progress = new Sink<NestProgress>();
JobEngineNest.CreateProgress("Fill", progress).Report(null!);
Assert.Empty(progress.Reports);
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Desktop/OpenNest.Desktop.csproj" />
</ItemGroup>
</Project>
+29
View File
@@ -0,0 +1,29 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Desktop.Tests;
internal static class TestNests
{
/// <summary>A nest with one 48x96 plate holding one 10x10 square part.</summary>
public static Nest OneSquare()
{
var program = new Program();
program.Codes.Add(new RapidMove(new Vector(0, 0)));
program.Codes.Add(new LinearMove(new Vector(10, 0)));
program.Codes.Add(new LinearMove(new Vector(10, 10)));
program.Codes.Add(new LinearMove(new Vector(0, 10)));
program.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("square", program);
drawing.Quantity.Required = 1;
var plate = new Plate(new Size(48, 96));
plate.Parts.Add(new Part(drawing));
var nest = new Nest { Name = "test" };
nest.Drawings.Add(drawing);
nest.Plates.Add(plate);
return nest;
}
}
@@ -0,0 +1,75 @@
using OpenNest.Undo;
namespace OpenNest.Desktop.Tests.Undo;
public class ChangeScopeTests
{
[Fact]
public void RootScope_EndsOnceAndCarriesChangesMergedFromNestedScopes()
{
var ends = new List<ChangeScope>();
var root = ChangeScope.Root("Rotate", "key", ends.Add);
using (var nested = ChangeScope.Nested(root))
{
nested.MarkChanged();
nested.Complete();
Assert.Equal("Rotate", nested.Label);
Assert.Null(nested.CoalesceKey);
}
Assert.Empty(ends);
root.Complete();
root.Dispose();
root.Dispose();
var ended = Assert.Single(ends);
Assert.Same(root, ended);
Assert.True(ended.IsChanged);
Assert.True(ended.Succeeded);
Assert.Equal("key", ended.CoalesceKey);
}
[Fact]
public void IncompleteNestedScope_FailsTheWholeEntry()
{
ChangeScope ended = null!;
var root = ChangeScope.Root("Fill", null, scope => ended = scope);
using (ChangeScope.Nested(root))
{
// Left incomplete, as when the nested edit throws or is canceled.
}
root.MarkChanged();
root.Complete();
root.Dispose();
Assert.True(ended.IsChanged);
Assert.False(ended.Succeeded);
}
[Fact]
public void RootScope_DisposedWithoutComplete_DoesNotSucceed()
{
ChangeScope ended = null!;
using (var root = ChangeScope.Root("Move", null, scope => ended = scope))
root.MarkChanged();
Assert.False(ended.Succeeded);
}
[Fact]
public void DetachedScope_RecordsNothingAndToleratesDispose()
{
var scope = ChangeScope.Detached();
Assert.False(scope.IsChanged);
scope.MarkChanged();
scope.Complete();
scope.Dispose();
Assert.True(scope.IsChanged);
Assert.Null(scope.Label);
}
}
@@ -0,0 +1,36 @@
using OpenNest.Undo;
namespace OpenNest.Desktop.Tests.Undo;
public class NestFingerprintTests
{
[Fact]
public void UnchangedNest_KeepsItsFingerprint()
{
var nest = TestNests.OneSquare();
Assert.Equal(NestFingerprint.Compute(nest), NestFingerprint.Compute(nest));
}
[Fact]
public void MovingAPart_ChangesTheFingerprint()
{
var nest = TestNests.OneSquare();
var before = NestFingerprint.Compute(nest);
nest.Plates[0].Parts[0].Offset(1, 0);
Assert.NotEqual(before, NestFingerprint.Compute(nest));
}
[Fact]
public void ChangingARequiredQuantity_ChangesTheFingerprint()
{
var nest = TestNests.OneSquare();
var before = NestFingerprint.Compute(nest);
nest.Drawings.First().Quantity.Required = 5;
Assert.NotEqual(before, NestFingerprint.Compute(nest));
}
}
+82
View File
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
namespace OpenNest.Desktop
{
/// <summary>
/// Auto Nest with the desktop dialog's defaults and no dialog: every drawing's remaining
/// quantity, with its priority and rotation constraints, on sheets like a template plate,
/// through <see cref="NestPipeline"/>. Only a valid, keepable result that places something
/// is committed, onto the nest's empty plates first and then new plates from its defaults.
/// </summary>
public static class AutoNestJob
{
/// <summary>
/// The job for <paramref name="nest"/>'s remaining quantities on stock like
/// <paramref name="template"/>; null when nothing remains. The nest is not changed, so
/// the caller can solve it off its thread.
/// </summary>
public static NestPipelineRequest ForRemaining(Nest nest, Plate template, string engineName)
{
ArgumentNullException.ThrowIfNull(nest);
ArgumentNullException.ThrowIfNull(template);
var items = nest.Drawings
.Where(d => !d.IsCutOff && d.Quantity.Remaining > 0)
.Select(d => new NestItem
{
Drawing = d,
Quantity = d.Quantity.Remaining,
Priority = d.Priority,
RotationStart = d.Constraints.StartAngle,
RotationEnd = d.Constraints.EndAngle,
StepAngle = d.Constraints.StepAngle,
})
.ToList();
if (items.Count == 0)
return null;
return new NestPipelineRequest(engineName, items,
NestStockBuilder.FromTemplate(template, null),
new NestJobOptions(maxPlates: NestRenest.MaxPlates, salvageRate: 0,
minimumSalvageDimension: nest.MinimumRemnantDimension));
}
/// <summary>
/// Commits <paramref name="result"/> to <paramref name="nest"/> on the nest's own thread
/// when it is valid, keepable and places something; otherwise leaves the nest unchanged
/// and says why in the note.
/// </summary>
public static NestRenestResult Keep(Nest nest, NestPipelineResult result)
{
ArgumentNullException.ThrowIfNull(nest);
return NestRenest.Keep(result,
valid => NestPipelineCommit.ApplyToPlates(valid, () => EmptyOrNewPlate(nest)));
}
private static Plate EmptyOrNewPlate(Nest nest) =>
nest.Plates.FirstOrDefault(p => p.Parts.Count == 0) ?? nest.CreatePlate();
/// <summary>
/// Why <see cref="Keep"/> would commit nothing for <paramref name="result"/>, naming the
/// first problem; null when it would commit.
/// </summary>
public static string RefusalReason(NestPipelineResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (!result.IsValid)
return $"Auto Nest found {result.Violations.Count} layout problem"
+ $"{(result.Violations.Count == 1 ? "" : "s")} (first: {result.Violations[0]}); nothing was changed.";
if (!result.CanKeep)
return "Auto Nest's layout cannot be kept faithfully; nothing was changed.";
if (result.Plates.All(p => p.Parts.Count == 0))
return "Auto Nest placed nothing; nothing was changed."
+ (result.Unplaced.Count > 0
? " Not placed: " + string.Join("; ", result.Unplaced.Select(u => u.Describe())) + "."
: "");
return null;
}
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Toolkit-neutral desktop logic shared by the WinForms and Avalonia front ends.
No UI toolkit: OpenNest.Desktop.Tests guards against WinForms, WindowsDesktop,
System.Drawing.Common and Avalonia references. -->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Desktop</RootNamespace>
<AssemblyName>OpenNest.Desktop</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
using System.Runtime.CompilerServices;
// The WinForms app drives undo recording through ChangeScope's internal root/nested factories
// and NestFingerprint; both desktop front ends report Auto Nest progress through the internal
// JobEngineNest.
[assembly: InternalsVisibleTo("OpenNest")]
[assembly: InternalsVisibleTo("OpenNest.Avalonia")]
[assembly: InternalsVisibleTo("OpenNest.Desktop.Tests")]
File renamed without changes.
@@ -8,7 +8,7 @@ namespace OpenNest.Undo
/// cut-offs, and the drawings with their required quantities. Two equal fingerprints do not
/// prove two nests equal; a differing one means the nest changed since it was taken. Objects
/// count by reference, so compare fingerprints of one live nest, never across a restore.
/// Derived values (nested counts, colours, the modified date) are left out.
/// Derived values (nested counts, colors, the modified date) are left out.
/// </summary>
internal static class NestFingerprint
{
Loaded 100 of 482 files, more files were not shown because too many files have changed in this diff. Show more