883 Commits
Author SHA1 Message Date
aj bf240ab6df docs(console): describe --plan-cutting with --no-fill and unverified acceptance 2026-10-20 05:26:35 -04:00
aj 3d4ac5ddf7 fix(planning): drop the desktop-only effort advice from console and MCP findings
CuttingPlanProposal.Describe is shown only by the console and MCP, which have
no Tools > Options; the desktop panel keeps its own effort hint.
2026-10-20 05:26:35 -04:00
aj 1ab39c3cd0 docs: correct cut planning effort scope and problem-nest storage steps
Console --plan-cutting and MCP plan_cutting run the planner at the default
effort; only the desktop exposes the setting. Adding a corpus nest must keep
the corpus server selected until the save, and the command is Nest > Edit.
2026-10-20 05:26:35 -04:00
ajandClaude Opus 5.5 56eaf4829a feat(planning): add a Cut planning effort setting that scales the search budget
Tools > Options > Cut planning effort (default 1.0, 0.5-10) multiplies the
desktop cut planner's per-plate expansion budget for both the free-order
search and the current-order retry. The budget counts expansions, not time,
so the setting is how faster hardware can be spent on hard plates.

The panel reads the setting on the UI thread and passes it to
CuttingPlanBatch.Capture; the worker never reads settings. The 1,000-
expansion first pass stays fixed: it is a cheap probe for easy plates, so a
higher effort only adds work where the full search runs. A plate refused
at the search limit now says the effort can be raised, without promising a
plan. The default keeps today's budgets and characterization digests.

Console, MCP and API do not run the cut planner, so they get no option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:54:28 -04:00
ajandClaude Opus 5.5 a10e204268 fix(ui): lock plate edits while a fill is still running
Stopping a fill only requests cancellation; its worker keeps reading
Plate.Parts until it returns. The progress window is modeless, so a
click or key on the plate view in that window (clone placement, Delete,
arrow nudges, drops) changed the part list mid-enumeration and threw
"Collection was modified" in PlateFillService.TryAddClear.

PlateView now refuses editing input while IsFillInProgress, the same
way it does during an inspection. Panning and zooming stay live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:54:28 -04:00
ajandClaude Opus 5.5 67daeafc98 test(planning): add opt-in problem-nest regression corpus check
Plans every nest on a dedicated OpenNest server (OPENNEST_PROBLEM_NESTS_SERVER)
plate by plate, with a per-plate time limit, and fails when a plate ranks
below the "planning: 1=... 2=..." baseline in the nest's comments. Customer
nests and baselines stay on the server, never in the repository. Refuses a
server holding more than 200 nests, so a production archive is not planned
by mistake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:54:28 -04:00
ajandClaude Opus 5.5 391a27b2f9 feat(console,mcp): add cut planning to the headless front ends
Console --plan-cutting and MCP plan_cutting/apply_cutting_plan run the
desktop's CuttingPlanBatch: capture, plan, then an all-or-nothing apply.
Settings come from an explicit JSON file or the plates' identical saved
settings, never defaults. Unverified plans need per-call acceptance,
which is not posting consent. Console --no-fill plans a loaded nest as
is; a blocked plan exits 2 and saves nothing. Scrap cut-offs stay
desktop-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:54:28 -04:00
ajandClaude Opus 5.5 cf32d1cab5 fix(cutting): scale the reorder stall with the blocked part, not the plate's largest
An attempt along a part order stopped backtracking after 8 x entries x
contours expansions without progress, with contours taken as the most of
any part on the plate. One many-holed part therefore let every blocked
part, even a two-contour tab, replay its predecessors' entries for the
whole of that allowance; a plate whose rounds each stalled that long
ran out of budget before the recovery pass that would have finished it.

Along an order the stall now uses the larger contour count of the blocked
part and of the part cut just before it, whose departure a retry changes,
capped by the old limit. The sequence-free recovery pass keeps the plate
maximum. Planning characterization digests are unchanged.

The optional private-fixture test now also runs RecoveryCuttingNestPath
with default lead-ins, and requires that file's plates to plan within
60% of their budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:54:28 -04:00
ajandClaude Opus 5.5 04a6e0eb50 fix(engine): let contour recovery plan scribed and boxed-in holed parts
The cut planner's directed-contour recovery pass gave up on plates the
full search could solve, so they ended "no plan found within the search
limit":

- Each hole was checked as an isolated probe that repeats the part's
  scribes, so every hole-to-hole hop was checked as a detour through
  the scribe start. Scribes and other open cuts are now checked once
  from the arrival, and each hole as a direct hop from the one before.
- Hole entries were ranked only by pierce distance, so the departure
  often ran back across the hole just cut. The nearest hole is still
  tried first, but its entries that leave toward the holes still to
  cut come first.
- The first-ranked outside entry could spend the whole recovery
  allowance. Each outside entry now gets a share of what is left.

The optional private-fixture test now takes its config key, adding
RecoveryCuttingNestPath; it fails on the old search and passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:54:27 -04:00
ajandClaude Opus 5.5 a0713289fa feat(planning): tour large aligned grids as a serpentine
A plate of at least 200 parts with no cutoffs or prerequisites, whose
part centres bin into rows and columns within a quarter of the smallest
part extent, is toured line by line in alternating directions. Each row
and column gap must be wider than that tolerance, each cell may hold
only one part, and at least a quarter of the cells must be filled
(interleaved rows fill half). The route runs along whichever axis, from
whichever corner, is shortest from the start point.

This replaces the travelling-salesman 2-opt/Or-opt passes, which took
about 8 s on 5,742 parts. It is still only a proposal: the joint search
checks every rapid and lead, and replans after a learned rule use the
general order. Smaller plates, and the characterization fixtures, keep
their order unchanged.

The washer nest's plate 1 now plans Ready in about 44 s (was 54 s), with
86k expansions against 91k.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:16:03 -04:00
ajandClaude Opus 5.5 94898225e8 perf(planning): skip lead checks against material no native query can reach
A lead check scanned every other part's material with native contact
queries. On the 5,742-washer plate that was about 80% of planning and
it never finished inside 120 s.

The earlier extent filter (reverted in ea1270e1) used a fixed 0.001 gap,
which native line/circle rounding can exceed for long leads beside small
circles. This skip adds 1e-6 times the span of both extents. The
discriminant's rounding is bounded by about 1e-7 times that span, and
about 2e-9 is seen. So a skipped material has no native contact, and the
900000-long lead beside a small circle is still checked. Each skipped
scan is charged to the native query budget as if it ran. Ray parity is
still asked natively unless the lead's midpoint is outside the
material's Y range. The pierce zone skips only material farther than its
radius by the same gap.

A randomized test checks that skipped material never has a native
contact. It fails with the span allowance at zero and passes with it as
low as 1e-8. Plate 1 of the washer nest now plans Ready in about 54 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:16:03 -04:00
ajandClaude Opus 5.5 40e4e559dd fix(planning): rank hole entries by a clear way out to the next pierce
With no lead-out the tool leaves a hole where it entered it. A tiny corner
arc's midpoint lies on both facing sides of its hole, so the ranker scored it
as an ideal two-side corner, but the tool leaves it along its 45-degree
tangent: a shallow rapid to a nearby next hole re-entered the corner and
crossed the hole just cut. Every outside endpoint's preferred hole chain
then failed the same way, and the reorder search spent its budget on full
backtracking. A 31-part plate of such parts needed 31,959 expansions
against a default budget of 20,000.

Hole entries whose straight rapid to the next pierce clears the hole itself
now rank first, in the preferred chain and in the full search. This is a
stable reorder only: no entry is dropped, and every emitted rapid is still
checked. That plate now plans Ready on its first preferred pass, using 7,781
expansions; the budget is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:16:03 -04:00
ajandClaude Opus 5.5 b9c5240a43 fix(planning): close incremental rounding drift at contour seams
PEP-converted incremental programs round every move, so a long contour
can end 1-1.4e-6 off its start. Material capture refused it as open
("blocked: unsupported geometry"), and a gap right at the 1e-6 boundary
could pass material capture yet fail preparation.

On the planner's owned clean copy only, close incremental contours whose
gap exceeds 1e-8 but stays within what their rounded moves could
accumulate (capped at Tolerance.Epsilon); the next motion absorbs the
correction so nothing later moves. Absolute programs, larger gaps and
the drawing itself are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:16:02 -04:00
ajandClaude Opus 5.5 13d75854c3 fix(console): give the DXF-only plate a quantity of 1
LoadOrCreateNest built the DXF-only plate with `new Plate`, leaving
Quantity at 0, so results reported "Parts placed: 0" and 0% weighted
utilization, and drawings' nested quantities stayed at zero. Build it
through Nest.CreatePlate as the desktop does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:48:51 -04:00
aj 61ad85768d test(api): drop property-echo tests and pin AllowRotation behaviourally
Remove three tests that only read back values they had just set. Fold the
quantity-weighted utilization check into the mixed-sheet test, and replace
the locked-rotation constraint read-back with a run where a bar fits only
rotated: placed when rotation is allowed, unplaced when it is not.
2026-10-19 13:15:37 -04:00
aj 274651b810 feat(desktop): add Nest > Clear Nest
Clear Nest removes every plate after the first and empties the remaining
plate (parts, cut-offs, repeat quantity), keeping nest metadata and the
first plate's configuration. It asks first, defaulting to No, and refuses
while nesting, a fill, a busy plate action or a database save is running.
The menu item is enabled only when there is something to clear.
2026-10-19 13:12:25 -04:00
aj 1ed66bbf20 fix(ui): point operator messages at commands that exist
Pinned-lead and locked-lead warnings named a Drawing Pins Off command and a
Clear Lead-ins command that the desktop does not have; name Plate > Remove
Lead-ins and spacing instead. Closing Plan Cutting no longer claims nothing
changed after lead-ins or cut-offs were cleared, the no-stock reason no
longer says the part is larger than every sheet when only the usable area at
the checked rotations is too small, and the console says whole-job engines.
2026-10-19 12:15:40 -04:00
aj b9092a5142 fix(scripts): point Deploy-OpenNest at Tools > Options 2026-10-19 12:11:13 -04:00
aj 98a046a112 docs: correct user-facing docs for the next release
Remove a private part reference from the lead-in notes, fix the Options and
New menu paths, say that release ZIPs never auto-update, describe current
Cincinnati CL ordering, clamp validation coverage and empty-plate saving,
list every built-in engine, and drop internal decision labels.
2026-10-19 12:11:13 -04:00
aj b97f67af41 ci(release): smoke-check the Circles and FlatBar engines in the package 2026-10-19 12:07:46 -04:00
ajandClaude Opus 5.5 5620c9b842 fix(ui): validate Nest Info and Edit Plate on the UI thread
Both dialogs revalidated with a System.Timers.Timer whose SynchronizingObject
was the form. Before the dialog has a handle, InvokeRequired is false, so the
Elapsed handler ran EnableCheck on a pool thread. When the UI thread was busy
(for example with the cut simulator open), the timer fired before ShowDialog
and touched applyButton while the UI thread was creating it, throwing a
cross-thread InvalidOperationException. Use a WinForms timer, which always
ticks on the UI thread, and stop starting it from the constructor, which
already calls EnableCheck directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:05:14 -04:00
ajandClaude Opus 5.5 be8d1443e3 feat(ui): replace status bar plate labels with nest material
The status bar's "Plate N of M" counted the trailing empty plate, and its
plate number and size repeated what the plate view header already shows.
Drop Plate, Size and Qty from the status bar. Show the plate quantity in the
header as "×N" when it is above one, and keep Util in the status bar so it
stays visible while the Drawings tab is open.

Add a Material label (name and thickness) that opens Nest Info when
clicked. Nest Info remains the only place to edit material, and the label
refreshes after the dialog closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:53:46 -04:00
ajandClaude Opus 5.5 3d1270a860 fix(ui): scale part label search precision to material width
The plate view searched for each part's label point with PolyLabel's fixed
0.5" precision, a third of a 1.5" part, so the search stopped on the first
interior cell and put labels near edges instead of in the thickest spot.
Precision is now 5% of the part's mean material width (2 x net area over
total outline length), so small, slender or heavily cut-out parts refine
further while large solid ones stay cheap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:50:41 -04:00
ajandClaude Opus 5.5 add94dff6c fix(data): leave the trailing empty plate out of nest record plate counts
The editor keeps an empty plate at the end of a nest as its new-plate slot.
Counting it made database records report one plate too many, so the record
now uses the same displayed-plate count as the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:09:57 -04:00
ajandClaude Opus 5.5 e82f26b976 feat(ui): rename nests from Nest Info in Database mode
The nest name box was always read-only, so a nest could only be renamed
by Save As, which leaves the old copy behind. In Database mode Nest Info
now edits the name (uploaded with the next Save), as does the saved-nest
browser's Nest Information (saved immediately). File mode keeps it
read-only because the file name is the nest name. Renaming to a name
another active nest already has asks first, and declining reopens the
dialog with the edits kept; a duplicate accepted there is not asked about
again by the nest's first Save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:09:08 -04:00
ajandClaude Opus 5.5 c7df3f5e23 feat(storage): ask before a first save reuses another nest's name
Nests named offline from the per-PC counter can collide with names already
on the server. Renaming them silently is not an option because the name may
already be on posted CNC programs and labels, so the first Save of a nest to
a server now checks for an active nest with exactly the same name and, only
then, offers a freshly allocated name in an editable dialog (Cancel saves
nothing). A failed or slow check keeps the current name, as before. Save As
and pending-upload reconciliation are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:09:08 -04:00
ajandClaude Opus 5.5 3bc71a7e87 feat(server): allocate nest names from one yearly server sequence
File > New restarted its counter every day while names only carry the
year, so the first nest of each day was always N26-223, and each PC kept
its own counter. In Database mode the nest server now hands out the
year's next number (POST /api/nest-names/next, nest_sequences table,
schema v6), staying above every stored generated name so offline or
pre-existing names are never reissued. The per-PC counter remains the
fallback when the server is unreachable or older, resets per year, and
is raised past each server allocation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:09:07 -04:00
ajandClaude Opus 5.5 d35178c3b5 feat(clamps): edit clamps on the plate view, on any sheet edge
Clamps could only sit on the origin-side edge and be edited as a list in
Nest > Clamps. Setting them up against a real sheet is easier by dragging
them where they are, so Plate > Edit Clamps adds an edit mode: drag a clamp
along its edge or onto another edge, click near an edge to add one, click a
clamp's locating dimension to type its distance from the origin, Delete to
remove. Dimensions measure to the clamp center or its near edge
(remembered per user). Edits replace the nest's rail so every sheet follows,
like the dialog.

Rail clamps now carry a ClampEdge (origin side, far side, origin end, far
end), positioned from the origin along that edge. Origin-side clamps keep
their ids and files; other edges persist in optional per-edge arrays in
.nest files and clamps.json, omitted when empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:09:04 -04:00
ajandClaude Opus 5.5 a1458a355b fix(cutting): route best-effort holes instead of cutting them in source order
The best-effort fallback (used whenever a part has touching or uncertain
material boundaries, which sends the whole plate there) cut each part's holes
in raw source order with the nearest entry. On hole grids that order
doubles back, so the rapids between holes cross; any crossing means a
shorter uncrossed order exists. On A0228-01 plate 1 this cost ~300 in of
rapid travel and drew crossing rapids on PT08 in the simulator.

Tour the holes with CuttingHoleOrder (the nearest-neighbour + 2-opt/Or-opt
proposal the strict search already uses), as an open path from the arrival,
perimeter still last. Hole-centre selection is shared with the strict search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:08:51 -04:00
ajandClaude Opus 5.5 baa5f8481a fix(cutting): plan cut-first parts before every other part
Classic sequencing honored Drawing.CutFirst, but the cutting planner built
its own order from the dependency graph and ignored it, so test coupons
interleaved with the rest of the plate. Each cut-first part is now a
prerequisite of every non-cutoff part, except a part nested in its cutout,
where containment wins so the graph stays acyclic. A kept order that cuts
a part before a coupon reports the cut-first conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:45:47 -04:00
ajandClaude Opus 5.5 9bf7760c1d fix(ui): keep the cut simulator's torch in place when rates change
Changing a rate, the pierce time or the machine rebuilt the timeline and
kept the playhead's old time. That time pointed at a different move on
the retimed timeline, so the torch jumped around while settings were
adjusted. When the new timeline has the same moves, Load now finds the
same move and how far through it the torch was (CutSimulation.TimeAt
inverts FractionAt, acceleration included).

The in-progress rapid was drawn whole with a dashed pen. GDI+ cost grows
with dash count: zoomed in, one paint of a long rapid can take seconds
to minutes. It is now clipped to the visible area, as finished rapids
already were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:00:47 -04:00
ajandClaude Opus 5.5 50cb34305c feat(ui): fixed-size cut simulator with a larger play button
The window used to grow to fit long drawing names, then never shrank.
Now it keeps one size. The status shows Time, Part, Drawing and Motion
on separate rows, and drawing names get two full-width lines (longer
ones are trimmed and appear in full on hover). Play is the largest
transport button because it is the main control. The track bar is
shorter, and playback now defaults to 5x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:32:27 -04:00
ajandClaude Opus 5.5 2b3f8e7b52 fix(posts): Cincinnati CL sub-program frames, head raises and dead settings
Part sub-program calls restored the sheet frame with G92 at the part
corner while the tool sat at the sub-program's last endpoint, shifting
every later part. The call now raises the head, rapids back to local 0,0
and restores there. Parts share a sub-program only when their posted
toolpaths match, so per-part lead-ins are kept.

Suppressed moves are left uncut: the cut resumes with a rapid past them.

Head raises go before the rapid that leaves a feature, one per rapid:
the safety raise now precedes the rapid to a new part instead of
following its first feature, and sub-program mode no longer posts M47
then M47 P2000. M47Mode.Auto posts M47 before rapids longer than
M47OverrideDistanceThreshold and /M47 otherwise. ExteriorM47 applies to
the contour spanning the part's cut extents.

The sheet GOTO targets FeatureLineNumberStart and is omitted without line
numbers. PrepareForNest pre-selects only the gas- and thickness-matching
library. Part programs are read through absolute copies, never switched
to absolute in place.

Removes the unimplemented UseSheetSubprograms, CoordModeBetweenParts and
ProcessParameterMode (G89Mode); old configs still load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:56:28 -04:00
ajandClaude Opus 5.5 5dc13a03ae feat(diagnostics): warn when parts are closer than the part spacing
Validate Nest, the posting review and console --post now report a
"Part spacing" finding for pairs of parts that do not overlap but sit
closer than the plate's part spacing. Before, only Auto Nest's
Keep/Discard review checked spacing, so hand-edited layouts could be
validated and posted with parts too close together.

Candidates are found with Auto Nest's own outline test, moved into Core
as SpacingOutline (NestLayoutCheck and NestTolerances now use it; Engine
tests unchanged). Because that outline circumscribes arcs by more than
the spacing slack, each candidate is confirmed on native contours
flattened 20x finer than the slack, so arcs placed exactly at the
spacing by interactive fills stay clear. The finding reports the
measured gap and marks the middle of the closest approach. Overlapping
pairs, cut-offs, zero spacing, the plate edge and clamps are not
reported as spacing findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:56:27 -04:00
ajandClaude Opus 5.5 93cf8007bc feat(cutting): pierce perimeters in open scrap when no lead-in keeps the pierce zone
With a pierce diameter set, a perimeter none of whose configured lead-ins keeps its pierce
clear (a lead shorter than the zone radius, or neighbours closer than it) was only rejected.
Plan Cutting now looks for a pierce in the open scrap around the part instead.

- ScrapPierceSearch (Engine): the window four zone radii around the part, minus every nearby
  part's clean material grown by the zone radius and a margin (ClipperBridge, conservative
  flattening); the clear point nearest each ranked perimeter entry is a candidate. Other
  parts' cutouts count as scrap, the part's own holes do not. With no clear scrap, the
  PolyLabel pole of the free scrap is the best effort, and its shortfall is reported.
- ContourChoice.Pierce / PreparedContours.ScrapPierceEntry: the planner's choice carries the
  pierce; the emitter cuts a straight lead from it to the nearest perimeter point and still
  reads nothing but the contour and the choice.
- Every candidate passes the lead validator like any planner-chosen entry; pinned perimeters
  keep their pins and warn. Without a pierce diameter nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:56:27 -04:00
ajandClaude Opus 5.5 6676f9f6f1 feat(cutting): keep lead-in pierces clear of material with an opt-in pierce diameter
Thick plate pierces leave a crater wider than the kerf. CuttingParameters.PierceDiameter
(Cutting Settings > Pierce Diameter, 0 = off) requires every lead-in pierce to keep half
of it from all part material: its own contour, its holes and every other part.

- LeadPathValidator checks the zone during Plan Cutting candidate checks and final
  replay; fixed emissions warn as usual. No-fit findings explain the zone.
- PierceZoneReview reports Pierce zone warnings before posting for leads from any source.
- Holes whose configured pierce is inside the zone move it toward the hole's PolyLabel
  pole (round holes: the centre), as close to the contour as the zone allows; a hole
  too small for the zone is pierced at its pole and the shortfall is still reported.
- Straight corner leads and round-hole clamping use the larger of PierceClearance and
  the zone radius. The manual lead-in preview shares the Core rules.
- Persisted through the DTO, owned copies and state fingerprint.

Perimeter fallback is not included; see docs/geometry/pierce-zone.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:56:27 -04:00
ajandClaude Opus 5.5 4676c97c2c fix(tests): skip cut-time PDF test when pdftotext is missing
The GitHub Linux runner has no poppler-utils, so ReportPdf throws
SkipException; as a plain [Fact] the test failed instead of skipping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:51:46 -04:00
ajandClaude Opus 5.5 5dff83cfb8 fix(server): reject duplicate nest ids, invalid metadata and bare --database
- POST /api/nests with an existing id (active or deleted) now returns 409 with
  the current record instead of a 500 from the UNIQUE constraint.
- Uploaded nest metadata must have string text fields and a defined status;
  null fields and undefined statuses (e.g. 99) are 400 instead of a 500 or a
  stored invalid value.
- --database <path> is accepted alongside --database=<path>; a missing or
  repeated path fails startup instead of silently using OPENNEST_DB/default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:30:37 -04:00
ajandClaude Opus 5.5 3a7073d38c fix(posts): post Cincinnati CL holes where and as drawn, in sequence
Hole sub-programs are stored incremental but were written under G90 as
if absolute, producing invalid arcs. The registry now posts absolute
copies and leaves the nest's programs untouched.

Sheet-level hole calls omitted the part location from G52, so every
part's holes were cut near the sheet origin. Calls now shift to the
part location plus the hole offset.

Hole calls were matched to sub-programs by call id, which planned
emission numbers 1, 2, ... per drawing, so holes from different drawings
shared the first drawing's sub-program and diameter. Calls are now keyed
by program reference and deduplicated by posted toolpath.

A part opening with a hole call got a synthetic rapid that posted an
empty pierce on the part edge. The rapid is now only inserted before a
contour motion with no rapid ahead of it; the hole call carries the
part comment and safety head raise that feature used to hold.

The sheet writer re-sorted parts bottom-then-left with cut-offs last.
Plate order is the cut sequence, so parts now post in plate order.

Regression tests replay the posted G-code for off-origin parts of
several drawings and check each hole's center and radius.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:08:06 -04:00
ajandClaude Opus 5.5 00acaeaaf1 feat(engine): report best whole-job plans from Rectangles, Circles and Default
Desktop Auto Nest's "Best plan so far" strip now fills in for every built-in
whole-job engine except FlatBar, not only Irregular.

- Rectangles makes one sheet-by-sheet run, so it reports the finished job as
  its one plan, then replays its plate commits (token checked as before).
- Circles reports when a stock-preference run or a tail re-plan beats its
  best result so far.
- Default scores each candidate plan as it ranks results (valid, fewest
  unplaced, lowest cost, candidate order) and reports it only when it beats
  every plan already shown, so a later candidate never shows a worse plan.
  The chosen result is always the last plan reported.
- Shared ReportPlan/SameLayout helpers on CandidateProgress; Irregular uses
  them too. Reporting never changes the search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:56:38 -04:00
ajandClaude Opus 5.5 cd2bea054b feat(desktop): show Auto Nest as a sheet strip with a Keep/Discard review
Auto Nest no longer draws parts on the plate view before they are kept:
its result is a set of sheets, possibly of several sizes, not parts on the
current plate. Ghost parts stay with interactive Fill and remnant tools.

- NestJobStage.PlanImproved carries an engine's best whole-job plan so far;
  Irregular reports one whenever a variant or the tail re-plan beats it.
- NestPipeline adds PlanSheets: the plan on detached drawings built from
  the job geometry (with the caller's names and colors), so previews never
  touch caller drawings or quantities.
- SheetStripControl draws every sheet side by side at one scale, repeats
  once with their count, captioned with size, parts and utilization.
- The progress window shows "Best plan so far"; a review window shows the
  final sheets, totals, cost summary and unplaced parts before commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:56:38 -04:00
aj a2fb945669 Revert "feat(engine): live sheet preview while Irregular packs"
This reverts commit 9f0e536eeed85e4417f285b94913e03f594400f7.
2026-10-18 23:56:38 -04:00
aj b11225135e Revert "feat(desktop): draw Auto Nest previews on the trial sheet"
This reverts commit bf42cdc63d5e475fa6921a3ddac06d7a39152bec.
2026-10-18 23:56:38 -04:00
ajandClaude Opus 5.5 d384e63068 feat(desktop): draw Auto Nest previews on the trial sheet
NestProgress gains PreviewSheet, a detached plate for the stock the preview
parts lie on; Irregular builds one per stock. PlateView draws it (size, edge
spacing, clamps) in place of the plate while the preview is shown and drops
it whenever the preview is cleared or the plate changes. Auto Nest zooms out
when a trial sheet falls outside what it has already shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:56:38 -04:00
ajandClaude Opus 5.5 c997b2e588 feat(engine): live sheet preview while Irregular packs
The frontier packer reports its placements after each choice; the engine
turns them into preview parts at most four times a second and sends them as
NestProgress.BestParts with the stock work area, which desktop Auto Nest
already draws on the plate view. Reporting only reads finished placements,
so layouts are unchanged with or without a progress sink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:56:38 -04:00
ajandClaude Opus 5.5 0baa737162 fix(nest): anchor placed parts when an edit moves origin
Converter edits rebuild a drawing's program around its first pierce, so
Source.Offset can move (legacy drawings with no source entities, or a
tabbed/hidden contour changing the first cut). Part.Update kept the old
Location, shifting placed parts by the rotated origin delta - about 43
inches on a 270-degree part in the reported nest. Offset rebuilt parts
by that delta so their geometry stays put on the plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:55:37 -04:00
ajandClaude Fable 5.1 e6c87df372 perf(engine): pool best-fit pair searches by part geometry across jobs
Every Auto Nest run, and every Default-engine run, recomputed each part's
best-fit pairs: job snapshots are fresh objects and BestFitCache keyed
its entries on Drawing reference, so the Irregular engine paid
PairCatalog.Build (about half of a 118-part solve) on every run of the
same drawings.

BestFitCache now keeps a process-wide, bounded (512 sets, least recently
used first) pool of unfiltered candidate sets keyed by the exact content
of a PartGeometrySnapshot (mode and every motion value, bit for bit;
PartGeometrySnapshot.ContentComparer) and the spacing. Every drawing
DrawingJobMapper.CreateDrawing reconstructs from a snapshot declares
that geometry (BestFitCache.ShareByGeometry), so the Irregular pairs and
blocks, the cutout lattice and the Fill/Default job paths all read and
fill the pool. Candidates depend on the program geometry and spacing
alone, so a pooled set is the set the search would produce: benchmark
layouts, costs and placements are unchanged on the synthetic nests (all
engines) and on the 67-drawing, 118-part job. Failed searches are not
pooled, Invalidate leaves the pool alone, Clear empties it, and a
drawing whose Program is replaced stops vouching for its geometry.

Same 118-part job solved twice in one benchmark process (--parallel 1):
the second solve now takes 13.1 s after a 33.0 s first; before, 35.6 s
after 41.8 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 22:17:53 -04:00
ajandClaude Opus 5.5 13ab8e02f8 fix(cutting): plan with staged scrap cut-offs within budget
Staged cut-offs were appended after every part, so a kept order (the
"Keep the current part order" option, or the fallback after a free
search runs out of budget) always conflicted with any cut-off crossing
a part. PlateSequencing.PlaceCutOffs moves each new cut-off to just
before the first part it crosses, separators last, without moving
anything else; the panel applies it after staging.

The free search also learned "cut the cut-off first" only after a full
stall, re-planning the many-holed parts before it. A cut-off is a fixed
program that never gets harder to cut earlier, so a crossing approach
into a non-separating cut-off now teaches that rule at once. On the
reported 35-part plate this drops the plan from 23,930 expansions (over
the 20,000 budget) to 15,605.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:14:28 -04:00
ajandClaude Opus 5.5 ee8be3d79e fix(cutoffs): accept absolute arc roundoff in automatic cut-off planning
Rotated imported arcs whose start and end radii differ by ~5e-11 are
converted without refitting their centre, so their rebuilt start point
lands just outside the cached part bounds. The allowance scaled with
coordinate magnitude (4096 steps, ~2.9e-11 here) and rejected them,
which stopped Plan Cutting's "Add scrap cut-offs" with "Converted
material extends outside cached part bounds". Use the existing 1e-10
cap as an absolute allowance; larger mismatches still block planning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:14:21 -04:00
ajandClaude Opus 5.5 fb505ef0ff fix(cutting): plan outlines with a seam gap before an arc, and memoize lead checks
A converted outline whose seam closes within the admitted 1e-6 gap could
only be entered at its seam: entered anywhere else, the arc after the
seam started off its own circle and every emission was refused as an
inconsistent radius. The search then spent its whole budget on that
part and the plate never planned. Emission now bridges the gap with a
straight move and cuts the arc as drawn; the verifier already admits
that slack. An entry on the seam vertex is left as before.

Each prefix of a part re-checked its earlier contours' leads against
every other part's material. One search now remembers each verdict by
the lead curve's exact bits and the material's reference, charging the
same native query budget; replay never uses it. On the reported plate
the memo alone gives a byte-identical result in 57 s instead of 12 min 47 s;
with the seam fix the plate plans Ready in about 17 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:21:20 -04:00
ajandClaude Fable 5.1 d317a90f1e perf(engine): cut Auto Nest time on many-part jobs
A 67-drawing, 118-part job took about six minutes through the desktop
Auto Nest path and was cancelled before any layout appeared. Almost all
of it was the cutout pre-pass, the rest the Irregular engine.

Cutout pre-pass (317 s -> 8 s on that job, same bundles and poses):
- The lattice and NFP searches prepare the frame's and the occupants'
  validation outlines once per search (NestLayoutCheck.Prepare) instead
  of re-outlining and re-triangulating the frame at every candidate, and
  test candidates against the small occupants before the frame. Answers
  match the pairwise check exactly, including its leftmost-inflated rule.
- A hole's proposal is replayed for the same frame, hole and offered
  inserts: automatic-rotation inserts ask the same question at every
  frame angle.

Irregular engine (40 s -> 15 s, benchmark 42 s -> 19 s, same layouts):
- Concave no-fit polygons are built as the union of pairwise convex sums
  of each footprint's convex pieces (ear clipping plus Hertel-Mehlhorn
  merging on the Clipper lattice) instead of n*m edge-pair quads; a
  footprint that cannot be decomposed keeps the old sweep.
- Pair and block catalogs share one private drawing per part geometry,
  owned by the engine instance, so each part's best-fit search runs once
  per instance and across a pipeline's trial and ordinary solves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 21:13:15 -04:00
ajandClaude Opus 5.5 f26c75ada5 feat(ui): let Drawing Properties return a custom color to automatic
Drawing Properties now says whether the drawing's color is custom or comes
from the color scheme, and an Automatic button clears the custom color. On
save the drawing gets its palette slot back through RecolorDrawings when the
nest is known, otherwise by its non-cutoff index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:30:18 -04:00
ajandClaude Opus 5.5 f009fab31d feat(ui): make color schemes app-wide and keep only hand-picked drawing colors
- Add Drawing.HasCustomColor, set when a color is picked in Drawing
  Properties or comes from a library record with a stored color.
- Persist it as customColor in .nest files (omitted when false) and keep it
  out of drawing packages; split pieces inherit it.
- Recolor non-custom drawings from the active palette when a nest opens and
  when the scheme changes; custom colors keep their palette slot.
- Library settings store a color only when it is custom.
- Document drawing colors in nest-file-format.md and nesting-view-colors.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:24:33 -04:00
ajandClaude Opus 5.5 d548ba4e6f feat(ui): add a Simulate Cutting toolbar button
Restore the toolbar shortcut for Plate > Simulate Cutting next to Plan
Cutting, using the cut_simulate icon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:15:41 -04:00
aj 87b6d7ee90 Revert "fix(ui): make part fills, labels and the cut trail easier to read"
This reverts commit 66afea5a51.
2026-10-18 20:14:18 -04:00
aj 9d68945e75 Revert "feat(ui): make Blueprint the default color scheme"
This reverts commit c875edcdd1.
2026-10-18 20:14:18 -04:00
ajandClaude Opus 5.5 c875edcdd1 feat(ui): make Blueprint the default color scheme
- Add the Blueprint scheme: navy canvas, pale blue sheet, dark navy part
  outlines, orange-red rapids and twelve distinct, readable part fills.
- Use it as the settings default and the fallback for unknown scheme names,
  and list it first in Options. Saved scheme choices are kept.
- Run the palette spacing and readability tests over Blueprint and Classic.
- Document the default in nesting-view-colors.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:04:41 -04:00
ajandClaude Opus 5.5 66afea5a51 fix(ui): make part fills, labels and the cut trail easier to read
- Replace the Classic part palette with twelve clearly distinct fills that
  stay readable with black IDs and clear of etch green; darken the canvas.
- Pick black or white part-ID labels by fill luminance; drop the underline.
- Give the cut simulator trail a pale casing so it shows on warm parts.
- Number plates in the status bar the same way as the plate header.
- Add ClassicPaletteTests covering palette spacing, contrast and labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:26:40 -04:00
ajandClaude Opus 5.5 d1aa056b9b fix(ui): keep the cut simulator smooth while panning and zooming
The overlay re-stroked the whole finished trail on every paint: about
275 ms at zoom-to-fit on a 392-part plate and over 11 s zoomed in, where
dashed rapids spanned millions of on-screen dashes.

The finished trail is now cached in a bitmap at the current scale, so a
pan or playback frame blits it and draws only the moves since. A wheel
zoom stretches the cached image until the wheel has been still for
150 ms, then redraws it sharp. When the trail would be too large an
image, each paint draws only the moves on screen and clips straight
rapids to the view. Arcs are added as GDI+ arcs instead of 5-degree
polylines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:08:08 -04:00
ajandClaude Opus 5.5 bbe0f17a64 feat(ui): allow clearing lead-ins and cut-offs while a cut plan runs
The Clear Lead-ins & Cut-offs button stays enabled during planning.
Confirming cancels the running plan, clears once the cancellation lands
so the plates never change under the planner, then replans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:45:31 -04:00
ajandClaude Opus 5.5 78ca08efe1 feat(reporting): show estimated cut time in nest reports
The nest report PDF gains an estimated cut time: the job total on the
summary, an Est. cut time column in the plates table, and per-plate and
all-copies times on each plate page. Times come from the cut simulation
with the settings last used in Calculate Cut Time, plus clamp removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:40:33 -04:00
ajandClaude Opus 5.5 d3cb23f117 fix: time display-layer cuts, add hole feed and etch skipping
The cut simulation treated Display-layer contour moves (what cutting
strategies emit) as head-up travel, so plates with assigned lead-ins were
timed as rapids. They now cut, matching the verifier and posts, and
suppressed moves are left out.

Cut time now follows the CI Fiber post: closed clockwise contours and
cut-offs take an outside feed, other contours a hole feed, and etch-only
contours can be skipped like its SkipScribe default. The settings are
shared by Calculate Cut Time and the simulator, and plate/nest times now
always come from the simulation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:32:38 -04:00
ajandClaude Opus 5.5 095b4d6e72 feat: time cuts with machine acceleration limits
A Grbl-style lookahead planner re-times the cut simulation with
trapezoidal acceleration, junction-deviation corner speeds and a
centripetal cap on arcs. Machines gain cut/rapid acceleration and corner
deviation in Machine Configuration, with placeholder values until the
controller's are entered. The simulator and Calculate Cut Time pick the
machine (or full speed), and the simulator shows the live head speed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:36:43 -04:00
ajandClaude Opus 5.5 530456a91b feat(ui): add a cut simulator for plates
Plate > Simulate Cutting replays the active plate in cutting order: the
torch traces cuts, rapids and pierces timed like Calculate Cut Time, with
play/pause, scrubbing, 1x-250x speed, step and part/cutout jumps. Parts
the torch has not reached are drawn muted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:27:32 -04:00
ajandClaude Opus 5.5 dd2c00aed4 perf(engine): replay cutout pre-pass clearance checks and trim hole subtraction
Profiling the cutout pre-pass on real round-part jobs put ~97% of its time in
the frame-wall NestLayoutCheck.Clears inside CutoutCirclePacker.Fill. Ear-clipped
round frames make every perimeter/insert triangle pair overlap, so a clear pair
runs hole subtraction for every pair. The same question repeats for every frame
angle and repeated frame instance, because Automatic inserts get identical
frame-local poses.

- CutoutMemo, one per Prepare, replays geometry reads, circle items and Clears
  answers. Keys hold geometry by reference, pose bit patterns, argument order
  and spacing. It is threaded through CutoutCirclePacker.Fill and the router's
  round-only path.
- TriangulatedRegion.SubtractAllHoles measures each piece's bounds once, skips
  holes and hole triangles that no piece reaches, and ping-pongs the remainder
  between scratch buffers instead of copying it into pooled ones. Clip
  arithmetic and order are unchanged.

Bundles are byte-identical on 14 real and 10 synthetic ring jobs. Pre-pass time
over the real set drops from 99.6 s to 10.1 s (N0414-02 35.8 s -> 0.6 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:36:02 -04:00
ajandClaude Opus 5.5 f01b2cec67 perf(engine): speed up round cutouts and re-plan Circles' last sheets
- The cutout router uses the circle packer alone for a round hole whose
  inserts are all round. Lattice and NFP never placed more there, and when
  demand exceeded what fit they spent 20+ minutes on one job.
- The cutout pre-pass skips a hole whose area is smaller than an insert's
  outline, so bolt holes no longer run the searches.
- Circles re-packs its last sheet, and its last two sheets together, onto
  each single stock and keeps the result when the whole job gets cheaper.

On 14 real round-part jobs costs are unchanged and pipeline time drops from
262 s to 144 s for Circles and 355 s to 245 s for Default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:54:01 -04:00
ajandClaude Opus 5.5 0e4fd5f90f fix(mcp): name FlatBar in the autonest engine description
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:29:27 -04:00
ajandClaude Opus 5.5 278252fe79 feat(console): allow multi-sheet autonest with --max-sheets and --stock
--autonest can now use more than one physical sheet. The first layout
replaces the target plate's parts, further layouts are inserted as new
plates after it, and --stock offers explicit sheet sizes (with optional
costs) built from the target's spacing, quadrant and clamp rail.
NestPipelineCommit gains ApplyToPlates for callers without a
PlateManager. Per-sheet results and overlap checks cover every
committed plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:27:14 -04:00
ajandClaude Opus 5.5 b115b9dcb0 feat(ui): add Halloween, Christmas and 4th of July color schemes
Built-in festive schemes theme the background, sheet and accents while
keeping 12-color part palettes distinct, light enough for black part IDs
and clear of the green etch stroke. They are only applied when chosen;
there is no automatic seasonal switching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:48 -04:00
ajandClaude Opus 5.5 9f2e3d5291 feat(engine): compare clamp-hit dropping with filling past the clamp strip
Dropping the parts that land on a clamp suits small parts, which fill the
gaps between clamps. Large parts often do better if the fill area stops
short of the clamps. The rows still fit, and no part is lost on a clamp.

When every clamp in the fill area lies on one edge, interactive Fill now
also fills the area with that edge moved just past the clamp keep-outs,
as a larger edge spacing would. It keeps whichever layout the strategy's
comparer ranks better, and a tie keeps the dropped-parts layout. Clamps
on more than one edge skip the comparison. The second fill reuses the
cached best-fit pairs. While it runs, a preview counts as the new best
only when it beats the first layout, so the preview never gets worse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:48 -04:00
ajandClaude Opus 5.5 05aac952ba feat(engine): fill clamped plates ignoring clamps, then drop clamp hits
Interactive Fill on a plate with clamps used to split the plate into
clamp-free rectangles and fill each one separately. It showed no progress,
so Accept did nothing until the end, and Stop discarded the result. Packing
around the clamps also tends to give a worse layout than losing the few
parts that land on them.

FillItem and FillGroup now fill the area as if there were no clamps. They
then drop each part that enters a clamp keep-out or sits too close to an
existing part, using the same check as before. For a multi-part group, the
whole copy is dropped when any member fails. Progress previews are filtered
by the same check, so a layout accepted early is clamp-clear. A quantity
request that loses copies asks for that many more, up to two more times.
Pack and whole-plate Nest still pack only into the clamp-free areas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:48 -04:00
ajandClaude Opus 5.5 859d10b31d feat(engine): offer a box layout before best-fit pairs in Fill
Interactive Fill ran the best-fit pair phase first. The first pair
computation for a drawing can take about a minute, and until it finished
the progress dialog showed no layout, Accept stayed disabled and the
status text did not change.

The fill now packs the part boxes (RectBestFit) before the pair phase and
reports that layout, so Accept is available at once. The layout is only a
fallback: strategies still run in their usual order, and a completed fill
picks the same winner as before. While the pairs are computed, the detail
line reads "Computing best-fit pairs (may take a minute)...". Accept or
Stop now stops the wait. The computation keeps running in the background
and is cached for the next fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:48 -04:00
ajandClaude Opus 5.5 de845a5818 feat(engine): add Circles engine for round parts
Circles packs every part as its minimum enclosing circle at centers tangent
to two of the work-area walls, placed discs and clamp keep-outs, bottom-left
and left-bottom, with hexagonal and square lattice seeds. It bids only when
every part fills at least 0.8 of its enclosing circle and otherwise returns
everything unplaced at once. Default runs it as a third candidate.

Rectangles' sheet-by-sheet stock loop moves to NestingEngines/SheetByStock
(Rectangles output unchanged) and Default's candidate scoring and commit
forwarding become shared helpers. Circles also runs the whole job preferring
each offered stock and keeps the best result. Contract tests take shapes an
engine can override.

Synthetic round jobs, salvage-credited: Circles wins or ties all six with
valid layouts; Default cost falls about 4% versus Irregular alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:48 -04:00
ajandClaude Opus 5.5 a430da89d0 feat(engine): nest round frames inside larger round frames
The cutout prepass now bundles frames smallest first, so a larger frame's
round cutout can take a ring part or an earlier bundle as one insert through
the circle packer: a flange holds a washer that holds a smaller washer that
holds a slug. Chains stop at four parts deep. Lattice and NFP proposals never
take these inserts, since they could seat a part in a ring's bore.

Expansion and released demand recurse, so placed order cuts innermost first
at every level and an unplaced outer frame returns its whole chain to demand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:36 -04:00
ajandClaude Opus 5.5 f3a6af7f09 feat(engine): pack round inserts into round cutouts as circles
Add CutoutCirclePacker, a circle-in-circle proposal for the cutout
router. When a cutout is one circle and every insert fills at least 0.8
of its enclosing circle, it packs the inserts largest first at centers
tangent to the wall or placed discs, trying a center-out pass (a single
insert sits concentric) and a wall-first pass (optimal counts for up to
nine equal discs). Every pose passes NestLayoutCheck.

The router runs it first and skips the lattice and NFP searches when it
places all demand; otherwise the higher count wins and existing
proposals keep ties. On synthetic disc jobs the prepass drops from
minutes to seconds and places as many or more inserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:36 -04:00
ajandClaude Opus 5.5 2174416dcc feat(engine): model any part as a circle for circle packing
Item.Create builds the circle packer's view of a part: the minimum
enclosing circle of its outward-flattened perimeter (padded by the
layout check's flattening allowance), its fill ratio, the offset back
to the part origin, and the largest disc clear of material inside its
cutouts. Gears and sprockets pack by their tip circle around their
bore; the fill ratio lets a future engine decide which jobs to bid on
instead of a yes/no round test.

Adds MinimumEnclosingCircle (Welzl, repeatable order) to Core geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:36 -04:00
ajandClaude Opus 5.5 6e2e27d6fc fix(engine): stop staggered circle fills overlapping
Staggered stripes were spaced only so adjacent stripes touched
diagonally. When the in-stripe pitch stretched past sqrt(3)*D, stripes
two apart, which sit directly over each other, came closer than a
diameter. Clamp the stripe spacing to at least a radius in both fills.

FillEndOdd also divided by zero when a single circle fit per stripe;
it now stacks those stripes a diameter apart. FillEndEven now clones
the template item, so placed circles keep its radius instead of zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:49:36 -04:00
ajandClaude Opus 5.5 d87302a189 feat(cad): exclude entities and set cut/etch/display-only roles
Select entities in the CAD converter (click, or drag a window/crossing
box) and right-click to exclude/include them or set them to cut, etch or
display only, regardless of their DXF layer. Delete excludes the
selection.

Roles are stored per source entity on the drawing and persisted in the
entity set as an optional `roles` map. Display-only geometry is kept out
of the program, so posts, nesting and verification never see it; placed
parts draw it as a dashed overlay. Load-time scribe repair skips
etch-layer entities set to cut. Per-entity exclusions now survive filter
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:14:55 -04:00
ajandClaude Opus 5.5 27a03b9caf feat(engine): add FlatBar nesting engine for flat bar stock
Reads each stock as a bar and cuts parts in one row along its length, packing
part lengths with a port of the CutList saw cut-list packer (first-fit
decreasing with swap optimization, exhaustive search for small jobs) plus a
best-fill subset-sum candidate. Each part is packed at its length less its
measured self-nesting gain, and a slide-left compaction pass nests shaped ends
(turning alternate mitred parts) and refills the freed length. Every slid pose
is confirmed with NestLayoutCheck.Clears.

On five synthetic flat-bar jobs every layout is valid and complete; total cost
33696 vs Rectangles 36816 and Default 34080.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:54:48 -04:00
ajandClaude Opus 5.5 0186ccfbff feat(nest): keep parts out of tabbed cutouts
A tabbed cutout's slug stays attached while the sheet is cut, but nesting
saw a closed hole and could place parts inside it. Nesting and layout paths
now read material through NestingMaterial.ForNesting, which drops closed
contours with tab spans so the slug counts as part. That covers part
outlines and collision lines, the cutout router, fill expansion, best-fit,
NestLayoutCheck and the GPU bitmaps. Area, overlap diagnostics and cutting
keep the hole, and programs without tabs read exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:14:22 -04:00
ajandClaude Opus 5.5 ba39c3dc55 feat(cad): add tabbed cutouts with draggable tabs
A hole can now be declared a tabbed cutout in the CAD Converter: pick a
circle or any edge of a closed hole, set the tab count and width, and drag
the tabs around the hole. The definition persists on the drawing and in the
nest file, and stays editable from the source entities.

The builder replaces the hole with open cut pieces and uncut spans on a new
Tab layer. Tabs are material, so area, nesting and overlap checks still see
a closed hole, but they are never cut. Plan Cutting and Assign Lead-ins cut
each piece from the slug with the internal lead-in and no lead-out. A lead
that would reach back over a tab is mirrored, and an arc lead onto an arc
pierces straight in. Planning accounts for each span between tabs and runs
the open-cut proof, so its own tabbed cutouts certify; tabs followed by a
lead-out are still refused. Pre-post review flags programs that still
contain tab motions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:50:11 -04:00
ajandClaude Opus 5.5 6cdda38d35 fix(verify): pass tabbed cutouts that provably retain their slug
A cutout cut in several pieces around tabs, as PEP programs often are, made
pre-post review report every piece's lead-out and the part's open fragments
as incomplete. Review now proves such open cuts release nothing: within the
part they touch other cuts only at their own ends and form no loop (each
closed contour with its leads counting as one point), and leads at open ends
stay out of the gap beyond them. Anything else stays incomplete. Plan Cutting
keeps refusing to certify its own tabs followed by a lead-out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:15:06 -04:00
ajandClaude Opus 5.5 3672697cb2 fix(ui): fill tabbed cutouts as one hole
A cutout cut in pieces around tabs is a set of open cut figures. Filling the
cut outline closed each piece with a chord, drawing a slanted bridge across
the hole. The fill path now rejoins open figures across their tab gaps, and
parts with open cuts use it for drawing, hit-testing and the lead-in overlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 23:40:11 -04:00
ajandClaude Opus 5.5 af65cb178b feat(ui): remember status-bar location mode as a user setting
Clicking the location label now saves the mouse-follow/click-only choice
to StatusBarLocationFollowsMouse (default true), so it survives restarts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 23:13:02 -04:00
ajandClaude Opus 5.5 4176e9d89a perf(ui): throttle status-bar location and follow the mouse by default
Mouse-follow mode set the auto-sized location label on every MouseMove,
relaying out the whole status strip and slowing the UI. Updates now run
at most every 100 ms with a trailing update so the label settles on the
final point. Mouse-follow is now the default; clicking the label still
toggles click-only mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 23:11:08 -04:00
ajandClaude Opus 5.5 766584633e refactor(ui): redesign Edit Drawing dialog
Rebuild the dialog as a two-column layout: a larger preview with a
color swatch, a Change Color button and read-only size, area and
source-file details on the left; Drawing (name, customer) and Nesting
(quantity, priority, cut first) groups on the right.

Quantity shows how many parts are already nested and how many remain,
priority explains that lower numbers nest first, and cut first gets a
short description. The form now uses Segoe UI with font auto-scaling,
re-renders the preview when its size changes, selects the name on
open, and is titled "Drawing Properties - <name>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:58:50 -04:00
ajandClaude Opus 5.5 8f73704bef feat(server): migrate v4 drawing library data to schema v5
The v4 upgrade now keeps existing library data: each distinct package
blob is stored once by hash, every revision and current row is copied
with default nesting settings, and the v4 drawing tables are dropped,
all in the migration transaction. Pre-v4 databases get the v5 tables
directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:52:25 -04:00
ajandClaude Opus 5.5 5a374aa3f4 feat(library): content-addressed packages and editable nesting settings
Schema v5 stores each distinct drawing package once in drawing_packages,
keyed by SHA-256; revisions point at it, so details, settings, archive and
restore revisions add no blob. The v4 drawing tables are replaced outright
(no production library existed); nest tables are untouched.

Nesting settings (cut first, priority, rotation constraints, color) become
structured record fields (DrawingLibraryRecord.Settings), returned on every
read, filterable by cutFirst, editable through the metadata endpoint and
applied by LibraryDrawingInsert. Canonical packages now leave every
record-owned field at its default, so renaming or changing settings never
changes the package hash. Saving a nest drawing to the library carries its
settings into the record.

The desktop library browser gains a Cut first column and an Edit Settings
dialog that saves notes and settings as a new revision with the same package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:47:18 -04:00
ajandClaude Opus 5.5 42d99f296c feat(sequencing): add per-drawing Cut first priority
Drawings flagged CutFirst (e.g. test squares) are sequenced ahead of
every other part: they get their own route with the chosen method, then
the remaining parts follow. Cutoffs crossing a cut-first part still
precede it. The flag is editable in Edit Drawing, persists in .nest as
`cutFirst` (omitted when false) and is kept by split pieces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:19:03 -04:00
ajandClaude Opus 5.5 7a1a554804 fix(ui): center parts within the plate work area
Center Parts centred the parts on the whole plate, ignoring edge
spacing, so a wider clamp-side margin left the parts closer to the
clamps than the opposite edge. It now centres them in Plate.WorkArea()
via the new Plate.OffsetToCenterPartsInWorkArea, so each side keeps
the same extra room beyond its edge spacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:09:07 -04:00
ajandClaude Opus 5.5 3541249441 refactor(ui): collapse MainForm menu delegators
Adds WithActive for handlers that only forward to the active nest
document and collapses 32 of them to one line. The eight align and
evenly-space items share Align_Click, reading their AlignType from Tag.
Center Parts becomes CenterParts_Click and ignores a missing plate or
an empty plate instead of throwing. The hidden, disabled Paste item
loses its empty handler. SetValueForm, OptionsForm and ShapeLibraryForm
are now disposed and owned by the main window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:16:57 -04:00
ajandClaude Opus 5.5 93d118585f refactor(ui): extract MainForm status bar binding
MainStatusBar owns the status, location, selection and plate labels and
the click-vs-move location mode. Bind(EditNestForm)/Unbind() replace the
manual PlateView subscribe/unsubscribe pairs in OnMdiChildActivate;
MainForm keeps only its overlap-menu subscription. Applying the location
mode now detaches both mouse handlers first, which also drops the old
self-assignment of the mode flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:14:18 -04:00
ajandClaude Opus 5.5 9b46db4891 refactor(ui): extract MainForm startup and settings services
Moves startup and settings code out of MainForm:
- PostProcessorLoader discovers posts and registers their materials.
  Each DLL now loads in isolation; a failing one is named in a startup
  warning instead of throwing out of the MainForm constructor.
- MachineSettings owns the clamp-settings and nest-defaults entry points
  and their once-per-session warnings; BomImportForm calls it directly.
- NewNestFactory creates File > New nests and their daily names;
  GetNestName is now a pure static the characterization test calls.
- MainWindowSettings restores/saves window placement and migrates the
  legacy nest template.
- UpdaterStatus owns the updater and its status-bar item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:11:37 -04:00
ajandClaude Opus 5.5 3cae257fb1 refactor(ui): extract MainForm Auto Nest, post and storage workflows
Moves the long workflow handlers into OpenNest/Forms/Commands:
AutoNestCommand (dialog, owned modal progress, solve, validation review,
commit and result notices), PostCommand (prepare, configure, verify,
save dialog, multi-file overwrite confirmation) and NestStorageController
(database open/save/save-as with reconciliation and storage errors).

MainForm keeps the busy guards, the nesting lockout and nestingCts, and
reacts to the controller's StateChanged to lock storage settings and
saving. The characterization test now flips the controller's IsSaving
instead of the removed databaseSaveInProgress field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:07:13 -04:00
ajandClaude Opus 5.5 e6b1e0308f refactor(ui): consolidate MainForm busy guards
Moves every MainForm busy predicate into MainForm.Guards.cs, built on
IsJobRunning, ViewsOf, IsPlateWorkBusy and IsNestFillBusy. Auto Nest,
Validate and Post share TryBeginPlateCommand; Clamps, Automatic Cutoffs
and Plan Cutting reuse the shared predicates. The deliberate variants
(Expand Spacing ignores busy actions; the report guard adds database
saves) keep their names and remain pinned by characterization tests.
NavigationEnableCheck now takes the lockout flag instead of
SetNestingLockout repeating its logic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:57:26 -04:00
ajandClaude Opus 5.5 d87d113ceb refactor(ui): split MainForm into per-menu partial files
Moves members verbatim into MainForm.File/View/Tools/Nest/Plate/Status
partials (nested under MainForm.cs in the project); MainForm.cs keeps
the shell: fields, startup, overrides and the Window menu. Each file
keeps only the usings it needs. No logic changes; private member names
used by reflection-based tests are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:57:26 -04:00
ajandClaude Opus 5.5 686bb4d749 test(ui): characterize MainForm nest naming and busy guards
Pins the nest-name format and which operations each MainForm busy
guard counts, ahead of splitting and consolidating MainForm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:57:26 -04:00
ajandClaude Sonnet 5 9d6c10c563 feat(ui): add Parts tab listing pass/fail status for every part
Lists every part in the cutting inspection panel's scope, including ones
with no finding, so an all-clear nest doesn't just look like an empty
grid. Selecting a part jumps to its first issue or zooms to it when clear.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 20:54:24 -04:00
ajandClaude Opus 5.5 6f555345e3 feat(cad): cut breakoff joining lines as tabbed open break cuts
Breakoffs are now picked by their joining line, default to the smaller side as scrap
(flippable), and cut that line between tabs on a new Break layer instead of slots.
Plan Cutting and Assign Lead-ins enter each cut from the scrap with the nest's internal
lead-in, or pierce on the line without one; checks treat break cuts as intentional open
cuts that are not material.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:46:35 -04:00
ajandClaude Opus 5.5 6fd14c5452 feat(cad): tab breakoff scrap to the part in the CAD Converter
Forming backgauge tabs drawn as open cuts came off the sheet loose and could not be
planned. A breakoff merges the scrap into the outline and cuts the shared edge as
closed slots between tabs, so it stays attached and every pierce lands in scrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:42:37 -04:00
ajandClaude Sonnet 5 ca09c33c38 feat(ui): add Open in CAD Converter to drawing context menu
Lets a single drawing be opened and edited in the CAD converter
directly from the drawing list, reusing the existing apply-back
logic from the Edit Drawings button (now shared via
EditDrawingsInConverter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 14:59:28 -04:00
aj adc12e175d Add drawing library toolbar icon 2026-10-17 14:34:41 -04:00
aj ac161f93c4 Merge remote-tracking branch 'origin/master' 2026-10-17 12:57:36 -04:00
ajandClaude Opus 5.5 bc363cc904 feat(engine): name every unplaced part and why
NestPipelineResult.Unplaced lists each requirement's unplaced copies with a
typed NestUnplacedReason, counted from the returned placements: no stock fits
at the sampled rotations, reserved in a cutout of an unplaced frame, stock
exhausted, plate limit, no position found, or proposal not keepable. Desktop
Auto Nest, Console --autonest and MCP autonest_plate/autonest_job report them;
the API returns NestResponse.Unplaced, persisted from response schema 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:51:24 -04:00
ajandClaude Opus 5.5 4a1587d6bd feat(engine): fill cutouts first in every whole-job run
NestPipeline.Run now bundles parts that fit other parts' enclosed cutouts
into their frames before the selected engine, for desktop, Console, MCP and
API alike. A valid complete bundled trial is kept; a refused trial falls back
to the ordinary solve (reason on RefusedCutoutTrial), and an incomplete trial
is compared with it, keeping whichever valid proposal places more. An
incomplete but valid trial is now keepable like any incomplete result.
Expanded sheets list each frame's inserts before the frame, so the committed
order cuts every insert before the cutout that releases its slug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:51:18 -04:00
ajandClaude Opus 5.5 6ac56c4a3f feat(core): pre-post checks for parts nested in cutouts
Posting review now proves containment exactly as the cutting planner does
(shared NestedPartContainment) and warns when a nested part is cut after its
host (NestedCutOrder) or when a host's hole lead or the nested part's lead
meets the other's material (NestedLead), including subprogram hole poses.
Ambiguous containment is incomplete, never clear; fixed-lead motions are not
reported twice. A committed cutout preview warns until Plan Cutting cuts the
insert first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:09:55 -04:00
aj 0cef0ae297 test(desktop): establish keyboard focus before saved-nest Enter
Fix the focus-sensitive failure in HighlightedNestShowsItsPlatesAndDrawings_AndEnterOpensIt from GitHub run 39042902952. Explicitly focus the nest grid and assert it owns focus before sending Enter. Production keyboard handling and all existing open, cancel, and missing-archive assertions remain unchanged.
2026-10-17 11:32:07 -04:00
ajandClaude Sonnet 5 b324baa69a fix(desktop): keep scrap cut-offs checkbox selectable during planning
The cut planner disabled "Add scrap cut-offs" along with every other
control while a plan ran, including the very first auto-started plan.
It now stays enabled; toggling it mid-plan cancels the run in progress
and restarts planning with the new setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 01:59:34 -04:00
ajandClaude Opus 5.5 170b517a28 refactor(engine): share engine-name classification across front ends
Console and Mcp each re-derived whether a name was a fill strategy, a
whole-job engine or unknown. EngineNameResolver now owns that decision,
trims consistently and accepts renamed legacy engine names; front ends
keep their own message wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:45:16 -04:00
aj 9360f59077 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	OpenNest/Forms/CuttingInspectionPanel.cs
2026-10-17 01:34:22 -04:00
ajandClaude Opus 5.5 2f91f1b036 fix(engine): forged pinned-contour ordinal stays an Incomplete verdict
The 5.5 pinned-entry shortcut in ContourEntryFeasibility looked up the
choice's contour before the probe's own validation, so an owned choice with
an out-of-range contour ordinal threw instead of returning Incomplete as it
did before. The lookup now runs only for an ordinal inside the preparation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:27 -04:00
ajandClaude Opus 5.5 6c691cc23b test(desktop): block the all-plates panel test on overlap
Since 5.2 a locked program's own rapid crossing is a fixed-lead warning on an
UNVERIFIED plate, so it no longer makes the panel's "Can't apply" case. The
blocked plate now holds overlapping parts, a hard block, and the test checks
the same navigation and no-partial-Apply behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:26 -04:00
ajandClaude Opus 5.5 9816326429 fix(engine): legacy lead assignment respects pins
Assign Lead-ins (LeadInAssigner) now leaves every unlocked part with active
drawing pins unchanged and returns those parts, so it never overwrites a pin
with the plate's settings; the desktop names them ("N parts kept their
drawing lead-ins (plate P: parts ...); use Plan Cutting to apply their
drawing pins."). Parts without pins, with pins switched off, or locked are
assigned or skipped exactly as before.

The pre-post review now covers drawing pins. A part with active pins whose
placed program does not cut each pinned contour exactly as the pin's own
emission (re-emitted with the part's cutting settings and matched motion for
motion) warns "Drawing pins not applied", as do locked lead-ins overriding
pins and a pin that no longer matches the geometry; pins switched off are
not reported. The contours that are cut as pinned join locked programs in
the fixed-lead re-check against every part's clean material, and fixed
leads are now also checked against active clamps (Plan Cutting itself does
not model clamps).

AGENTS.md records the fixed-emission safeguard and the characterization
digests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:26 -04:00
ajandClaude Opus 5.5 a18f8c9b56 feat(engine): pinned contours use the fixed-lead warning path
A drawing-pinned contour is now a fixed emission like a locked program. A
regenerated placement maps its execution motions onto the emitted contour
choices (one group per choice after the program's scribes, opened by its
rapids) and owns as fixed the lead-in, cut, lead-out and tab of every pinned
contour; the rapids between contours, including the one into a pinned
contour, stay the planner's and strictly checked. If the motions do not
split cleanly into one group per choice, nothing is treated as fixed.

So a pinned lead that meets another part's material or its own part's no
longer rejects the candidate: the feasibility precheck accepts a pinned
entry as given, the search keeps it in place, and replay reports a FixedLead
warning naming the part, cutting contour, pin and the part hit, with the
advice to increase spacing or switch off drawing pins for the part. The
plate is UNVERIFIED and needs acceptance; the lead is never shortened and
the entry never moved. The unpinned control and the same plate with pins
off are Ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:25 -04:00
ajandClaude Opus 5.5 8d17cf8c59 feat(engine): capture and freshness for pins
Capture now honours a placement's pins only while they are active: a part
with DrawingPinsOff plans from the plate settings with no warning (D1's
per-part escape hatch), and Part.HasActiveDrawingPins is the shared test.
A pinned part is regenerated like any unlocked part whatever lead-ins or
settings it carried. A locked part with active pins keeps its exact program
and warns "Locked lead-ins override drawing pins on part N; Clear Lead-ins
to apply the pins." instead of being refused; an unlocked part a caller left
ineligible warns that its pins are not applied.

Apply's freshness record now holds each drawing's pin list (reference and an
explicit StateFingerprint of every pin's point, leads and tab) and each
part's pins-off flag, so editing either after capture returns Stale. The
fingerprint writer is shared between settings and pins; a reflection test
pins ContourCutPin's public surface to what the fingerprint covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:25 -04:00
ajandClaude Opus 5.5 7949d41b6d feat(core): pinned contour preparation
PreparedContours.Capture(program, parameters, pins) takes CutPinResolver
results for the same contours and fixes every resolved pin's contour: its
one and only entry is the pin's point, and it is emitted through the
existing emitter with the pin's own lead-in, lead-out and tab (angle
rounding off, so the entry never moves). Every catalogue returns the pinned
entry alone and emission refuses any other entry for that contour, so no
search path can relocate it. The certified tab size is the pinned outside's
own, so a pinned tab gap is genuine and replay certifies it. Unpinned
contours keep the shared settings and automatic selection; without pins the
existing preparation is untouched (5.1 digests unchanged).

Capture now resolves a regenerated part's drawing pins. A pin that does not
resolve (not on a contour, on several, sharing one, or unplaceable) is never
guessed: its contour stays automatic and the plate gets a FixedLead warning
naming the pin, making the proposal UNVERIFIED. A tab pinned on an internal
contour cannot be cut as pinned (only the outside is tabbed) and warns the
same way. Pins-off, locked parts with pins and freshness follow in 5.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:24 -04:00
ajandClaude Opus 5.5 d56f67a99a feat(engine): fixed-lead violations warn instead of refusing
D1a (user, 2026-10-14): one locked cutout must not refuse the whole layout.
With confirmed parameters a locked program is now a fixed emission, accepted
as given. The replay checker and the lead validator take a per-motion owner:
a violation inside the fixed emission (a lead meeting another part's or its
own material, a rapid inside the locked program crossing a cut contour, an
uncertifiable tab gap or a partial/reversed/retraced cut) is recorded apart
instead of failing the candidate. The search never rejects or learns an order
rule from them, so fixed emissions never steer it; the planner's rapid into
the locked program and every regenerated lead keep every strict check.

Replay turns those violations into typed FixedLead findings that name the
part, contour and the part hit. A plate whose only findings are these is
BestEffort with IndependentlyReplayed set: never Ready, applicable only with
the per-proposal acceptance, and CuttingPlanService.Apply still rejects it.
Overlap, unreadable programs, stale input and cancellation still block, and
unlocked programs a caller left ineligible and the no-parameter route keep
refusing as before.

Pre-post review now re-checks locked leads against every part's clean
material, so the same violations reappear before posting.

Existing tests that encoded the old locked refusal now assert the warning;
two batch tests that used a locked crossing as their hard block use an
overlap instead. The 5.1 digests are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:23 -04:00
ajandClaude Opus 5.5 36ba3c2d6b test(engine): planning characterization
Freeze what Plan Cutting produces today before pins and fixed-lead warnings
touch the planner: the six synthetic nests (placed deterministically, every
other part turned a quarter) and five focused fixtures go through the desktop
batch, and the proposals, expansions and every emitted motion are hashed.
Coordinates are rounded to six decimals so last-bit libm differences between
platforms cannot move a digest. OPENNEST_PLANNING_DIGESTS appends the computed
digests to a file for the slice record.

Two red rows record today's refusals that D1a will turn into warnings: a
locked hole lead that runs into a part nested in that hole, and a locked
tabbed perimeter under confirmed parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:22 -04:00
ajandClaude Opus 5.5 d9b8f054cc feat(core): mismatch drop and part copy
Two remaining pin rules from the drawing library plan:

- D3: a library drawing added to a nest whose material, grade or
  thickness differ from the library record now really arrives without
  its cut pins. The insert already warned and reported KeepPins = false;
  it now clears the copy's pins, because they were authored for the
  library material and thickness. The library link still records the
  revision the copy came from.
- A copy of a placement (Clone, CloneAtOffset) keeps its drawing pins
  off, so copying or patterning an overridden part does not silently
  bring the drawing's pins back. Clearing lead-ins keeps it too: pins-off
  is a placement choice, not lead-in state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:22 -04:00
ajandClaude Opus 5.5 734346eeec test(core): pin frame lifecycle
Proves the source-to-program mapping the pins rely on (plan policy: prove
it or stop): the program frame is the source frame moved by
-Source.Offset, never rotated in place; a placement rotates it about the
program origin and adds the part location. Source.Angle is canonical
metadata and plays no part.

The oracle does not use Source.Offset or part rotation bookkeeping. It
recovers the rigid source-to-plate transform from the placed geometry
itself, using two holes of different sizes, and checks every pin at 0,
90 and 37 degrees, placed directly and the way engines place parts
(CreateAtOrigin), after:
- import (BuildDrawing re-origins at the first rapid);
- a converter edit that hides the first-cut hole and so moves
  Source.Offset;
- a converter edit of a drawing without source entities (the derived
  entities are moved back by Source.Offset);
- save and reload;
- a drawing package round trip.

To exercise the real converter paths headlessly, the desktop's entity
load and in-place apply move into CadImporter.EditableEntities and
CadImporter.ApplyEdit, unchanged; the in-place apply keeps the library
link and cut pins as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:21 -04:00
ajandClaude Opus 5.5 0f06748f28 feat(io): persist pins and pins-off
Drawing cut pins and the per-placement pins-off flag now survive
save/reload and travel in drawing packages.

- `DrawingDto.cutPins` reuses the plate cutting-parameter lead and tab
  DTOs. Pins are read strictly per entry: an unknown lead or tab type is
  never replaced by a default, because a pin that cut differently from
  what was saved is worse than no pin. A bad entry is skipped with a
  reader warning naming the drawing and pin, and the rest load; a value
  that is not a list loads no pins, with a warning. A nest never fails to
  open over pins.
- `PartDto.drawingPinsOff` is written only when true. Anything other than
  a boolean loads as pins on (the drawing wins, D1) with a warning naming
  the plate and part, so an operator's override is never lost silently.
- Packages carry pins as drawing content, so pinning a drawing changes
  its package hash and the newer-revision notice can tell geometry/pins
  changes from detail-only ones.
- `Part.DrawingPinsOff` holds the flag; nothing plans with it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:21 -04:00
ajandClaude Opus 5.5 1bd0b258af feat(core): contour cut pins
A library drawing can say how individual contours must be cut. A
`ContourCutPin` holds the entry point in the drawing's source frame plus
the lead-in, lead-out and optional tab used there; `Drawing.CutPins`
lists them. Nothing plans with pins yet (Slice 5).

- Pins accept only the exact built-in lead and tab types, copied through
  the same owned copiers the planner uses, so a pin cannot carry a type
  the emitter would not certify. Properties return copies: the pin is
  immutable.
- A tab requires no lead-out, because the planner refuses a lead-out
  after an open contour.
- The source frame is the frame of the source entities. It does not move
  when BuildDrawing re-origins the program at its first rapid, so pins
  survive converter edits that change Source.Offset.

`CutPinResolver` maps pins to (contour ordinal, entity ordinal, local
point) for a placement rotation, using the same `PreparedContours`
ordinals Plan Cutting will use. It never guesses: a pin must lie within
the contour closure tolerance of exactly one contour that no other pin
claims, otherwise it is reported unresolved with a typed reason. A
drawing program rotated in place (a canonical copy) is not the source
frame and resolves nothing; canonical copies and split pieces carry no
pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:20 -04:00
ajandClaude Opus 5.5 2aecafb356 feat(desktop): Save to Drawing Library dialog
Operators can now save a nest drawing to the shared drawing library. Right-
click a drawing in the drawing list > Save to Drawing Library... opens a
dialog with name, customer, material (post materials suggested), grade,
thickness, cut type and notes, prefilled by the save model; units follow the
nest.

Whenever the details change, the dialog looks up library drawings with the
same details and offers Save as: the next revision of the match (the default
for one match or the linked drawing), a choice among several, or a separate
drawing. Save stays disabled until the lookup for the current details has
finished, so a choice can never apply to details it was not made for. A 409
is shown and the choices are refreshed; a save that could not be confirmed
tells the operator that saving again checks it and never stores it twice.

On success only the drawing's library link changes, and the chosen cut type
is remembered per PC. There is no nest dirty flag to set, so the operator
saves the nest to keep the new link. The item is Database mode only and is
shown only where the drawing list's host handles it (not the best-fit viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:20 -04:00
ajandClaude Opus 5.5 f93b0d6273 feat(data): library save model
Saving a nest drawing to the drawing library now has a Linux-testable model,
DrawingLibrarySaveSession, ahead of the desktop dialog.

Prefill starts from the drawing's name and customer (the nest's customer when
the drawing has none), the nest's material, grade, thickness and units, and the
linked revision's cut type and notes when the drawing came from this server's
library, otherwise the operator's last-used cut type.

Duplicates follow D4: active drawings whose normalized customer, name, material
and grade and exact thickness, units and cut type match. The server only
narrows (a blank filter means "any" there, and LIKE does not collapse
whitespace), so every key is checked again on the client. The default is the
linked drawing when it matches, else the only match; several matches require
the operator to choose, and a linked drawing that no longer matches is offered
but never the default, so a thickness change cannot silently rewrite it.

Writes follow NestSaveSession: a new drawing's id is allocated before the
upload, and a save that fails without a definite answer stays pending. The
next save first looks for the revision it would have produced and adopts it
when the package hash and details match; a not-stored new drawing is retried
under the same id, never onto another drawing, and a revision someone else
saved meanwhile is a conflict. A definite refusal (409, old server, invalid
request) leaves nothing pending. On success the drawing's link points at the
saved revision; nothing else in the nest copy changes (D2).

RemoteDrawingLibrary now implements IDrawingLibraryStore, the read/write
surface the session needs, so tests can use an in-memory store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:19 -04:00
ajandClaude Opus 5.5 a1bd44d0fa feat(desktop): Add from Drawing Library
Operators can now pull shared library drawings into a nest. Nest > Add from
Drawing Library... and a Library button above the drawing list open a paged
browser (name, customer, material, grade, thickness, cut type, revision, saved)
with a notes preview and a quantity. It starts filtered to the nest's material,
grade and thickness, with Show all to lift that.

Add downloads the listed revision's immutable package, not the current one, so
a revision saved meanwhile can never be what lands in the nest. The insert is
planned first and every warning or refusal is shown before the nest changes;
the dialog is modal, so the nest cannot move between plan and apply. The dialog
stays open for further adds and the drawing list refreshes on close.

Both entries are Database mode only: the library lives on the nest server, so
in File mode they are disabled with a "Database mode only" tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:19 -04:00
ajandClaude Opus 5.5 175782d8f8 feat(data): library browse session
Add from Drawing Library needs the same race-safe paging as the Database-mode
Open dialog: a generation counter so an older response never replaces a newer
one, cancellation after the superseding request is installed, and IsLoading
tracking only the latest request. `DrawingLibraryBrowseSession` mirrors
`NestBrowseSession` for library queries.

It starts filtered to the nest's material, grade and thickness, since that is
almost always what the operator wants and a mismatch drops cut pins anyway
(D3). "Show all" lifts the filter. A nest with no thickness filters by material
only, and the empty-result text tells the operator when the nest filter, rather
than an empty library, is why nothing is listed. Archived drawings stay hidden.

`IDrawingLibrary` is the read side the session and the add flow use, so both
are testable with fakes; `RemoteDrawingLibrary` implements it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:18 -04:00
ajandClaude Opus 5.5 4dbb4527e2 feat(data): library insert model
Adding a library drawing to a nest has several outcomes that must be decided
before anything changes, so the desktop can show warnings first and so every
rule is tested on Linux. `LibraryDrawingInsert.Plan` returns a new exact copy
carrying its library link, an added quantity on the copy already there, or a
refusal that leaves the nest untouched.

- Material, grade or thickness that differ from the nest still add the drawing
  but drop its cut pins, with one warning naming each difference (D3). Names
  compare by SharedListNames key and thickness at four decimals, matching the
  server's own filters.
- Different units are refused: conversion is out of scope and a silent unit
  mismatch would scale the part.
- The same revision from the same server only raises the quantity; a different
  revision of that drawing is refused in favour of Update Drawings (D2b).
- Another drawing with the same name is refused naming both, because the
  name-keyed DrawingCollection would otherwise drop one silently.
- A package whose hash or units disagree with its record is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:18 -04:00
ajandClaude Opus 5.5 15654c0690 feat(core,io): drawing library link
A nest drawing copied from the drawing library has to remember exactly which
revision it is a copy of, so later slices can tell when the library moved on
(D2b) and so a save can target the right drawing. `Drawing.Library` holds the
server key, id, revision and a snapshot of that revision's cut type, thickness,
material, grade, units, notes and package hash. It is immutable, so copies can
share it.

`DrawingDto.library` is additive and omitted when null. It is read leniently:
anything that cannot be trusted as a link drops the link with a reader warning
and the drawing still loads, because a nest must never fail to open over
provenance. Enum names are checked as single names since Enum.TryParse would
accept numbers and comma lists.

Copy paths: converter edits update drawings in place and so keep the link;
canonical engine copies carry it; split pieces are new geometry and get none.
Packages strip it so linking never changes a drawing's package bytes or hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:17 -04:00
ajandClaude Opus 5.5 8b63c042b5 test(server): drawing library end to end
Proves Slice 1's contract through the production routes and the real client:
a code-built drawing is packaged, uploaded, found by cut type, material, grade
and thickness among decoys, downloaded and read back with identical program
text; a stale If-Match update raises a conflict and leaves the current
revision, its package hash and the history unchanged. Archive, restore,
metadata edits and duplicate ids are exercised the same way.

Documents the /api/drawings contract, schema v4 and the fact that the backup
manifest and restore check do not yet verify drawings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:03 -04:00
ajandClaude Opus 5.5 0c1eb3ad65 feat(data): drawing library client
RemoteDrawingLibrary covers upload, query, get, package download, revisions,
revision packages, guarded package/metadata updates, archive and restore. It
checks the shared bounds before sending, refuses oversized or inconsistent
pages, and verifies that the server stored the exact package it sent.

A 404 on the library routes means the server predates them: the client raises
ServerTooOldException and never falls back to another store. Because a 404 for
one drawing is ambiguous, it probes the query route before deciding between
"unknown drawing" and "server too old". Conflicts raise
DrawingLibraryConflictException carrying the current record, which the save
flow needs to offer a reload or the next revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:03 -04:00
ajandClaude Opus 5.5 3c577d4ac7 feat(io): drawing package
Library drawings travel as a single-drawing, zero-plate .nest archive so the
server stores opaque bytes and the client reuses NestWriter/NestReader. A
characterization test first proves that shape round-trips the program, hole
programs, source entities and suppression, offset, material, bends and units.

Packages are canonical: fixed archive and nest timestamps, zero nest quantity
and no best-fit caches. The same drawing content therefore has the same bytes
and SHA-256, which later revision checks rely on to tell geometry changes from
detail-only edits. Read checks the archive's own declaration before the lenient
nest reader, which would otherwise default unknown units and drop empty plates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:02 -04:00
ajandClaude Opus 5.5 76133193df feat(server): drawing library schema and routes
The server becomes the system of record for shared drawings (cut type,
thickness, material and grade, customer, notes). Schema v4 adds `drawings`
(current row) and `drawing_revisions` (immutable) to the same SQLite file under
the existing monitor; the nest tables are untouched and a v3 upgrade keeps every
nest row byte-identical.

`/api/drawings` follows the nest revision contract: client-allocated ids,
If-Match on every existing-drawing write (428/400/409 with no state change),
archive instead of delete, and one transaction for promotion plus snapshot.
Geometry is an opaque package (a zero-plate .nest) the server never parses.
Enum fields accept single names only, because the JSON enum converter would
otherwise accept numbers and comma combinations. Material, grade and customer
filters match SharedListNames keys so spacing and case cannot split a drawing
family; thickness is stored and compared at four decimals.

Shared bounds live in OpenNest.Data so the client can refuse the same requests
before sending. If-Match parsing moves to one helper used by nests and drawings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:02 -04:00
ajandClaude Opus 5.5 0ee5b9d93e refactor(data): share server URL validation
The drawing library client needs the same base-address rule as the nest
repository. Extract it into one internal helper so both clients accept and
reject exactly the same addresses. Behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:02 -04:00
ajandClaude Opus 5.5 7a5c06bc99 fix(bestfit): bind loaded pair candidates to a canonical copy
Saved pairs are in the drawing's canonical frame, but loaded candidates
held the source drawing, so BuildCanonicalParts/BuildSourceParts rebuilt
a different pair for any drawing with a non-zero canonical angle (a 30
degree T-shape came back 16.57 x 10.39 instead of its saved 16.92 x 8).
That reached the Irregular engine's pair catalog and the best-fit viewer
after opening a saved nest. Import now binds every candidate to one
canonical copy, as computed candidates are; fills already passed the
canonical drawing explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:43 -04:00
ajandClaude Opus 5.5 844f360732 docs: document saved best-fit entries and IO's Core-only dependency
The bestfits/ archive entry was undocumented. Describe its JSON shape,
that it is an optional cache readers may ignore, and that it is read or
written only when a caller supplies an IBestFitStore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:54:45 -04:00
ajandClaude Opus 5.5 2d5bf6f42c build(io): drop OpenNest.IO's reference to the engine
IO only needed Engine for the best-fit cache, which now goes through
IBestFitStore. Removing the reference stops every IO consumer pulling in
the engine and ONNX Runtime. A guard test keeps the assembly from
referencing it again; IO.Tests references Engine itself for its whole-job
round-trip tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:54:44 -04:00
ajandClaude Opus 5.5 6e66a1efe7 refactor(io): persist best fits through an explicit IBestFitStore
Reading a nest used to fill the engine's static BestFitCache as a side
effect, which is the only reason OpenNest.IO referenced OpenNest.Engine.
Core now defines SavedPairSet/SavedPairResult and IBestFitStore; the
mapping to BestFitResult moved verbatim into Engine's BestFitCacheStore.
NestReader/NestWriter take the store as an optional BestFitStore property
and skip bestfits/ entries without one.

Every production reader and writer opts into BestFitCacheStore.Shared, so
behaviour is unchanged; the golden round-trip test still matches byte for
byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:54:43 -04:00
ajandClaude Opus 5.5 063362a674 test(io): pin best-fit round trip through .nest archives
Characterize the bestfits/ entries before moving their mapping out of
OpenNest.IO: golden bytes for hand-built sets, field-by-field restore of
hand-built and computed sets (kept results and used plate sizes only), a
nest without saved best fits, and the loaded-candidate frame on a rotated
source drawing, which today rebuilds to a different pair than its saved
bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:37:15 -04:00
ajandClaude Opus 5.5 4e0699de7a fix(rectangles): break exact material-area ties the same way on every platform
ZeroClampMixedShapeLayoutIsBitIdenticalToBaseline has failed on Windows since
it was added in de9a087 while passing on Linux CI (bisect pointing at
c374e02 was an artifact: the assumed-good start commit fails too). No
commit changed the layout; the two platforms always chose differently.

In that fixture two candidate sheets (ContactPoint/Global and
BestShortSide/Ordered) both place all 30 parts at the same cost, so Choose
falls to its "more material placed" tie-break. SheetPacker summed material
area in placement order, and the disc's area differs in the last bit
between the Windows CRT and glibc math libraries. On Linux the two orders
rounded to ...253 vs ...257, so the later candidate won on rounding noise;
on Windows both rounded to ...257 and the tie fell to enumeration order as
Choose documents.

Sum a plan's area per type in type order instead (the same form as the
engine's demand area), so plans placing the same parts get bit-identical
areas and exact ties keep enumeration order everywhere. No tolerance is
loosened. The Windows layout is unchanged; Linux now matches it, so the
frozen hash moves to the Windows value, verified identical on both.

Verified: full Engine.Tests pass on Windows and Linux (1105 passed, 2
skipped); Rectangles and Default benchmark results on the six synthetic
nests are identical before and after (validity, placed parts, cost,
utilization, plates).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:20:20 -04:00
ajandClaude Opus 5.5 9f627c1a9d build(mcp): target net8.0 and pin package versions
The MCP server uses no Windows APIs, so net8.0-windows only kept it from
building on Linux. Floating 0.*-* / 9.* versions let a restore silently pick
up a new ModelContextProtocol preview; pin the currently resolved versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:15:52 -04:00
ajandClaude Opus 5.5 7600d75f41 refactor(desktop): stop shipping the unused GPU evaluator
The GPU hooks in MainForm were commented out, so ILGPU only powered a
status-bar label that reported a GPU device the nesting never used. Removing
the reference drops OpenNest.Gpu and ILGPU from the desktop output. The GPU
kernel tests reference OpenNest.Gpu directly; Training still uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:15:50 -04:00
ajandClaude Opus 5.5 90a6d9e4d2 refactor(core): drop System.Drawing.Common and fix CutParameters namespace
Core only uses System.Drawing.Color, which ships in the shared framework, so
the System.Drawing.Common package (Windows-only beyond primitives) was an
unneeded dependency of the cross-platform domain library. The desktop app
keeps its own reference.

CutParameters lives in OpenNest.Core but declared namespace OpenNest.Api,
forcing Core and desktop files to import the Api namespace for a Core type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:15:48 -04:00
aj 19adfbcfbd merge: drop the orange banner during part-order search 2026-10-16 11:21:34 -04:00
ajandClaude Opus 5.5 2ea201140b fix(desktop): drop the orange banner during part-order search
The saturated orange Busy tone broke the panel's single alert style and
read as a warning while planning was simply running. The part-order
search now shows the same neutral banner as the other planning phases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:21:33 -04:00
aj 6bf0658d68 merge: ask for a new nest name on database Save As 2026-10-16 11:18:28 -04:00
ajandClaude Opus 5.5 4e95a09706 feat(desktop): ask for a new nest name on database Save As
Database Save As uploaded the copy under the same name as the original,
so the saved nests list showed two indistinguishable entries. It now
prompts for the copy's name first (prefilled with the current one) and
cancelling aborts the save. The prompt is skipped while a lost upload
is being reconciled, since that click never writes. If the upload fails
outright the original name is restored, so a later Save cannot rename
the original nest; it is kept only while the copy may still be pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:18:28 -04:00
aj 37ddf1b7b2 merge: continue strip lines into a tail too short to keep 2026-10-16 10:40:05 -04:00
ajandClaude Opus 5.5 75195e8ba4 fix(cutoffs): continue strip lines into a tail too short to keep
When the end tail was shorter than the minimum tail to keep, the planner
dropped the separator but still stopped the strip grid at it, so the last
strip ran to the sheet end and could exceed the maximum spacing (e.g. a
39-inch strip from 105 to 144 with 35-inch spacing). The grid now spans the
whole sheet whenever the separator is skipped, keeping every strip within
the spacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:40:04 -04:00
aj 274a1a1dea merge: show cut-plan status in a banner at the top of the panel 2026-10-16 10:12:10 -04:00
ajandClaude Opus 5.5 91b693be90 feat(desktop): show cut-plan status in a banner at the top of the panel
The outcome line sat mid-panel in coloured text and was easy to miss. It
now leads the panel in a tinted, outlined banner, and the existing
lead-in/cut-off alert uses the same style so the panel has one alert
look. The part-order search turns the banner bright orange because it is
the slow phase the operator waits on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:12:09 -04:00
aj 648c0f1efc merge: replay seams whose bridge lengthens the cut 2026-10-16 09:55:49 -04:00
ajandClaude Opus 5.5 017e06de2d fix(cutting): replay seams whose bridge lengthens the cut
A nominal ring may close within NominalContourClosure.Tolerance, and replay
already carried that admitted gap as slack when bridging it shortens the cut.
When the gap instead lies across the following edge, the emitted path starts
that edge at the ring's end point, so the cut is longer by the gap. Two checks
still compared at the exact epsilon and refused it as "invalid input":

- the retrace guard rejected any surplus length beyond 1e-8;
- Follows matched the edge's leftover sliver against the next nominal entity
  and failed its direction check.

Both now use the ring's admitted slack, the same bound the shortfall and the
nominal side of Follows already use. Exactly closed rings keep the epsilon, and
a second circuit still exceeds the slack by a whole ring.

A0227-08's sprocket outline misses its start by 1e-6 and refused planning in
both keep-order and reorder modes; it now plans Ready and applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:55:49 -04:00
aj 21e1aee342 merge: mark cut-plan issues on the plate, simplify the inspection panel and draw planned cut-offs 2026-10-16 09:20:30 -04:00
ajandClaude Opus 5.5 571776716f chore(desktop): add AutomaticCutOffForm designer resource file
Visual Studio generated the standard empty .resx for the form; every other
designer form keeps its .resx in source control, so track this one too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:20:29 -04:00
ajandClaude Opus 5.5 405a867a77 fix(desktop): draw planned cut-offs and issue markers on the plan preview
The proposal preview skipped the cut-off overlay: it carries cut-off parts but no
cut-off definitions, so planner cut-offs looked like thin black part outlines
instead of the orange-red dashes used everywhere else. The preview now draws each
cut-off part's own cutting moves in that style.

The same paint guard hid the validation overlay over a preview, so issue markers
for unverified plans were set but never drawn. The overlay now draws on proposals
too, with the marker's part numbers mapped to the preview's proposed order so the
right parts stay highlighted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:18:48 -04:00
ajandClaude Opus 5.5 c3fa8e79e0 feat(desktop): mark cut-plan issues on the plate and trim the inspection panel
Plan Cutting findings dropped the checker's geometry, so selecting one could only
zoom to part extents, and overlap-blocked plates (no preview) did nothing at all.
Findings now keep the check's world-coordinate witness (overlap regions, rapid,
contacts); selecting a row draws it with the Validate Nest overlay, on the proposal
or a blocked plate's live sheet. The witness is excluded from finding equality so
repeated search rejections still collapse to one finding.

The side panel's scrolling report was a wall of text operators skipped. It is now
a colored outcome line, one line for the shown plate, and one remedy or marker
hint above the issue grid, with secondary detail moved to tooltips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:11:13 -04:00
ajandClaude Opus 5.5 658c1c9ee3 style(desktop): give every data grid the nest browser's look
Grids across the desktop app each set their own borders, header styles
and selection colours, so tables looked different from form to form.
The database nest browser's grid style moves into a shared GridStyle
helper that the AutoNest, BOM import, options, nest history, cutting
inspection and post settings grids now apply too. The helper also fixes
row heights so users cannot resize rows; editing, selection and column
sizing stay with each grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:53:23 -04:00
aj 697b6492c7 fix(browser): integrate metadata-safe nest information edits 2026-10-16 06:52:33 -04:00
ajandClaude Opus 5.5 f05533f69c merge: integrate inspection-view navigation lock with live header zoom
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:46:12 -04:00
ajandClaude Opus 5.5 7d254ce18d feat(desktop): remove or restore a clamp by right-click
Operators sometimes pull a clamp for a manual layout, but only Auto Nest's
last-resort search could take clamps off a sheet. Right-clicking a clamp in
Select mode now offers to remove it from that sheet or put it back, reusing
the existing per-sheet RemovedClamps, so the zones, cut-time charge, display
and .nest persistence all behave as they do for Auto Nest removals.

The change is refused while a nest run or fill may be reading the clamps,
sharing the guard the Clamps dialog already used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:20:30 -04:00
aj b305b56876 test(browser): run asynchronous edits with the UI dispatcher 2026-10-16 00:18:55 -04:00
aj f4dd8d2a85 fix(browser): preserve all current nest metadata during edits 2026-10-16 00:07:37 -04:00
aj 9f0d4f37ce feat(browser): edit nest information without opening the nest 2026-10-16 00:03:32 -04:00
aj d028d82f0a fix(ci): follow the material-marker overlay acceptance test 2026-10-15 23:41:57 -04:00
aj ee3bc3aabe docs(geometry): align collision guidance with overlap diagnostics 2026-10-15 23:32:20 -04:00
aj 12f2ce5deb fix(geometry): stop PolyLabel refinement when centres cannot advance 2026-10-15 23:31:14 -04:00
aj 1672b62717 docs(diagnostics): state the tiny-clamp and grid-threshold marker limits
The spec review found two places where the marker and threshold
contract does not hold.

A clamp narrower than the chord tolerance is still judged
tolerance-free against finely re-read material and reported as the
whole clamp rectangle. That existing safety policy is kept, so its
marker is the clamp's centre: within half the clamp's size (under
0.0005) of the material, though not necessarily on it. Document this
on PlateOverlapPair.Marker and in the overlap docs, and lock it in the
tiny-clamp region test.

Also document that 1e-8 grid rounding can move an area within
rounding error of the 1e-5 threshold to either side of it.
2026-10-15 23:31:14 -04:00
aj 83b606c486 fix(geometry): end PolyLabel's starting grid where steps stop advancing
Far from the origin a grid step smaller than the coordinate's
precision leaves x (or y) unchanged: at 1e16, 1e16 + 1 == 1e16. The
starting grid loop then never ended and allocated cells without limit,
even with the new maxCells budget (a hang dump reached 4.3 GB). Stop a
row or column once its step no longer advances.

Grids that advance are untouched, so the bit-identical differential
against the former sorted-list implementation still passes. Found by
the independent quality review of the Clipper2 overlap branch.
2026-10-15 23:31:14 -04:00
aj 80d01839bd feat(diagnostics): place overlap markers on the shared material
The overlap crosshair sat on the pair's area centroid. For a split,
holed or concave overlap, that point can be off the red material:
between two patches, in a hole, or inside a curve.

PlateOverlapPair.Marker is a point inside the pair's largest island.
It is the island's pole of inaccessibility (PolyLabel), searched to a
tenth of the island's area/perimeter within a 4096-cell budget. If
that bounded search does not land inside (a long thin sliver), the
marker falls back to the midpoint of the widest interior run of a
horizontal scan line placed between vertex heights.

The overlay crosshair, marker hit testing, hover coordinates
("Marker:") and the cutting-inspection ring now use the marker.
Centroid stays the true area centroid. View > Overlap Check > Display
> Centroids is now labelled Markers.

Before the marker existed, its call sites used the centroid. Two
patches, a covered frame, a half annulus and a thin crescent then put
it off the material; now all four land on it. Disabling the scan-line
fallback fails its test. On the 389-plate corpus, markers add about
0.4 ms per overlapping pair; pair and area results are unchanged.
2026-10-15 23:31:14 -04:00
aj dc0aab06fc perf(geometry): heap queue and optional cell budget for PolyLabel
PolyLabel kept its candidate cells in a sorted List, which costs
O(n^2) through Insert and RemoveAt(0). A 48 x 0.002 sliver searched
to 1e-4 took about 27 s.

Use a PriorityQueue keyed by (MaxDist, insertion order). Equal bounds
still pop newest first, and initial cells in sorted order, so every
existing caller gets bit-identical poles. A frozen copy of the
sorted-list implementation checks this on 1800 random shapes and on
symmetric shapes with tied poles. Swapping the tie order fails 25 of
the differential rows.

A new Find(outer, holes, precision, maxCells) overload stops after
about maxCells distance evaluations and returns the best cell so far.
When the starting grid would exceed a quarter of the budget, it is
coarsened so the whole shape is sampled first. The unbounded overload
behaves as before.
2026-10-15 23:31:14 -04:00
aj f04d9f298b perf(diagnostics): compute overlap areas with Clipper2
The material-overlap diagnostic clipped every triangle of one part
against every triangle of the other, then carved each fragment by
every hole triangle. Curved overlaps produced thousands of fragments,
and a part sitting inside another part's cutout was slow even when
clear.

PlateOverlapAnalyzer now intersects each candidate pair's hole-aware
material once with Clipper2, in part-local coordinates on a 1e-8 grid.
Each operand is its outline minus the union of its holes (positive
fill rule). Regions become connected islands: a counterclockwise
outline plus clockwise Holes, including an island that both parts'
holes enclose together. PolygonAreaMoments.TrySubtract gives each
island's net moments. A pair is reported when its total shared area
exceeds Tolerance.Epsilon. Out-of-range or nonfinite coordinates make
the pair incomplete rather than clear. The overlay and the cutting
inspection highlight fill outlines with their holes.

On 389 real PEP-converted plates the pair, clamp-finding and issue
sets are identical to the previous kernel. Pair areas are equal or up
to 0.0002 larger: the kernel drops fragments below its area threshold.
With material prepared, the per-plate clipping maximum fell from
2.6 s to 52 ms (total 5.0 s to 0.5 s).

Engine and validation checks still use Collision.
2026-10-15 23:31:14 -04:00
aj 8663463945 fix(diagnostics): close the tiny-clamp finding rectangle
PlateOverlapRegion vertices repeat their closing vertex, and the
desktop overlay drops that last vertex when it builds the fill path.
The tolerance-free tiny-clamp finding returned four open corners, so
the overlay drew it as a triangle. Close the ring like every other
region.
2026-10-15 23:31:14 -04:00
aj 1c05f65571 docs(geometry): put the Collision GPU port on hold
The hand-written kernel was kept Clipper-free so it could be ported to
a GPU. That port is on hold in favour of Clipper2, starting with the
material-overlap diagnostic. Engine and validation callers stay on the
kernel until a Clipper2 near-contact differential and the layout gate
pass. The former port contract stays documented for reference.
2026-10-15 23:31:14 -04:00
aj fb92b75c06 fix(cutoff): give uncached CutOff.Regenerate the converted-material envelope
A direct CutOff.Regenerate(plate, settings) call with no perimeter
cache argument trusted Part.BoundingBox plus a sub-epsilon roundoff
allowance. A legitimately larger arc refit (ConvertProgram fitting
an imported arc's center to its chord) can protrude past that cached
box by more than the roundoff, so an uncached manual cutoff could
emit a segment crossing the part's true converted material.

Every production call site (Plate.RegenerateCutOffs,
AutomaticCutOffPlanner, the desktop cutoff tools) already builds and
passes a Plate.BuildPerimeterCache dictionary, so this only affects a
direct/uncached caller. Extract the per-part conversion out of
BuildPerimeterCache into Plate.BuildPartPerimeter and use it as a
lazy, on-the-fly fallback in CutOff.ComputeSegments whenever no cache
dictionary is supplied at all, so the uncached path gets the same
conservative envelope as the cached one instead of silently trusting
the bare bounding box. Cached behavior (including a cache's own
explicit null entries from a failed conversion) is unchanged.

OpenNest PM 13f7a685-0564-4cbf-8f46-a7805a0288a0.
2026-10-15 23:26:23 -04:00
ajandClaude Sonnet 5 a62f9a359e Merge branch 'feat/inspection-view-navigation'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-15 21:12:38 -04:00
ajandClaude Sonnet 5 f99ba97b28 fix(desktop): keep Cut-off Settings button visible beside the checkbox
The automatic scrap cut-offs checkbox's dynamic text (spacing and tail
length) could grow wide enough to push the adjacent Cut-off Settings
button past the side panel's edge. Wrap the checkbox text and stack
the button below it instead of beside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-15 21:12:18 -04:00
aj 04c9f748f9 Merge remote-tracking branch 'github/master' into feat/inspection-view-navigation 2026-10-15 07:42:46 -04:00
aj 1db0ef04f0 fix(desktop): refuse Expand Spacing while a nest or fill is running
Expand now reads the plate on a worker while its Cancel window pumps
messages, so a still-running fill could commit parts into the plate the
search is enumerating (InvalidOperationException: collection modified).
Refuse the command while whole-job nesting, a NestProgressForm or a fill
on any view of the nest is active. The usual plate-action guard is not
reused: ActionSelect reports busy whenever parts are selected, which
Expand requires.
2026-10-14 20:52:52 -04:00
aj 855d686a6a docs(expand): describe cancelling a long Expand Spacing search 2026-10-14 20:39:58 -04:00
aj b7779904f6 feat(desktop): add Cancel to Expand Spacing
Expand Spacing ran its search on the UI thread, so a complex layout froze
the app for minutes with no way out. Run the search on a worker; when it
lasts past 0.3 s, show a small modal window with elapsed time and Cancel.
Cancel, Escape and the close box request cancellation through the
Expander token and the window stays open until the worker stops, so the
layout is never touched afterward. A cancelled run moves no part and
reports it in the status bar instead of the "select two parts" notice.
2026-10-14 20:39:58 -04:00
aj f9e3c361b4 fix(cutoffs): trim lines through clearance-outline vertices to the part
A cut-off line passing exactly through a vertex of the clearance outline
got duplicate intersection hits and fell back to the part's bounding box.
Automatic strip lines sit on a 0.05 lattice that often lands on such
vertices, and the strip search preferred them because the box fallback
measured as less cutting, so planned cut-offs looked box-trimmed while a
slightly moved manual line was trimmed correctly.

Slice the polygonal clearance outline exactly with half-open edges:
crossing vertices count once, touching vertices zero or twice, edges on
the line are excluded whole. Zero clearance and non-polygon outlines keep
the existing path and its conservative fallback.
2026-10-14 20:18:32 -04:00
aj 8834abc854 fix(desktop): pick up a drawing on mouse down in the parts list
Choosing a part from the editor's drawing list armed the pickup only in
Click. Moving the mouse more than 3 px before release started a
drag-and-drop, which takes mouse capture, so the list never raised Click
and nothing attached to the cursor on the plate.

The list now raises DrawingPickedUp on a single left press over a row
and the editor arms the pickup there. Dragging onto the plate still
drops a part at the release point; a completed drop clears the pickup so
a second copy does not follow the cursor. Presses below the last row,
right presses and the second press of a double-click do not pick up.
2026-10-14 16:11:23 -04:00
aj 720c3d9b7a fix(desktop): allow pixel rounding in cutoff drag test
The Windows run 38614147947 failed ActiveCutOffDragNeedsLeftMotionAndEndsAfterSelectionClears because a screen-space drag to world X=11 rounded to X=10.96. Assert bounded movement, then verify the released coordinate stays unchanged.
2026-10-14 14:23:52 -04:00
aj e8c0eea0b0 test(desktop): guard cutoff drag mouse lifecycle 2026-10-14 14:19:08 -04:00
aj bf8ca19ce5 fix(desktop): finish cutoff drag and regenerate after mixed delete 2026-10-14 14:10:39 -04:00
aj 21d38beb53 fix(desktop): select cut-offs with plate windows 2026-10-14 14:10:39 -04:00
aj 18be8a2c81 Match cutoff and remnants icons to cutting toolbar 2026-10-14 13:50:50 -04:00
aj efc2b73709 Add cutting action icons and toolbar shortcuts 2026-10-14 13:50:50 -04:00
aj 1d745d20ba feat(desktop): draw cut-off lines as a prominent orange-red dash
Cut-off scrap lines were a solid 1.5px near-black stroke that read like
another sheet/part outline. They now render as a 2px orange-red dash so
operators can spot scrap cuts at a glance, and the manual cut-off preview
is recolored to match the applied line. Selected cut-offs keep their solid
blue highlight.

Add bitmap regression tests (CutOffDisplayTests) that verify the dashed
orange style, the dashed gaps, and the unchanged selection highlight.
2026-10-14 12:01:11 -04:00
aj 29f48789dd Merge remote-tracking branch 'github/master' into feat/inspection-view-navigation 2026-10-14 10:21:03 -04:00
ajandClaude Opus 5.5 5180ce1506 feat(inspection): keep header zoom live while plate navigation is locked
Validation inspection disabled the whole plate header, so the zoom buttons
and Ctrl+Plus/Ctrl+Minus stopped working even though wheel zoom and fit stay
available. Group the plate navigation buttons into their own container so the
inspection scope can lock them while leaving the viewport-only zoom controls
and shortcuts live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:20:57 -04:00
aj 74f32f0516 fix(nests): keep etch marks when clearing lead-ins of PEP nests
PEP-converted nests stored etch marks only in each placed part's locked
cutting program; the drawing program held the contour cuts alone. Because
Remove Lead-ins rebuilds a part from its drawing, clearing lead-ins also
erased the marks from every part.

Recover the marks while reading the nest: when a drawing has no scribe
moves and every placement that kept its saved program carries the same
marks, add them to the drawing in its own frame and refresh that drawing's
clean placements. Saved programs are never rewritten, drawings that already
have marks are untouched, and drawings whose saved programs disagree are
left alone rather than guessed at.
2026-10-14 06:58:24 -04:00
aj 6424512e8c fix(expand): distinguish open-path material entry from bounds 2026-10-13 22:34:21 -04:00
aj 27f8c9799b fix(expand): measure open cutoff segments and refuse uncertain curved gaps 2026-10-13 22:11:12 -04:00
aj 775ece5d21 fix(expand): certify disk and cutoff paths and report invalid input 2026-10-13 21:49:28 -04:00
aj cad1083287 test(expand): bound pinned relaxation passes 2026-10-13 21:31:57 -04:00
aj 4183af4142 fix(expand): retain convex exits and report tiny clamp overlap 2026-10-13 21:31:57 -04:00
aj 73be301ebf test(expand): verify cancellation after scratch movement 2026-10-13 21:31:57 -04:00
aj fdc6f074ee fix(expand): distinguish part blockers from plate edges 2026-10-13 21:31:57 -04:00
aj dc4b07ddf5 fix(expand): separate parts touching cutout walls 2026-10-13 21:31:57 -04:00
aj 6c76617905 fix(expand): clip translations against material along their path 2026-10-13 21:31:57 -04:00
aj 43e08965b9 fix(expand): preserve entry clearances and retry blocked pairs 2026-10-13 21:31:57 -04:00
aj ddd1e422be feat(cutoffs): cut sheet-separating cut-offs after every part 2026-10-13 21:09:35 -04:00
aj b9a387b517 feat(desktop): choose cutting-planner cut-off settings in the automatic cut-off dialog 2026-10-13 20:55:24 -04:00
aj 9f2ece37ed feat(desktop): add automatic cut-offs, existing-work banner and clear button to the cutting planner 2026-10-13 20:39:43 -04:00
aj 81cccfc610 feat(cutoffs): stage revertible automatic cut-offs and clear applied cutting work 2026-10-13 20:39:43 -04:00
aj c107ab13f7 fix(cutoffs): preserve exact integral scores in strip search
Avoid shortest round-trip decimal text for already-integral doubles. The failing tie regression and the independent 9,163-case exact-integer sweep now keep the fewer-pierce result.
2026-10-13 20:12:44 -04:00
aj 57450ecea8 fix(cutoffs): honor candidate budget and truthful cut-length ties
Reserve nominal-line slots in the candidate lattice, compare rounded lengths in exact micro-units before pierce ties, and report real fixed-grid cut length. Per-line notices name local increases instead of negative savings. Failing-first regressions cover dense spacing, decimal ties and the legacy summary.
2026-10-13 20:01:48 -04:00
aj b245290664 test(cutoffs): verify shifted lines through apply save and rerun 2026-10-13 19:45:22 -04:00
aj 6d5d963654 feat(cutoffs): move strip lines off clamps 2026-10-13 19:41:57 -04:00
aj 178954e2df feat(cutoffs): keep existing cut-offs as strip boundaries 2026-10-13 19:41:57 -04:00
aj f63fee7392 feat(desktop): add minimum strip and cut-length summary to automatic cutoffs 2026-10-13 19:34:02 -04:00
aj 5857e7fc69 feat(cutoffs): shift strip lines to shorten cuts 2026-10-13 19:29:32 -04:00
aj 15055aef4d feat(cutoffs): measure a cut-off line without building its program 2026-10-13 19:29:32 -04:00
aj c57e0ded83 fix(diagnostics): keep tiny clamps beyond coarse chords in the overlap check
Check Overlaps dismissed a tiny clamp by the part's coarse material bounds
before the fine tolerance-free test ran, but that material is inscribed at
the 0.001 chord tolerance: real material in the sliver beyond a convex arc
(a short arc cap, a rotated crescent) lay outside those bounds and the clamp
read as clear. For clamps below the tolerance the bounds are widened by the
chord tolerance before rejecting.

PM 407f5eb7-329f-41e4-947e-64ad52aa4034 (review s6-delta3-review.md)
2026-10-13 18:15:50 -04:00
aj f2cc471a33 fix(clamps): test tiny clamps against both arc flattenings
Circumscribing every arc holds all material along convex arcs but drops a
sliver along concave ones (a notch), so a tiny clamp 6e-7 into the material
beside a notch read as clear. Tiny-clamp checks now test the part flattened
both ways (all arcs inscribed, all arcs circumscribed): along any arc one of
the two holds the real material, so together they find every real overlap.

PM 407f5eb7-329f-41e4-947e-64ad52aa4034 (delta review s6-delta2-review.md)
2026-10-13 18:15:50 -04:00
aj 07cba5719f fix(io): write an empty sheet whose rail differs only in removal time
The writer's sentinel rule compared zones only, so an empty sheet following
a rail that differs from the default only in clamp removal minutes was
dropped on save although ChangePlateDefaults treats it as configured stock.
The rule now also requires the sheet to follow the default rail itself.

PM 1469b4e0-dc59-416b-8118-560b726aa1e1
2026-10-13 18:15:50 -04:00
aj 9df9a802cd fix(clamps): flatten part material conservatively for tiny clamps
Fine inscribed flattening still let a native arc cut 6.2e-7 into a 1e-5
clamp unseen: less than one chord's sag, so the layout check, interactive
fill and Check Overlaps all called it clear. Tiny-clamp checks now
circumscribe perimeter arcs and inscribe cutout arcs (lines stay exact), so
the polygons hold every bit of real material. Any real overlap is reported;
the cost is a possible report within the tolerance of an arc, never a false
clear. The analyzer builds the same polygons instead of a second
OverlapMaterial read.

PM 407f5eb7-329f-41e4-947e-64ad52aa4034 (delta review s6-delta-review.md)
2026-10-13 18:15:50 -04:00
aj 1bcead4b3a fix(io): write empty clamp sheets whose spacing differs from the defaults
The writer omitted an empty rail sheet when its size, quadrant and zones
matched the defaults, ignoring spacing, so a sheet left at the old part or
edge spacing after a defaults change (configured stock under C17) was
dropped on save. The comparison now includes edge and part spacing.

PM 455f6763-f0aa-40da-b9ad-11de4b2ea95b
2026-10-13 18:15:50 -04:00
aj d9273c889f fix(io): keep a sheet's own clamp rail and its removals through save and reload
Commit can attach the stock's rail to a sheet whose nest has no (or another)
default rail, but only the default rail was saved and the reader re-attached
sheets only to it, so the removed clamp and its time vanished on reload. A
sheet following a rail other than the default now saves it in an optional
per-plate clampRail (read leniently, omitted otherwise), and the reader
re-attaches the sheet to it.

PM 22daec3d-dabb-4aae-95f7-c1d090d5dcdf
2026-10-13 18:15:50 -04:00
aj 4226c0f902 fix(engine): find the fewest clamps to remove, not only neighbouring runs
Recovery only tried contiguous runs of clamps, so a part with a notch over
a middle clamp had all three clamps removed although the two outer ones
were enough (D8: 'the fewest clamps that let it fit'). One probe per clamp
now finds the clamps every removal must include (a part that cannot fit with
only that clamp left needs it off; removal is monotone), and the search
tries every set by size, mandatory clamps plus the optional ones nearest the
origin first. Full-sheet parts resolve with one probe per clamp.

PM 3df03af2-35a6-49c2-9082-fc6208f5b708
2026-10-13 18:15:50 -04:00
aj 6908e00969 fix(clamps): judge tiny clamps against the part's real material
The tolerance-free rectangle test was fed approximated material: the layout
check passed it the 1e-4-grid validation outline (a strip of real material
that wholly contains a 1e-5 clamp could vanish), and interactive fill and
the overlap check passed 0.001 inscribed arc chords (a clamp inside a disk
but outside its chords read as clear). All three now flatten the native
contours at a quarter of the clamp's smaller side, lines kept exact, so the
arc-chord sliver can never hold the clamp. Ordinary clamps are unchanged.

PM 407f5eb7-329f-41e4-947e-64ad52aa4034
2026-10-13 18:15:50 -04:00
aj e38980e54b docs(clamps): describe last-resort clamp removal and current clamp coverage
automatic-nesting.md still said interactive fill/push, the overlap check and
cutoffs ignore clamps, and nesting-engines.md listed the large-spacing and
tiny-zone defects fixed in this slice. Both now describe current behaviour,
and the new last-resort removal section states its limits (1000-probe budget,
sub-grid clearance) as settled with the user (plan D12-D15).
2026-10-13 18:15:50 -04:00
aj a1914e147a fix(engine): reuse clamp-removal probes across identical stock
Offering several identical sheets (same size, spacing and clamps) multiplied
every feasibility probe, so eight full-sheet parts on four 48x120 options
with the eight-clamp rail spent the 1000-probe budget and recovered nothing
although each part fits with its clamps removed. A probe's answer is now
reused for the same part, engine and physically identical stock.

Delta review (s3b-delta-review.md) policy gate 3; PM 0e655fdb-a99a-40d8-968a-1863d8068c3e
2026-10-13 18:15:50 -04:00
aj 64ebda2f6f fix(engine): let clamp recovery move on to another stock with sheets left
Each blocked requirement was tied to the one stock where it needed the
fewest removals; once that stock's finite pool ran out the part stayed
unplaced even with another offered stock untouched. Recovery now runs in
rounds: a blocked part with demand left takes its best removal among the
stocks it has not tried that still have sheets (that stock's own minimal
run, C11). A variant used in several rounds stays one stock entry.

PM bf4438ac-929a-405f-ba26-fcfaa9b1c55e
2026-10-13 18:15:50 -04:00
aj 360f58c101 fix(engine): record a committed clamp removal whatever the target sheet holds
SyncClampZones returned early when the empty target already held exactly the
solved reduced zones, and otherwise preferred the target's own rail over the
stock's, so a removal committed onto such a sheet kept no rail, removed ids
or time. The stock's rail now wins, and the rail/removal state is reconciled
even when the zones themselves need no replacement.

PM 60f7a673-455e-4a87-b865-a4806f417ee6
2026-10-13 18:15:50 -04:00
aj 6fc1a0ebdc fix(core): move an untouched trailing sheet to new nest defaults
Changing the nest defaults (Nest Info, Set as default, Edit Plate on an
empty sheet) left the editor's trailing empty sheet on the old size and
quadrant, so the writer's clamp-preserving empty-sheet rule saved it as a
stale extra sheet. PlateManager.ChangePlateDefaults wraps a defaults edit:
a trailing empty, cutoff-free sheet that exactly matched the old defaults
(and followed their rail with no removed clamps or explicit zones) takes the
new defaults; every other empty sheet keeps its configuration and is still
written.

PM cade6901-5f4a-4503-85bf-0ea681d9ca7f
2026-10-13 18:15:50 -04:00
aj 6b995ac40a fix(core): make a plate's removed-clamp set immutable
Plate.RemovedClamps exposed a HashSet (and every untouched plate shared one
static empty HashSet), so a downcast could change one plate's removal state
or every plate's at once without re-resolving zones. It is now an
ImmutableHashSet.

PM 6ded8ac4-84fd-4e8b-9a3b-6f41e494542c item (e)
2026-10-13 18:15:50 -04:00
aj 7ed40d4bb8 fix(core): charge removal time only for removed clamps on the sheet
Plate.ClampRemovalMinutes multiplied every listed removed id by the
removal time, including ids the rail places beyond the sheet after a resize
or does not know at all. Only removed clamps the rail resolves on this sheet
are pulled, so only those add time; the ids themselves stay listed (C13).

PM 6ded8ac4-84fd-4e8b-9a3b-6f41e494542c item (a)
2026-10-13 18:15:50 -04:00
aj 04d538b0a4 fix(io): omit removedClamps from sheets without a removal
Every plate was written with "removedClamps": [], so files without the
feature changed. The key is now omitted unless a clamp was removed, like
the other additive sections; nest-file-format.md documents removedClamps
and removalMinutes and their lenient reading.

PM 6ded8ac4-84fd-4e8b-9a3b-6f41e494542c item (c)
2026-10-13 18:15:50 -04:00
aj 65754957cc fix(io): keep the clamp rail when its saved removal time is invalid
A negative removalMinutes in a saved nest made the ClampRail constructor
throw, so the reader dropped the whole rail and every sheet lost its
removed-clamp state and time. An invalid value now falls back to the
default five minutes, like a missing one.

PM 6ded8ac4-84fd-4e8b-9a3b-6f41e494542c item (b)
2026-10-13 18:15:49 -04:00
aj 53db543d6b fix(io): read a malformed removedClamps value without failing the nest
The optional per-plate removedClamps list was deserialized as a typed
List<string>, so a string or a list of numbers threw JsonException and the
whole nest failed to open. It now reads leniently: a non-array value reads
as none removed and non-string entries are skipped. The saved zones load
either way, so a removed clamp's area is never silently re-blocked or lost.

PM 9f3d676b-8a1c-4d9a-b877-b5e01e70c3f0
2026-10-13 18:15:49 -04:00
aj 2b208c9b6a fix(engine): bound the clamp removal search
Recovery searched every contiguous clamp run per blocked part and stock with
whole-engine solves and no bound: 25 parts on four 20-clamp stocks cost
21,025 extra solves even though none could fit at all. Each stock is now
first probed once with every clamp removed (dismissing stock the part cannot
use and choosing the lanes worth asking); the run search goes size by size
across the remaining stock and stops at the first fit; and one recovery may
spend at most 1000 probe solves, after which it keeps the ordinary result.

PM 0e655fdb-a99a-40d8-968a-1863d8068c3e
2026-10-13 18:15:49 -04:00
aj b46310e01b fix(engine): solve each clamp removal on its own sheets within the stock pool
Phase 2 offered every removal variant to all residual demand in one solve,
each variant as unlimited stock. A sheet could take more clamps off than its
own parts needed (C11), and a finite pool could yield extra physical sheets
(C12). One root cause, one repair: each variant is now solved separately,
fewest removals first, with its own blocked parts plus unblocked residual
demand; a sheet is kept only when it holds a part that needed exactly that
removal; kept sheets come out of the base stock's remaining pool and the
plate cap. The expanded job reduces each finite base by what its variants
took, so validation sees the true inventory. Variants are cloned from the
exact sheet-frame zones instead of a translate round trip.

PM f91e054a-6f62-4080-91ed-2e1ce1f6eb7e; PM 245a555f-4f16-40b0-a3c9-430c661ad6a6
2026-10-13 18:15:49 -04:00
aj f19a825c4e fix(engine): treat a part as clamp-blocked only when no lane can place it
Last-resort removal treated the selected engine's failure to place a lone
part as proof it could not fit with the clamps in place. Rectangles packs
bounding boxes, so a concave part whose material clears a clamp lost that
clamp for nothing (C10). A part now counts as blocked only when the caller's
engine, Irregular (true material, NFP) and Rectangles all fail on every
offered stock; Default already runs both lanes.

PM c5984c6e-3882-4d15-8a42-8de76b4c2080
2026-10-13 18:15:49 -04:00
aj af50da7609 fix(engine): remove only rail clamps and always name them on the sheet
Last-resort removal ran on any clamped stock, but only a rail-following
plate can record which clamp to pull and its removal time: an explicit-zone
target lost the instruction entirely. NestPlateStock now carries the rail its
zones were resolved from (NestStockBuilder, DrawingJobMapper.FromPlate);
recovery removes clamps only from rail-backed stock, and commit falls back to
the stock's rail when the target plate has none. Runs are now ordered by
distance from the origin along the edge, not by zone list order.

PM 346d46ed-9336-4706-9392-52ed14d6bd33; PM 6ded8ac4 item (d)
2026-10-13 18:15:49 -04:00
aj 3bf63b74f9 fix(engine): keep the ordinary Auto Nest result when clamp recovery fails
A throwing feasibility probe or residual solve inside last-resort clamp
removal propagated out of NestPipeline.Run and discarded the usable ordinary
proposal. Recovery is optional: any non-cancellation failure now returns no
recovery; cancellation still propagates.

PM 1db4333f-96fc-4e3e-8643-47e1120f312c
2026-10-13 18:15:49 -04:00
aj d24e660286 fix(diagnostics): report parts covering a tiny clamp in the overlap check
Collision.Check finds no overlap region for a clamp smaller than its
tolerance, so Check Overlaps reported a part covering a 1e-5 clamp as clear.
Clamps below 0.001 are judged with KeepOutOverlap and reported as a finding
whose fragment is the whole clamp rectangle; flush and clear parts stay clear.

PM aa995123-295f-479c-adc4-b0cbcb7cf838
2026-10-13 18:15:49 -04:00
aj 9b5885e5a2 fix(clamps): judge tiny keep-outs tolerance-free in interactive fill
InteractiveClamps.MaterialInKeepOut flattened a sub-tolerance keep-out with
the 0.001 polygon tolerance and called a part covering a 1e-5 clamp clear,
so the fill safety net could pass it. Keep-outs below the tolerance now use
KeepOutOverlap. Adds the 1e-5 zone sweep through every fill strategy and a
push (characterization; both already stay clear).

PM b0062c73-40ef-440e-8efb-b94908f7c2d1
2026-10-13 18:15:49 -04:00
aj 8f0c0692fd fix(validation): check tiny clamp zones without outline collapse
A clamp zone smaller than the validation outline tolerance went through
ClipperBridge.OffsetForValidation, collapsed to a null outline and made
NestLayoutCheck (and Default scoring) throw. The keep-out is now an exact
four-vertex ring, and a keep-out below 0.001 on either axis is judged by a
tolerance-free rectangle test (KeepOutOverlap): positive-area overlap only,
hole-aware, never a silent clear. Tolerances are unchanged.

PM 6859db53-e39b-4135-85c6-4967037cf880
2026-10-13 18:15:49 -04:00
aj 3f0a2703fd fix(rectangles): keep large-spacing clamp blocks off the clamp
When part spacing exceeds a clamp's inflated span the per-axis block
interval [zone - clearance + s, zone + clearance] inverts. The old
collapse to the zone centerline was not conservative: a part could
start exactly on the centerline with material inside the clamp
(PM d86d22c9 - the probe rail Box(0,0,5,0.625) at part spacing 4
completed at y=0.3125). Per decision D11, an inverted span now blocks
a zero-extent line at the clamp edge facing the sheet interior:
zone max + clearance when the zone center is in the bin's lower half
on that axis, else zone min - clearance + s, so interior-side parts
keep the exact bottom-edge clearance and the unavoidable extra
exclusion lands toward the nearer sheet edge.

Failing-first: the PM probe fixture in quadrants 1-4 and two X-inverted
fixtures (mid-rail and far-rail) were red on ab50900 against an
independent material-vs-inflated-footprint oracle; all six now pass.
Non-inverted spans stay bit-for-bit identical: four keep-row layout
digests captured from the unfixed ab50900 packer are unchanged, the
between-clamp rows still pack the gap, and the three zero-clamp C2
baseline hashes are untouched. Full Engine suite 929 passed/2 existing
skips.
2026-10-13 18:15:49 -04:00
aj 83ae1a48f0 test(diagnostics): lock the start-point clamp rapid warning
D10 (user decision 2026-10-13, keep the warning): a clamp footprint covering
the modeled program start (0,0) makes the first rapid of every posting attempt
cross it, and that warning has no exemption and no persisted bypass. Lock the
behavior in all four quadrants with a physical-footprint oracle and a mutation
red (skipping rapids that start inside a zone reddens exactly the four
origin-covering rows). Document the design in docs/post-verification.md.
2026-10-13 18:15:49 -04:00
aj 92d8964339 fix(engine): return detached clamp zones from plate stock
NestPlateStock is documented as immutable input, but ClampZones handed
out its own zones, whose bounds are mutable. Editing one after the
stock was built moved the stock's clamp while plates materialized from
the stock (layout check, fill, posting) kept the original zone, so a
part or cut on the moved clamp validated and posted clear.

Return detached copies on every read, so the stock and its plates can
never disagree. Copies keep the exact bits; engine results are
unchanged.

Project Memory: opennest 14f93b26-2e41-41f1-b09f-87dc895bb4da
2026-10-13 18:15:49 -04:00
aj df2f1b796d fix(engine): keep every plate's clamp zones in the sheet frame
Plates the engine built from stock (DrawingJobMapper.CreatePlate, used
by the public result materializer, the layout check and the placement
fillers) stored placement-frame zones, while live plates store them
relative to the sheet's lower-left corner. The same Plate.ClampZones
therefore meant two frames: the new clamp cut checks double-translated
materialized plates in quadrants 2-4, and interactive fill and push on
live plates read sheet-frame zones as placement coordinates, so they
could put parts on clamps there.

Plates built from stock now keep the stock's input zones (sheet frame,
no lossy translate-and-back), and the layout check and interactive
clamp obstacles map through ClampZoneFrame.ToPlacement, which
reproduces the stock's placement zones bit for bit, so engine
placements and validation are unchanged.

Project Memory: opennest 14f93b26-2e41-41f1-b09f-87dc895bb4da
2026-10-13 18:15:49 -04:00
aj 3e9e336072 fix(diagnostics): tolerate missing cut-offs and zones in overlap freshness
The desktop captures the overlap freshness stamp before its error
boundary, and a null cut-off or clamp zone in the plate's lists threw
NullReferenceException there. Stamp missing entries as a distinct empty
state so the whole-plate capture can report the malformed input as a
failed check instead.

Project Memory: opennest cdbe2629-b562-46ee-9509-2b120a511654
2026-10-13 18:15:49 -04:00
aj c18db3f896 fix(diagnostics): check placed cut-off programs against clamps
Pre-post clamp checks looked only at nominal cut-off definitions and
rapids. A definition moved without regenerating its placed program
left a cut through an active clamp that posted with no warning, because
cut-off material is excluded from the overlap check.

Also check each cut-off part's placed cutting motions against the
clamps in the placement frame (lines exactly, arcs by their extent).
Report one warning per cut-off part and clamp, so a current definition
does not produce a duplicate of its nominal finding.

Project Memory: opennest cdbe2629-b562-46ee-9509-2b120a511654
2026-10-13 18:15:49 -04:00
aj 9a293bf221 fix(core): compare clamp cut checks in the plate's quadrant frame
Plate.ClampZones are stored relative to the sheet's lower-left corner,
but cut-off positions, motions and part poses use the signed quadrant
frame. The automatic cutoff planner, manual cut-off contacts, clamp
rapid warnings and whole-plate overlap capture compared the two
directly, so in quadrants 2-4 a cut or rapid through a real rail clamp
was neither avoided nor warned about.

Add ClampZoneFrame.ToPlacement, which maps live zones into the
placement frame exactly like the sheet and engine stock, and use it in
every Slice 5 consumer. It rejects missing or malformed zones (bounds
stay publicly mutable); pre-post verification reports those as an
incomplete clamp check instead of a clear result. Overlap freshness now
treats a size or quadrant change as moving the zones.

Tests use real ClampRail zones in all quadrants; the hand-signed
quadrant fixtures that hid the defect are converted to rails.

Project Memory: opennest 274836d5-fb76-4619-b3cc-0c811e767dd6
2026-10-13 18:15:49 -04:00
aj bde332fb10 fix(core): check equivalent existing cutoffs against clamp spans
The automatic cutoff planner tested only the proposed X against active
clamp spans, then certified a tolerance-close existing line at its own
X. A manual line just inside a clamp edge could therefore be reported
as a verified tail separator. Test the proposed X and every equivalent
existing definition before regenerating.

Also keep a same-line limited manual cut blocking the whole plan when
it lies on a clamp, as it did before clamp checks were added.

Project Memory: opennest 51597040-cff6-4304-87dc-3c2a13edf45a
2026-10-13 18:15:49 -04:00
aj d7187f65c7 feat(diagnostics): warn on cutoffs and rapids crossing clamps 2026-10-13 18:15:49 -04:00
aj 3014ab3480 feat(core): keep scrap cutoffs clear of clamp zones 2026-10-13 18:15:49 -04:00
aj 104b0aef99 fix(clamps): retain complete groups ahead of partial fill tails 2026-10-13 18:15:49 -04:00
aj 74544c689a fix(clamps): preserve interactive clearance and group integrity 2026-10-13 18:15:49 -04:00
aj 3e58a9e93f feat(engine): respect clamp zones in interactive placement and diagnostics 2026-10-13 18:15:49 -04:00
aj 87849e5326 feat(desktop): show clamps to pull and their time
Slice 3b task 3 (D9 + display). OpenNest.Core: TimingInfo gains
ClampRemovalMinutes (included in +, -, * so every aggregation stays
consistent); Timing.GetTimingInfo(Plate) adds plate.ClampRemovalMinutes
once per physical sheet, before the quantity multiply so it scales
with everything else cut on that sheet; CalculateTime adds it to the
estimate as a flat number of minutes regardless of the cut parameters'
unit. A plate with nothing removed is unaffected (zero).

OpenNest.Data: ClampSettings and ClampRailEditor carry RemovalMinutes
through the same three boundaries as clamp size - the rail/constructor,
the settings file (missing key falls back to
ClampRail.DefaultRemovalMinutes; present but negative/non-numeric/null
is ignored and reported Partial, same as width/depth), and
ToMachineDefaults - except it is a time, not a length, so
ConvertedTo never scales it between inches and millimeters.

Desktop: ClampsForm gets a 'Minutes to remove and replace one clamp'
field wired to the editor; PlateRenderer.DrawRemovedClamps draws each
clamp Plate.RemovedClamps lists (resolved from the full rail, since a
removed clamp is absent from Plate.ClampZones) as a dashed grey
outline with a small 'remove' marker, distinct from an active clamp's
solid hatch and still display-only - no hit test, selection or
sequencing; PlateView calls it right after the active-zone pass.
MainForm's Auto Nest completion flow lists every committed sheet with
a removal as 'Sheet N: remove clamp-X (+5 min)', using
Plate.RemovedClamps/ClampRemovalMinutes from Slice 3b tasks 1-2.

Tests: 10 new Data (ClampSettingsTests, ClampRailEditorTests) covering
round-trip/default/invalid-value handling and the never-scaled-by-units
rule; 4 new Core (TimingTests) covering the per-sheet add, the quantity
scaling, CalculateTime, and nest-level aggregation across plates.
Desktop changes (dialog field, renderer, Auto Nest summary) are
compile-verified only (-p:EnableWindowsTargeting=true), matching the
project's existing WinForms testing bar - no automated UI test exists
for the sibling DrawClampZones either.

Gates: Release OpenNest.Tests 3898 passed/31 skipped, OpenNest.IO.Tests
105 passed, OpenNest.Engine.Tests 878 passed/2 skipped; desktop and
OpenNest.WinForms.Tests cross-builds clean; scoped dotnet format
--verify-no-changes clean.

This completes Slice 3b (D8/D9) on top of Slice 3's rail-following
plates. Not published; master and other feature/clamp-zones-* branches
untouched. Windows runtime/visual acceptance not claimed - the plan's
click-through checklist (after slices 3-5) still applies.
2026-10-13 18:15:49 -04:00
aj 83c77269a1 feat(engine): remove clamps only for parts that cannot fit with them
Slice 3b task 2 (D8/C10-C12). New OpenNest.Engine.Jobs.ClampRemovalRecovery:
after the ordinary clamped solve leaves demand unmet, probes (via the
caller's own selected engine, quantity one, one fresh empty sheet)
whether each unplaced part fits on any offered stock with every clamp
in place; only a part that fits nowhere is 'blocked' (C10). For a
blocked part, searches ascending run size then ascending start index
over each stock's own clamp zones (already rail-position order) for
the smallest contiguous run whose removal lets it fit alone - fewest
clamps first, nearest the origin on ties (C11). Builds reduced-clamp
stock variants for every resolved (part, stock), re-solves the whole
residual demand (so freed space can also take other unmet parts, C12)
with original stock carried forward minus phase 1's usage (a finite
pool is never offered both full and reduced, so it is never double
counted) and merges the result back: phase 2 placements get fresh
instance indices continuing from phase 1's, plates are reindexed, and
fulfillment/stock usage are recomputed. Returns null (no-op) whenever
the job is already complete, no stock has any clamp, no remaining
part is genuinely blocked, or even every clamp removed cannot fit a
blocked part - so an ordinary job is bit-identical to Slice 3 (C10's
last sentence).

NestPipeline.RunCore calls it once, right after the main solve, only
on the non-cutout-preview path; a recovery replaces 'raw' and expands
'job' (adding exactly the reduced-clamp variants used) before the
existing validation/cost/grouping/binding code runs unchanged.

NestPipelineCommit.SyncClampZones now derives which rail clamps (if
any) are missing from a committed sheet's solved zones and, when the
rail recreates exactly that reduced set, reattaches the rail with
Plate.SetClampRail(rail, removedClamps) - so a recovered sheet keeps
following the rail, minus its removal, through later resizes (C13),
using Slice 3b task 1's plumbing.

Tests: 7 new (OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs,
ClampRemovalCommitTests.cs) covering the minimal-removal + nearest-
origin tie-break, freed space taking other residual demand (C12), a
no-op on an already-complete job, no-op with no clamps offered, a
part blocked only by stock exhaustion never being treated as clamp-
blocked, a part unfit even clamp-free staying unplaced, and the full
Auto-Nest-to-commit path keeping Plate.RemovedClamps/ClampRail/
ClampRemovalMinutes correct through a resize. Mutation checks
(disabling the FitsAnywhere gate; reversing the ascending-start tie-
break) turn the relevant assertions red, confirming real coverage.

Gates: Release OpenNest.Tests 3884 passed/31 skipped, OpenNest.IO.Tests
105 passed, OpenNest.Engine.Tests 878 passed/2 skipped; desktop
cross-build (-p:EnableWindowsTargeting=true) clean; scoped dotnet
format --verify-no-changes clean.

Desktop surface (removed-clamp display, Auto Nest summary, cut-time
minutes, Clamps dialog removal-minutes field) is Slice 3b task 3, not
yet done.
2026-10-13 18:15:49 -04:00
aj ec058d270c feat(core): remove individual rail clamps on one plate
Slice 3b task 1 (D8/D9): ClampRail gains RemovalMinutes (D9, default
5 minutes, validated finite >= 0) and Plate gains RemovedClamps (rail
clamp ids taken off this sheet as a last resort) plus the computed
ClampRemovalMinutes. Plate.SetClampRail gains an overload that
attaches a rail with a removed-clamp set; zones become the rail's
resolution for the sheet minus those ids, re-resolved on every
resize/rotate so a removed clamp stays off and reappears removed if a
later resize brings it back onto the sheet. ClampRail.Matches gains an
overload comparing against the reduced resolution.

IO: PlateDto.RemovedClamps and ClampRailDto.RemovalMinutes persist
(missing removalMinutes falls back to the default; missing
removedClamps means none). The writer's empty-plate drop logic no
longer drops a sheet whose only content is a removal state the
defaults can't recreate. The reader re-attaches the nest rail to a
plate only when its saved zones equal the rail's resolution minus its
saved removed ids, so a reduced-clamp sheet keeps following the rail
(and the removal) through later resizes instead of freezing as static
zones.

Engine/desktop wiring (minimal-removal computation, Auto Nest
last-resort removal, cut-time, UI) is Slice 3b tasks 2-3, not yet
done.

Tests: 16 new Core (ClampRailTests) + 5 new IO (ClampRailPersistenceTests)
covering validation, resize/resolve behavior, round-trip, legacy
missing-key defaults, and the empty-plate-with-removal-only retention
case. Mutation check (reverting the RemovedClamps subtraction) turns
3 Core + 2 IO assertions red, confirming real coverage.

Gates: Release OpenNest.Tests 3884 passed/31 skipped, OpenNest.IO.Tests
105 passed, OpenNest.Engine.Tests (Clamp filter) 98 passed; scoped
dotnet format --verify-no-changes clean.
2026-10-13 18:15:49 -04:00
aj bafb311735 feat(clamps): keep the whole footprint of a partly used clamp
A clamp is an avoidance area, so a clamp that only partly reaches a
sheet (for example centered at 60.5" on a 60" sheet, or near the
origin) now keeps its whole 1.5" x 0.625" footprint instead of being
clipped to the sheet; clamps wholly beyond the sheet are still left
off. Job validation accepts a zone that hangs past the sheet edges and
still rejects one that does not reach the sheet at all. Both packing
lanes already block keep-outs extending past the bin; a hanging-clamp
solve through Rectangles, Irregular and Default keeps every part clear
of the whole inflated footprint.

User decision 2026-10-12, settling the plan's pending D2 question.
2026-10-13 18:15:49 -04:00
aj 37700b3ef9 docs(io): state when an empty clamp sheet is omitted
The empty-plate rule drops a rail-following empty sheet only while it
still matches the plate defaults; a trailing sheet created before the
defaults changed is written. Say so instead of promising that the
editor's trailing sheet is always dropped. Comment and documentation
only; behaviour unchanged.

Delta review minor finding, Project Memory 4e5f566d.
2026-10-13 18:15:49 -04:00
aj 5125a5f2f3 fix(desktop): warn about clamp settings on BOM import
BOM import read clamps.json without its status, so an unreadable or
partly invalid file could give BOM-created nests no or partial clamps
silently. New and BOM import now share one loader that warns once per
session, and BOM import reads the settings when it creates the nests.

The Clamps dialog no longer claims that all parts and nesting keep
clear of clamps: it names Auto Nest and states that interactive fill
and push, the overlap check and cutoffs do not avoid clamps yet.

Review findings 7 and 9, Project Memory 4e5f566d.
2026-10-13 18:15:49 -04:00
aj fe7083f4c4 fix(cutting): keep clamp zones on cutting-plan previews
Plan Cutting previews were built without the plate's clamp zones, so
machine clamps disappeared from the canvas while inspecting a proposal.
Copy the plate's immutable zones onto the detached preview; it does not
follow the rail, so it never re-resolves.

Review finding 8, Project Memory 4e5f566d.
2026-10-13 18:15:49 -04:00
aj 1e37c661ff fix(io): harden clamp rail persistence
Omit an empty clamp-carrying plate only when a new plate from the saved
defaults would recreate it: same size and quadrant and zones the saved
rail recreates. A rail-following empty sheet with another size or
quadrant is written again instead of being lost.

Read plateDefaults.clampRail through a lenient converter so a wrongly
typed rail (a string or null width, non-numeric centers, an array in
place of the object) loads as no rail while every plate's saved zones
still load, as the file format documents, instead of failing the whole
nest.

Review findings 4 and 5, Project Memory 4e5f566d.
2026-10-13 18:15:49 -04:00
aj 76b49677c4 fix(data): keep clamp setting fallbacks physical and report nulls
Read clamps.json as a raw document so an explicit null is reported as
an ignored value (Partial, which the desktop warns about) instead of
being taken as an absent field. Missing or invalid sizes now fall back
to the physical 1.5 x 0.625 inch clamp expressed in the file's units,
so a millimeter file no longer gets a 1.5 x 0.625 mm clamp. Values that
would overflow when converted between inches and millimeters are
ignored and reported instead of throwing while creating a nest.

Review findings 2, 3 and 6, Project Memory 4e5f566d.
2026-10-13 18:15:49 -04:00
aj aea51b5e1d fix(engine): stop following a rail that does not recreate solved zones
When a commit installed solved explicit zones on a plate that followed
a different nest clamp rail (an extra sheet created from the defaults),
the plate kept the rail, so a later Size or Quadrant assignment (Edit
Plate assigns Size even when unchanged) or a stock-option build swapped
the validated zones for the rail's clamps. Install solved zones as
explicit zones and keep the rail only when it recreates them.

Review finding 1, Project Memory 4e5f566d-626e-4db5-9352-14ddd9051181.
2026-10-13 18:15:49 -04:00
aj 5c9c7df038 test(clamps): cover the machine clamp workflow end to end
Enabled clamp settings give a new nest its rail; whole-job Auto Nest
through the template stock, validated pipeline and commit keeps every
part clear of the clamps (an independent box oracle grown by the bottom
edge spacing) on every sheet while still using the edge between them,
for Rectangles and Default in quadrant 1 and Rectangles in quadrant 3.
Save and reload keep the rail, each sheet's zones and placements, and
do not write the empty tail sheet.
2026-10-13 18:15:49 -04:00
aj 7069f155f5 feat(desktop): apply and edit machine clamps on the plate
New and BOM import give a new nest the machine clamp rail from
clamps.json when it is enabled, converted to the nest's units; with the
default (off) settings nests are created exactly as before. An
unreadable or partly invalid settings file warns once per session.

Nest > Clamps... edits the current nest's rail: clamp width and depth
and a sorted list of X centers with Add/Remove. OK applies the rail to
every plate (Cancel discards the edits); Load Machine Defaults only
fills the dialog; Save as Machine Defaults writes clamps.json at once
and is labelled as not undone by Cancel. The dialog refuses to open
while nesting or a plate action runs. Its decisions live in the
Linux-tested ClampRailEditor; the form only renders and forwards input.

PlateView draws clamp zones as translucent hatched amber rectangles
over the sheet and parts and repaints when they change. They take part
in no hit test, selection or sequencing.
2026-10-13 18:15:49 -04:00
aj 99f64840b2 feat(engine): resolve the clamp rail for each stock option
When the Auto Nest template plate follows the machine clamp rail, offer
each plate-size option with the rail resolved for that size instead of
copying the template's zones: a shorter option no longer carries clamps
beyond its end (which job validation rejected), a longer one gets the
clamps past the template's length, and sheets extending to negative X
keep their clamps measured from the origin. Templates with explicit
zones and no rail are offered unchanged.
2026-10-13 18:15:49 -04:00
aj 5674764447 feat(io): persist the nest clamp rail
Write the nest's machine clamp rail under plateDefaults.clampRail
(width, depth, centers) so plates created or resized after a reload
still get the clamps. The key is omitted for nests without a rail, and
a missing, empty or malformed rail loads as none while each plate's
saved zones still load.

On load a plate follows the rail again only when its saved zones are
exactly the rail's for that sheet; other zones are kept as written.
The empty-plate filter now drops an empty sheet whose zones the saved
rail recreates, so the editor's trailing empty sheet is not written
for every clamped nest; empty sheets with other zones are still kept.
2026-10-13 18:15:49 -04:00
aj 81e3e58f13 feat(data): clamp settings store
Persist the machine clamp rail in %APPDATA%\OpenNest\clamps.json, in
the same shape as the nest defaults store: camelCase JSON written with
retry, and a read that never throws. Clamps are off by default: a
missing or unreadable file keeps them off, and nothing changes until
the settings are enabled with at least one center.

Each value is read independently; an invalid one (including a single
bad clamp center) is ignored and reported as Partial so the desktop
can warn that the rail may lack a physical clamp. Values carry their
units and convert between inches and millimeters for a new nest.
2026-10-13 18:15:49 -04:00
aj cc85a9e068 feat(core): resolve a fixed clamp rail onto nest plates
Add ClampRail: identical clamps (width along the edge, depth into the
sheet) at X centers measured from the plate origin on the origin-side
edge. It resolves onto any sheet size and quadrant without re-scaling:
clamps wholly beyond the sheet are skipped, one still reaching onto it
is clipped to it, and ids follow rail position (clamp-N) so a physical
clamp keeps its id on every sheet.

A plate can follow a rail (Plate.SetClampRail); its ClampZones are then
derived and re-resolved whenever Size or Quadrant changes, because the
clamps are fixed to the machine. Plates without a rail keep explicit
zones unchanged. Nest.PlateDefaults.ClampRail is the template new plates
follow, and Nest.SetClampRail applies a nest-wide rail to every plate
(an empty rail removes it).
2026-10-13 18:15:49 -04:00
aj 040776807a fix(engine): keep solved clamp zones on committed plates
ApplyToEmptyPlates copied size, spacing, quadrant and quantity from the
solved stock but not its clamp zones, so a sheet the commit created had
no keep-outs although its parts were validated against them, and a
reused plate could keep zones the layout was not solved with.

Map the stock's placement-frame zones back to plate coordinates on every
committed plate; zones already equal in the placement frame are left
untouched so an unchanged plate raises no change events.

Project Memory: bcc2dd2a-2ab6-4a2a-9c13-197e0aeb24bc
2026-10-13 18:15:49 -04:00
aj fbca54637b fix(irregular): retain sub-grid clamp obstacles 2026-10-13 18:15:49 -04:00
aj c374e02614 feat(irregular): treat clamp zones as static NFP obstacles 2026-10-13 18:15:49 -04:00
aj de9a087027 feat(rectangles): avoid clamp zones in sheet packing
Block each clamp's keep-out into the max-rects free list before any
packing: same split logic as placements, not a used item (no contact
score, no material). The blocked rectangle accounts for the inflated
item model exactly - material keeps the bottom-edge clearance from the
clamp without paying an extra part-spacing tax; degenerate spans
collapse to the zone centerline (conservative, never permissive).

Zero-clamp sheets skip blocking entirely: three fixture layout hashes
(rectangle grid, mixed shapes, multi-sheet overflow), captured from the
unchanged packer at a3f1124, stay bit-identical (C2). With clamps:
placements never intrude, demand lands between two clamps when it fits,
a full-width front rail makes an otherwise feasible job Incomplete, and
a far-away zone leaves the layout hash equal to no-clamps.

Rectangles lane only; Irregular parity is Slice 2.
2026-10-13 18:15:49 -04:00
aj dc87911b2f feat(engine): carry clamp zones into stock, validation and repeat identity
NestPlateStock takes an optional clamp-zone list, translated into the
placement frame exactly like WorkArea (quadrants 1-4) and detached from
caller boxes; empty input keeps stock bit-identical to before. Plate
mappings (DrawingJobMapper.FromPlate/CreatePlate, NestStockBuilder) map
zones across; materialized plates carry them for checking.
NestJobValidator rejects zones outside the sheet or duplicate ids.
RepeatedPlateGrouping and NestLayoutCheck treat clamp sets as stock
identity (C8), and the layout checker flags part material inside a zone
inflated by bottom-edge spacing with the shared spacing slack (C1/C5) -
no tolerance loosened.
2026-10-13 18:15:49 -04:00
aj d001abcf69 feat(io): persist clamp zones in nest files
Add the additive per-plate clamps array (id, x, y, length, width) to the
nest DTO with writer and reader support. Files without the key load with
zero zones; an individually malformed entry is dropped while the rest of
the nest loads. A plate carrying only clamp definitions now survives the
writer's empty-plate filter; genuinely empty plates remain omitted.
Format documented in docs/nest-file-format.md.
2026-10-13 18:15:49 -04:00
aj c16092c293 feat(core): add rectangular clamp zones to plates
Introduce ClampZone: an axis-aligned rectangular keep-out in plate
coordinates (id + detached Box), validated finite, positive-size and
identified. Plate.ClampZones is a first-class collection alongside
Parts/CutOffs with a ClampZonesChanged event for view refresh. Zones are
never parts: quantity, utilization, cut sequence and CNC output ignore
them (locked by tests); they carry no z-height reasoning.
2026-10-13 18:15:49 -04:00
aj c04562f56d test(ui): pin the SegmentedCut notice the cutoff dialog details box shows
Windows CI proved the default single-plate fixture is not notice-free;
the details box now surfaces the SegmentedCut notice that the old text
wall buried. Assert the real content instead of a clean box.
2026-10-13 14:10:41 -04:00
aj b66af8658b refactor(ui): make the automatic cutoff dialog scannable at a glance
The dialog buried its verdict in a scrollable text box that restated the
same numbers per plate behind a paragraph-length warning. Replace it
with:

- a bold colored status line stating exactly what Apply will do
  (Ready / Blocked / No new cut-offs / Error with the reason);
- the occupied span, used span, separator X and retained tail as
  compact caption+value cells with tooltips instead of four sentences;
- the warning paragraph reduced to one amber line with the full text
  in its tooltip;
- a per-plate table (cut-offs, spans, tail, Ready/Blocked/No change)
  for the All Plates command, replacing repeated text blocks;
- the details box now lists only issues and notices, prefixed by plate
  number when planning a nest, instead of echoing every measurement.

Planning, preview, apply and rollback behavior is unchanged; tests and
the cutoffs document follow the new presentation.
2026-10-13 13:54:21 -04:00
ajandClaude Opus 5.5 311178c15a fix(cutting): trim baked-in seam overcuts and route parts nested in cutouts
Nests converted from another CAM system carry its seam overcut inside the
clean contour: the last cut runs on past the contour's own start along its
first cut, then a straight jog returns to the start. The ring then touches
itself at its seam, so neither the cutting planner nor the overlap analyzer
could certify it and the whole plate refused.

SeamOvercut recognizes exactly that shape (same line, or same circle and
direction) and trims it from an owned copy; the retraced tail encloses no
area, so material is unchanged and the drawing is never edited. Any other
self-contact still refuses. Arcs of one circle whose centers were each derived
from their own endpoints agree only within Epsilon, so their spans are now
compared at the seam tolerance: shared spans still refuse as overlap, while
arcs that only meet at a joint or lie apart no longer do.

Plates with parts nested in other parts' cutouts then failed to route, since
the host is cut last and every straight approach from the middle of a crowded
cutout crosses a finished neighbour. CuttingNestedOrder tours each host's
inner parts as one run ending beside the host's own contours, hosts follow
the parts beside them, and learned rules still win. Outside entries are
ordered by clear sight lines to the next cut, the look-ahead toward a host is
its nearest contour point rather than its centre, a cutout containing the
arrival point is routed from that point, and a pass that teaches an order
rule ends the round instead of repeating the blocked approach.

Tests: SeamOvercutTests and CuttingNestedOrderTests; the cutting-planning,
diagnostics and overlap tests in OpenNest.Tests pass (1023 passed, 6 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:40:32 -04:00
aj b36db41769 Merge remote-tracking branch 'github/master' 2026-10-12 13:33:13 -04:00
aj e5c82e2c92 fix(mcp): release repeated stock reservations with their owner 2026-10-12 13:10:38 -04:00
aj 522de2761c feat(irregular): reuse completed plate layouts for repeat batches 2026-10-12 12:54:15 -04:00
aj 49017c4a91 feat(rectangles): repeat completed sheets from remaining demand 2026-10-12 12:53:59 -04:00
aj c1d5a827bc fix(nesting): preserve repeated-layout quantities through commit 2026-10-12 12:53:18 -04:00
ajandClaude Sonnet 5 937448242f feat(machine-config): add a material grade tier
MaterialConfig only ever had one grade per material name, so the tree
and GetMaterial/GetParameters couldn't represent e.g. Mild Steel A36
vs. A1011 as distinct, independently-parameterized entries. Prep work
for an upcoming drawing database that will store material + grade per
drawing and needs to resolve cut parameters by that pair.

- MachineConfig gains GetMaterial(name, grade), GetGrades(name), and a
  GetParameters(material, grade, thickness) overload, all keyed through
  the existing SharedListNames normalization. The name-only overloads
  stay for back-compat ("first grade found").
- MachineConfigForm's tree gains a real tier: Machine -> Material name
  (synthetic grouping node) -> Grade -> Thickness. Add/remove gained
  "Add Grade" and split "Delete Material" (all grades) from "Delete
  Grade" (one entry). Renaming a grade's Name relocates its node to the
  matching (or a new) group on focus-loss, keeping the tree in sync
  without a full reload.
- No schema/JSON changes; existing machine files keep working as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 09:26:42 -04:00
ajandClaude Sonnet 5 f74da248b3 refactor(ui): split MachineConfigForm into a Designer-backed partial class
The form's static chrome (tree/detail split, Save/Export/Import bar) was
built entirely in the regular constructor, so Visual Studio's WinForms
designer had no InitializeComponent() to read and showed a blank/broken
surface. Move that static layout into MachineConfigForm.Designer.cs and
add a private parameterless constructor for the designer to instantiate,
following the same pattern already used by EditNestForm. The dynamic,
data-driven detail panel content stays in the regular constructor/methods.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 08:34:05 -04:00
ajandClaude Sonnet 5 c67577d463 refactor(ui): redesign Machine Configuration form
Replace the flat label/textbox list and always-destructive +/- button
row with a tabbed detail panel (General/Lead In/Lead Out/Cut Off/Plate
Sizes for thicknesses) and a right-click context menu on the tree.

Move add/remove/edit to a single "editing session" model: mutations
only touch the in-memory tree until Save, which also prompts on close
if there are unsaved changes. Tree labels now update live instead of
requiring a full reload, which previously collapsed the tree and lost
selection on every add/remove.

Reuse existing domain data instead of free text: Lead Type and Cut Off
Direction become dropdowns sourced from the real option sets elsewhere
in the codebase (CuttingPanel's lead types, OpenNest.CutDirection), and
Plate Sizes becomes a checklist seeded from OpenNest.Shapes.PlateSizes
with a custom-size fallback. Assist Gas becomes an editable dropdown.
Numeric fields show their unit (in/mm) and have sane per-field ranges
instead of a blanket 10000 max. Input controls are capped to a fixed
width instead of stretching across the panel, and the tree pane is
widened to make room for longer machine/material/thickness names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 08:27:57 -04:00
aj 565dc90762 refactor(ui): rephrase negative-tone disclaimers as affirmative guidance
Report, dialog and diagnostic text across post verification, cutoff
planning, alignment and cutting-plan review repeatedly told the user
what checks weren't (safety certifications, proof, guarantees) instead
of what to do. Reworded these to state the same caveats affirmatively
(inspect X, confirm Y, review Z) without changing the underlying
safety guidance, and updated the tests/docs that pinned the old wording.
2026-10-12 07:45:23 -04:00
ajandClaude Sonnet 5 18d63ffd61 feat(desktop): add Velopack auto-update support
Lets the desktop app self-update like CncSender, but only restarts after
the user confirms via a clickable status-bar prompt -- an open, unsaved
nest must never be discarded automatically. The feed URL is a blank-by-
default, per-machine setting (File > Options), since it's operator
infrastructure that doesn't belong in source control; Deploy-OpenNest.ps1
packages releases for whatever feed host/path the operator supplies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 06:54:46 -04:00
aj e3bcc77145 fix(cutting): keep ray parity consistent across nominal closure seams
Point-in-material used exact Y comparisons, but rings are only nominally
closed: capture admits closure seams and PEP-converted or rotated geometry
carries sub-micro-inch vertex jogs. When a jog straddled the test ray's Y,
the two edges at that shared vertex disagreed which side it was on and the
parity flipped, so a point ten inches outside a material read as interior.
LeadMaterialSnapshot reported leads as entering a neighbour they could not
touch, and the enclosing-perimeter check refused to capture materials whose
rings it misclassified.

Snap the ray comparison by the admitted seam tolerance so both edges at a
shared vertex decide together. Features larger than the tolerance still
count, and containment of a point within the tolerance of an edge stays as
ambiguous as before. On the 93-plate corpus 83 plates are untouched, one
previously refused plate now plans and replays, and 48/93 plates reach
Ready. Accepting more leads also changed one plate's first-found route to a
longer rapid path; that is search ranking, recorded with the measurements.

Also drops an extent-prefilter fast path measured at -6.8% corpus time: it
cleared leads whose native contact was reported beyond any fixed bbox
margin, which NearbyMaterialCheckTests deliberately pins as a refusal.
2026-10-12 03:50:54 -04:00
aj df94824054 fix(cutting): replay rings closed within nominal tolerance
Capture and preparation agree that a nominal contour may close within
NominalContourClosure.Tolerance inclusively, which converted drawings rely on:
the sampled production corpus carries vertex gaps up to 1e-6. Replay
verification still compared those rings at the exact geometry epsilon, so the
one joint a rotated tool path legitimately bridges refused the whole plate
after the search had already spent its budget.

Census of 91 plates from 60 stored nests: every searched plate containing such
a seam refused with contour-accounting (4 plates, 76.5s of wasted search, worst
71.7s for one 11-part plate), and none of the 42 ready plates had one. Replay
now carries that same admitted endpoint gap as positional and length slack for
the affected ring only. Direction sign, coverage order, retrace detection, ring
counting and selected-emission binding keep the exact epsilon, and capture's
closure admission is unchanged.

With the fix the census yields 45 ready plates instead of 42, no plate changed
any other way, and expansion counts are identical wherever status matched.
P260805-01's 16-part plate plans in 0.76s rather than refusing after 4,129
expansions.

Tests: SeamClosureReplayTests covers seams of 0, 5e-7 and 1e-6 plus four
accounting faults (missing edge, reversed edge, interior shift beyond the
admitted seam, second circuit) that must still refuse, and the beyond-tolerance
capture refusal. OpenNest.Tests 3751, OpenNest.Engine.Tests 690 and
OpenNest.IO.Tests 79 pass; Windows-only projects cannot build on Linux.
2026-10-12 00:36:48 -04:00
aj 9bd5d0dfa7 fix(cutting-ui): navigate selected source plate in planner 2026-10-11 22:45:54 -04:00
aj d6ae706098 refactor(cutting-ui): retire redundant planning and validation forms 2026-10-11 22:15:35 -04:00
aj f9b2e1282a feat(cutting-ui): inspect live nest validation in docked panel 2026-10-11 21:55:59 -04:00
aj 943b63937d feat(cutting-ui): plan and apply all plates from docked panel 2026-10-11 21:40:26 -04:00
aj 6ed7ab7392 fix(cutting-ui): restore canvas after panel layout and clamp splitter 2026-10-11 21:31:01 -04:00
aj 4738a0390e feat(cutting-ui): inspect one proposal on the main canvas 2026-10-11 21:21:07 -04:00
aj 322dc3eda6 fix(cutoffs): use outward union for fitted arc bounds 2026-10-11 20:56:34 -04:00
aj ddc4710dfd perf(cutting): defer alternate hole entries in preferred pass 2026-10-11 20:26:47 -04:00
aj 97a468ee2c fix(cutting): recover checked routes through interlocking holed parts 2026-10-11 19:51:29 -04:00
aj 09dd19ca66 feat(ui): show nest versions in database details tab 2026-10-11 19:42:06 -04:00
aj fae37dd1d0 fix(data): retry absent pending uploads without changing identity 2026-10-11 17:15:16 -04:00
aj 94ed8a0f8f fix(backup): refuse uncertain or reused restore volumes 2026-10-11 17:10:26 -04:00
aj 028832debc fix(data): reconcile ambiguous nest uploads before retry 2026-10-11 17:10:26 -04:00
aj 51ad700bec docs(storage): explain version browser and recovery 2026-10-11 17:02:41 -04:00
aj 0216a9e9e2 test(server): verify complete revision backup and restore 2026-10-11 17:02:31 -04:00
aj a7c8e8541e feat(ui): inspect and restore nest history 2026-10-11 17:01:01 -04:00
aj 5f1eeb00eb test(data): verify legacy metadata guard before mutation 2026-10-11 16:51:56 -04:00
aj 914c197181 feat(server): restore historical nest as new revision 2026-10-11 16:49:12 -04:00
aj 5598804942 feat(server): preserve deleted nests with guarded recovery 2026-10-11 16:49:12 -04:00
aj d216bcd406 feat(server): version guarded metadata edits 2026-10-11 16:49:12 -04:00
aj 11471e3c71 test(server): exercise version history through real client 2026-10-11 16:39:48 -04:00
aj ed7600dc90 feat(data): fetch bounded nest history and guard saves 2026-10-11 16:38:35 -04:00
aj a47b414392 feat(server): preserve file-save nest revisions 2026-10-11 16:37:28 -04:00
aj 3d512c794c fix(cnc): show first internal rapid after cut-first programs 2026-10-11 15:48:07 -04:00
aj 08acb092a7 fix(cutting): anchor near-corner entries to their native edge 2026-10-11 01:14:50 -04:00
aj dc142cc88d docs(readme): streamline engine guidance and tabulate CLI options 2026-10-10 21:31:11 -04:00
aj 6799e08356 docs(readme): remove stray plate header note 2026-10-10 21:28:25 -04:00
aj ca6966906f Support direct contour piercing and correct planner geometry checks 2026-10-10 16:44:03 -04:00
aj c28ccd7568 Move zoom controls into centered plate view header 2026-10-10 16:04:58 -04:00
aj 90ae3b6a84 Fix false self-intersection warnings at rounded contour joints 2026-10-10 15:57:16 -04:00
aj 72776886cb Remember saved nest browser sort preferences 2026-10-10 15:39:11 -04:00
aj d59c03f5ab Highlight validation contacts and mute unrelated parts 2026-10-10 15:11:41 -04:00
aj bb108e9341 Add interactive nest validation with animated issue highlights 2026-10-10 14:51:47 -04:00
aj 80c647bf07 fix(report): omit trailing empty stock plates 2026-10-10 13:23:58 -04:00
aj 24be90ace9 fix(report): replace closeups with best-effort grouped labels 2026-10-10 13:14:45 -04:00
aj 4feada094c fix(report): use plain numeric part IDs 2026-10-10 13:14:39 -04:00
aj 4e846007a2 fix(report): use parts per plate consistently 2026-10-10 13:14:31 -04:00
aj 8ebb48694f fix(report): rename plate copies to quantity 2026-10-10 13:14:25 -04:00
aj afa50c5a12 fix(report): display stock dimensions as width by length 2026-10-10 13:14:19 -04:00
aj ef6ca80e6d Merge branch 'master' of https://github.com/ajisaacs/OpenNest 2026-10-10 12:38:50 -04:00
aj 0c99455489 feat(ui): validate Auto Nest stock prices and report costs 2026-10-10 12:38:48 -04:00
aj c34ac2bc0e feat(mcp): support priced stock and preserve accepted job data 2026-10-10 12:38:36 -04:00
aj 076b794dda feat(api): accept stock prices and archive cost summaries 2026-10-10 12:38:30 -04:00
aj 5dbf887b68 feat(engine): score nesting jobs by per-sheet stock cost 2026-10-10 12:38:20 -04:00
aj 17e504fb70 feat(cutting): navigate diagnostic rows in enlarged planner 2026-10-10 11:37:57 -04:00
aj 69304d3851 feat(settings): set nest server URL from Options 2026-10-09 21:54:10 -04:00
aj 1c6d02c480 perf(cutting): try a bounded entry catalogue before full search 2026-10-09 21:26:40 -04:00
aj d328c5f691 perf(cutting): generate ranked alternatives only when visited 2026-10-09 20:40:10 -04:00
aj e897258a85 feat(cutouts): reconcile repeated frames and inserts through one solve 2026-10-09 20:36:43 -04:00
aj 1b9d8a36d8 fix(pipeline): certify bound programs and cancellation before commit 2026-10-09 20:28:05 -04:00
aj b750475228 fix(pipeline): refuse changed drawings before binding or commit 2026-10-09 20:15:17 -04:00
aj 138c86526a perf(cutting): reuse native curve wrappers for lead queries 2026-10-09 19:15:28 -04:00
aj 07e682a654 test(cutting): add opt-in complete-route performance probe 2026-10-09 19:15:09 -04:00
aj 0b29c683b9 feat(cutouts): trial physical composite reconciliation in pipeline 2026-10-09 18:57:31 -04:00
aj 71bd964344 test(cutouts): trace accepted lattice and NFP poses in one run 2026-10-09 17:53:22 -04:00
aj 1d50c6ce60 feat(cutouts): compare measured ratio routes and certify leftovers 2026-10-09 17:47:12 -04:00
aj 962ae8c825 feat(cutouts): propose bounded mixed lattice and NFP inserts 2026-10-09 17:20:41 -04:00
aj d3e325f36c fix(ui): restore manual part sequencing and close control 2026-10-09 17:06:17 -04:00
aj 575412a464 feat(cutouts): propose certified single inserts with inner-fit geometry 2026-10-09 16:59:11 -04:00
aj 60fb009329 fix(ui): expose Auto Nest stock row selectors for deletion 2026-10-09 13:16:40 -04:00
aj 41737a4f4c feat(mcp): complete jobs with finite mixed sheet stock 2026-10-09 13:14:34 -04:00
aj f1d1df3f17 fix(mcp): preserve job constraints and compatible stock 2026-10-09 12:34:30 -04:00
aj 81404fc673 feat(mcp): complete whole jobs across matching plates 2026-10-09 12:22:40 -04:00
aj a5b6c8a00e fix(mcp): warn on unmet quantities rather than sheet ratio 2026-10-09 11:09:51 -04:00
aj 2a6e5504cd fix(mcp): report zero packing demand accurately 2026-10-09 10:52:41 -04:00
aj 3d5c73a353 fix(mcp): report requested and placed drawing mix 2026-10-09 10:48:59 -04:00
aj 8b94e49711 feat(mcp): delete unused session plates 2026-10-09 10:45:28 -04:00
aj 2623517219 fix(mcp): reject negative plate indices 2026-10-09 10:42:35 -04:00
aj eff148c24e fix(test): unpool direct transient server databases 2026-10-08 20:03:10 -04:00
aj 648ffbca65 fix(test): own and close private server database before cleanup 2026-10-08 19:55:56 -04:00
aj 4d94273a00 fix(test): release transient SQLite handles for Windows server tests 2026-10-08 19:49:09 -04:00
aj 028adee046 ci(release): pin Poppler for Windows PDF verification 2026-10-08 19:42:13 -04:00
aj 3ddfff242d fix(test): read PDF text by visual rows across platforms 2026-10-08 19:36:33 -04:00
aj 5fc563bbca test(report): retain Windows PDF extraction with diagnostic 2026-10-08 19:30:15 -04:00
aj 785a861ab9 test(report): place diagnostic PDF in CI artifacts 2026-10-08 19:26:31 -04:00
aj 986ba3dee1 test(report): retain Windows PDF for failed release diagnosis 2026-10-08 19:22:25 -04:00
aj a73a85ebc0 ci(server): skip image publication for GitHub prereleases 2026-10-08 19:17:15 -04:00
aj 8628c69098 fix(ui): settle shift-click cloned parts with repeated directional pushes 2026-10-08 18:57:39 -04:00
aj c1c4b18f17 fix(ui): keep closed etch-only parts selectable 2026-10-08 13:50:12 -04:00
aj 7e887921ea fix(cutting): expose every unverified warning before consent 2026-10-08 13:50:12 -04:00
aj f50705c491 merge: integrate golden-angle Workshop colors 2026-10-08 13:34:23 -04:00
aj 81f3c006ab merge: integrate best-effort cutting on current master 2026-10-08 13:34:05 -04:00
aj b83518feac merge: integrate reviewed cutting-start planner fixes (#10)
Preserve reviewed cutting-start and plate-label commits; operator accepted A1-A4 and B1-B7. Excludes best-effort cutting and color trials.
2026-10-08 11:01:21 -04:00
aj 1826e6d5b5 feat(ui): add Workshop palette with distinct drawing colors
Keep cut outlines dark and etch strokes bright green. Assign drawing fills by a golden-angle hue walk with varied saturation/lightness tiers, preserving legacy schemes and rendering-only behavior. Include palette, visibility, persistence, and contrast regressions.
2026-10-08 10:24:11 -04:00
aj fc300705a8 fix(cutting): aim round cutouts toward the next cut 2026-10-07 20:55:40 -04:00
aj 1c923976a8 fix(cutting): explain missing leads and suggest clearance remedies 2026-10-07 17:50:31 -04:00
aj f4d5cfe98e fix(ui): translate absolute-coordinate part paths by placement
Build paths in the drawing-local frame and translate both ordinary and split cut/lead paths once, without changing CNC programs. Add native Windows regressions using the operator's synthetic G90 fixture and incremental controls. Four absolute-mode cases fail before the fix; all seven pass after.
2026-10-07 17:22:06 -04:00
aj e7e4012974 style(ui): format graphics helper before placement repair 2026-10-07 17:18:48 -04:00
aj 9196d9157f fix(cutting): reuse clear candidates across side coverage scans 2026-10-07 16:05:25 -04:00
aj 7468483a10 fix(cutting): retain closest-arrival fallback for the last part 2026-10-07 15:56:20 -04:00
aj 432eb54633 fix(cutting): validate choice ownership before cached verdicts 2026-10-07 15:53:28 -04:00
aj 64459f7bc8 fix(cutting): try endpoint alternatives before hole combinations 2026-10-07 15:21:09 -04:00
aj 7622d63556 test(cutting): cover facing starts and safe fallbacks 2026-10-07 15:12:06 -04:00
aj 414e9b4e75 fix(cutting): prefer hole entries facing the next contour 2026-10-07 14:58:23 -04:00
aj 76f73f7a87 fix(cutting): exclude scribes from downstream pierce targets 2026-10-07 14:46:30 -04:00
aj 6b7d0d7f90 feat(cutting): chain preferred hole entries to the actual pierce
PreferredContourEntries resolves one entry per hole by walking the
proposed hole route BACKWARD from the already-chosen perimeter entry.
Each hole ranks against the downstream contour's ACTUAL emitted pierce
(first non-rapid motion of the owned probe emission, native rounding
included), so the cut chain flows toward the outside start rather than
chaining on nominal points that rounding may move.

Catalogue is the S04 merged list (preferred kinds where present,
compass/arc fallbacks otherwise — pure circles included); ordering is
the S05 facing/tier/travel ranker; feasibility is one S08-bounded pick
per hole through caller-supplied S07 verdicts. A hole with no feasible
candidate yields an explicit no-preference proposal naming the contour
and reason — never a silent skip, never a partial chain. Arrival proxies
(part arrival for the first hole, previous hole centre otherwise) keep
the pass deterministic and circularity-free.

Deterministic (repeat runs identical), cancellation-aware, ownership-
checked, source program untouched, fresh owned probe programs only.
This is a recommended-v1 proposal: nothing is installed into the search
and no rapid is certified here — S12 wires it and must handle a blocked
preferred rapid.

Tests: circle pair flows hole->hole->outside via actual pierces (each
pierce strictly nearer downstream than the opposite compass pierce);
rectangular holes pick right-facing corners under all-clear evaluation;
blocked ideal candidate falls back to next-ranked on the same hole;
blocked hole yields honest no-preference finding; permutation integrity
in route order excluding the perimeter; determinism; foreign perimeter
refused; cancellation; fingerprint/ownership/fresh-program invariants.
Gate 9/0/0; full Core 3580/25skip/0; cross-build 0 errors; scoped
format verify 0; facing-drop and silent-skip mutants killed; nominal-
vs-actual-pierce mutant documented near-equivalent.
2026-10-07 13:18:05 -04:00
ajandClaude Sonnet 5 47140cc59e perf(ui): prefetch nearby nests in the Database Open dialog
Browsing the saved-nest list was slow because arrowing to each row
downloaded that nest's archive from scratch. NestDetailsSession now
keeps a small LRU cache and exposes Prefetch(id); SavedNestsForm warms
the rows around the highlight once its own details are showing, so
arrowing through the page mostly resolves from cache instead of
re-downloading each nest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 13:13:25 -04:00
aj fa3c5e348e feat(cutting): propose hole paths toward the perimeter entry
CuttingHoleOrder proposes the cut order of a part's remaining holes as
an OPEN path arrival -> every hole centre exactly once -> the fixed
perimeter entry, reusing CuttingPartOrder's bounded nearest-neighbour +
2-opt/Or-opt machinery (no exhaustive permutations, no new routing
invention). A proposal only: the search still certifies every rapid and
emitted lead; nothing is installed into the search yet.

CuttingPartOrder gains an optional endpoint: the terminal edge joins
EVERY improvement delta (2-opt tail edge on both sides; OrOpt gap to the
open end measures the endpoint distance), so heuristics see route moves
that re-point the path at the entry rather than only final scoring. No
endpoint keeps whole-part semantics byte-identical (control test);
precedence, prerequisites and tie policy unchanged.

Tests: empty/one hole; asymmetric fixture where the endpoint changes the
ORDER (open route ends at C, entry-facing route ends at B) with each
route asserted equal to its brute-force optimum computed from
coordinates; permutation integrity excluding the perimeter; random 7-set
within 1.05x brute force; deterministic tie order; cancellation; legacy
no-endpoint equality. Mutants dropping the terminal edge from OrOpt or
TwoOpt deltas are killed.
2026-10-07 11:43:26 -04:00
aj d4544ea358 fix(cutting): aim perimeter entries toward the next part
No-hole parts now choose the outside entry through the S03-S08 pipeline
at the part boundary: rank the native catalogue toward the NEXT cut's
placed-material centre, lazily certify each emitted lead with the shared
validator, cap at maxEntries with side coverage. The next cut is the
next unfinished part on the supplied order (re-read after every
learned-order replan) or, in sequence-free fallback, the nearest
dependency-ready remaining part with stable ordinal ties; the last part
has no target and ranks by tier then arrival distance — never the plate
origin. Target and arrival are converted to prepared LOCAL coordinates
exactly once; geometry is already rotated.

The look-ahead rank survives Follow's ordering: children sort by
nearest source first (the sequence-free tour stays nearest-first), then
contour, then the selection rank — plain OrderBy(Distance) can no longer
undo the facing. Measured fixture (three squares, 0.15 leads, origin
start): legacy cut every sheet at its arrival-nearest lower-left corner
with 10.5-unit cross-sheet rapids totalling 21.0; look-ahead cuts the
+X-facing corner with the same 21.0 total but each inter-part rapid now
starts at the facing edge instead of trailing across the whole sheet.

Uncertain validator answers are never precheck-refused: they skip the
selected slots but flow to the emitted-prefix Check and complete replay,
which stay the authority — a refused-looking incomplete is reported,
not hidden. A fully evaluated catalogue with no fitting lead surfaces
'No tested lead-in fits on part N, contour M'; budget exhaustion stays a
budget finding. Lead prechecks count separately from DFS expansions; the
pipeline itself is one counted expansion per contour. Full backtracking,
dependencies, locked/fixed programs, cutoff handling, emitted-prefix
Check and complete replay are unchanged; budgets and CuttingPartOrder
untouched. Hole parts keep the legacy path until S12.
2026-10-07 11:01:08 -04:00
aj acd1f21293 feat(cutting): cap feasible entries with side fallback coverage
ContourEntrySelection: lazy bounded selection over one contour's ranked
candidates. Greedy global rank order until the cap (default 16) fills or
the finite catalogue ends; blocked and incomplete candidates never
consume a slot, and once selection settles the untouched tail is never
evaluated (EvaluatedCount/EvaluatedKeys pin the counts, not timings).
At caps of 4+ a corrective scan represents every side of the candidate
bounding rectangle that HAS a feasible candidate — lazily chasing each
missing side and replacing the worst selected candidate only when every
other covered side survives; one corner may cover two sides. The cap is
never exceeded and points are never manufactured on infeasible sides;
caps 1-3 obey cap+ranking first, as promised. Verdicts memoize per
attempt; duplicate geometric points never take two slots.

Honest metadata: Choices/EvaluatedCount/Shortfall/Reason with None,
Exhausted and Incomplete kept distinct — 'no tested lead-in fits' is
producible only from a fully evaluated Exhausted catalogue; incomplete
checks and cancellation are never presented as geometric impossibility.
Sides are exactly the ranker's bounding-rectangle geometry. No search
changes and no larger search budget.
2026-10-07 06:25:16 -04:00
aj 2399cc3936 feat(cutting): precheck emitted entry leads with the shared validator
ContourEntryFeasibility: one adapter over the EXISTING LeadPathValidator
for one owned candidate — emit through the S06 diagnostic seam, read at
the placement position, certify emitted lead-in AND lead-out against ALL
placed material. Only complete+clear qualifies; blocked and incomplete
keep their reasons as distinct verdicts (an incomplete check never
reads as clear). Not a new collision implementation and not a plan
approval: NoLeadIn passes vacuously and the complete-plan missing-lead
check still runs later; rapids and pierce clearance stay with their
existing checkers.

One instance is one captured planning attempt: verdicts cache per exact
owned choice + node context (settings/placement are fixed per instance),
never static, never across instances. Lazy: only the checked candidate
is emitted; EvaluationCount counts validator executions for cost tests.
Foreign/malformed emissions are Incomplete verdicts with reasons, never
crashes; cancellation propagates without poisoning the cache. No search
changes and no broad-phase skips.
2026-10-07 05:27:05 -04:00
aj bd3f529ccd refactor(cutting): isolate candidate emission for lead validation
EmitCandidateForValidation emits exactly one owned contour with its
normal lead-in and lead-out and its ORIGINAL contour type — the
perimeter keeps External even with no holes before it — through a small
shared EmitChosenContours seam, so a perimeter candidate's emitted leads
can be validated before the hole choices exist. The probe is a throwaway
program: never installed on a Part, never a complete plan. Normal Emit
and EmitPrefix keep the perimeter-last gate untouched (still throwing
for Emit([perimeter]) on a holed part); foreign and re-stamped choices
still fail ownership/geometry validation and source shapes/settings are
never used directly. Differential tests pin per-contour block fidelity
(External-vs-hole lead geometry, five lead styles, two hole
choice/orders, scribes once, reversed winding, rotated capture, circle
rounding/clamping without cross-contour state) — contour lead geometry
is choice-independent, only absolute accumulated coordinates drift by
ulps with program head.
2026-10-07 04:28:09 -04:00
aj a791b74945 feat(cutting): rank contour entries toward the next cut
Pure deterministic ordering of the automatic catalogue: classify each
point by its nearest bounding-rectangle side(s) with the preparation
tolerance (a corner belongs to two sides), choose the facing side pair
from the look-ahead target against the centre (right/left and top/bottom
per the source plan), then order by matched facing sides descending,
rank tier ascending (corner, midpoint/tangent peer tier, fallbacks),
then arrival->entry + entry->target travel, then the stable geometric
key. Without a target (last part) the tier leads and distance to the
arrival breaks ties — never the plate origin. The input list is never
mutated, no candidates are added, no cap is applied and entity order is
never the tie-break; reversed and cyclically reindexed drawings rank to
the identical geometric order.
2026-10-07 02:51:06 -04:00
aj 4e60cebf68 feat(cutting): add native fallback contour entries
AutomaticEntryCandidatesWithFallbacks completes the uncapped catalogue
with tier-3 fallbacks after the preferred points: native arc midpoints,
the eight compass points of a whole circle (a pure-circle contour is now
usable instead of refusing), near-convex-corner points on each incident
straight edge back from the corner by 2 x the applicable lead-in length
using the emitter's own SelectLeadIn semantics (non-length lead-in styles
contribute 0 and the fallback is omitted, never approximated), and the
exact target-facing closest point toward the caller's look-ahead. Every
fallback runs through the same reflex/cusp exclusion — a raw closest
point or an inset landing on a forbidden inside corner is dropped — and
the same geometric duplicate merge; short edges omit an out-of-segment
inset by projection parameter instead of extrapolating. No ranking and
no lead-safety verdict yet.
2026-10-07 02:07:34 -04:00
aj 63f2a4b555 feat(cutting): enumerate preferred contour entry candidates
Internal PreparedContours.AutomaticEntryCandidates builds the uncapped
preferred start catalogue per contour from the contour's own winding:
convex corners, then straight-edge midpoints, then line/arc tangent
joints, deduplicated geometrically with the preparation epsilon (the
most preferred metadata wins at equal points). Reflex and cusp vertices,
collinear splits and circles are never enumerated for automatic placement;
hole slugs classify from their own travel. Manual Entry/ClosestEntry and
the legacy Entries API are unchanged; emission wiring comes in S09.
2026-10-06 23:58:01 -04:00
aj ed35b94e7f test(cutting): keep both edge selections in corner classification theory
Scoped dotnet format removed the unused entityIndex parameter from
TraversedBackwards_SquareCornersStayConvex, leaving MemberData supplying
two values to a one-parameter theory (8 xUnit failures at test discovery).
The parameter is now genuinely used: each geometric corner is classified
from both adjacent entities of the reversed contour.
2026-10-06 23:58:01 -04:00
aj e75f68dbb6 refactor(cutting): share corner classification with entry planning
Expose TryClassifyAutomaticStartCorner: an internal read-only query over
the emitter's existing TryGetCorner/ClassifyCorner with the same winding
derivation EmitContour uses, so start-point planning can prefer convex
corners without copying tangent math or touching lead generation. Corner-
kind characterization covers convex, reflex, tangent-smooth and cusp
vertices from either adjacent edge, both windings, under rotation, and
rejects midpoints and open contours.
2026-10-06 22:09:47 -04:00
aj b8f319d3e8 fix(ui): exclude the new-plate sentinel from display counts
The editor header and the Plan Cutting dialog counted the trailing empty
new-plate workspace PlateManager.EnsureSentinel maintains, so a one-plate
nest read 'Plate 1 of 2'. PlateDisplayNumbering now derives display-only
numbers that exclude only a trailing empty sentinel; interior empty plates
keep their slot and number, and navigation, storage indexes, exported
names and batch selection are untouched. The sentinel itself is labeled
'New plate (empty)' instead of being numbered beyond the shown total.
2026-10-06 20:58:32 -04:00
aj 91aef95b1b fix(tests): acknowledge native BOM dialog without assuming button id
Fix DuplicateRows_CreateNestsButtonImportsOneDrawingWithCombinedQuantity timeout from Windows run 37526243901. Native trace on DesktopPC showed the sole OK button uses control ID 2, not IDOK 1. Preserve real dialog text and nest assertions; find the sole button and stop polling only after successful posting. All 392 WinForms and 49 FrontEnd tests pass in an interactive Windows session.
2026-10-06 16:52:52 -04:00
aj 9110cd62e0 fix(ci): retain readable results from unsuccessful Windows runs 2026-10-06 16:11:32 -04:00
aj 8e8b1de25f fix(ci): reject contradictory Windows test counters 2026-10-06 16:09:26 -04:00
aj 1add37d3e0 test(windows): require shown-form acceptance lifecycle cases 2026-10-06 16:00:48 -04:00
aj 71f41e23e4 test(ui): cover shown BOM import and nest info persistence 2026-10-06 15:59:57 -04:00
aj b591528aa2 ci(windows): retain fail-closed desktop acceptance evidence 2026-10-06 15:56:27 -04:00
aj 12206ed8fd feat(cutting): offer unverified best-effort plans for imperfect geometry 2026-10-06 14:44:53 -04:00
aj bb43118451 fix(ui): keep the database Open dialog on screen on a second monitor
The saved-nest browser opened maximized with MaximizedBounds set from the
owner screen's working area in absolute coordinates. Windows reads that
position relative to the monitor, so on a secondary screen the offset was
applied twice and the modal dialog was placed off-screen, leaving the
disabled main window with no way back to it.

Open it instead as a normal window sized to 90% of the owner screen's
working area and centered there; it still clears the taskbar and moves
between monitors like any other window.
2026-10-06 14:40:41 -04:00
aj 89356371db feat(mcp): describe each engine in autonest_plate and default fill tools to Fill
The engine argument's description now names every built-in engine with what
it suits, so a model can choose one; omitting it still means Default. The fill
tools resolve an omitted strategy to Fill directly instead of through the
session's whole-job default. Front-end tests cover the MCP and console
defaults and keep the description in step with the registry's built-ins.
2026-10-06 12:26:19 -04:00
aj 5af44b251c feat(engine): make Default choose between Irregular and Rectangles per job
Default is the engine every front end uses when none is named. It now runs
Irregular, then Rectangles, checks both layouts with NestLayoutCheck and keeps
the best: valid first, then fewest unplaced parts, then lowest salvage-credited
cost; ties keep Irregular. A candidate that throws or returns nothing is
skipped, cancellation stops the search, and only the chosen layout's plate
commits are reported. Neither engine wins every job in the lane benchmarks,
and Rectangles adds little time while also covering Irregular's invalid
layouts.

The registry lists Default first and no longer maps the name to Fill; fill
strategy callers still read Default as Fill. A future circle/ring engine joins
as another candidate.
2026-10-06 12:26:10 -04:00
aj 0a1d021bf7 refactor(engine): rename the Default engine and fill strategy to Fill
The multi-phase lattice fill (linear, pairs, rectangle best-fit, remainder) is
no longer meant to be the engine used by default, so it gets a name for what
it does. The registry lists it as Fill and maps the old name Default to it;
PlateFillService, PlateNesterFactory and NestJobOptions use Fill, and every
fill-strategy caller still accepts Default. Console --autonest now validates
engine names through NestingEngineRegistry.ResolveName so renamed names work.

Layouts are unchanged: Default and Fill resolve to the same fillers and the
golden layouts pass under the new name.
2026-10-06 12:11:03 -04:00
aj 6b4078aeb2 style(tests): apply dotnet format to files touched by the Fill rename 2026-10-06 12:10:42 -04:00
aj 18a61717fe fix(cutting): accept tangent line-arc joints in material capture
Plan Cutting refused ordinary filleted parts with "Native contact query is
numerically uncertain." Material capture checks every curve pair of a ring,
adjacent ones included. Where a line meets a tangent arc at their shared
vertex, rounding can drop the tangent root of the native line/circle
quadratic; the exact ray cast from the line's far end then reached the
vertex, which is already recorded as an endpoint contact, and was read as a
contact the native query missed. Rounded rectangles rotated off-axis were
refused 1037 times in 1080 before this change and 0 times after.

The exactness rays now stop short of a line endpoint the other curve already
contains (half-way from each end when both are contained), so together they
still cover every other point of the line and any unrecorded contact still
refuses. The native kernel and tolerances are unchanged.

Regressions: four tangent-fillet rings and a rotated filleted part planned
through CuttingPlanBatch (red before, green after); a line ending inside a
small circle's contact band stays uncertain in both directions. Mutations that
treat either endpoint as always contained, drop the start ray, or count
recorded endpoints as native contacts all fail.

Project Memory: 41880037
2026-10-06 10:37:07 -04:00
aj bec4db21a8 merge: integrate ci/parallel-tests
Runs the four cross-platform suites as a matrix and the six synthetic
Irregular nests in their own job, with a fail-closed final "tests"
check.

Conflict in ci.yml: take the branch's Linux jobs and keep master's
windows-desktop job unchanged after them. actionlint and the aggregate
checker self-tests pass on the merged file.
2026-10-06 06:41:06 -04:00
aj bc061488ee merge: integrate refactor/menubar-reorg
Brings in the Plate menu reorganization, Database-mode drag-and-drop
open, the SavedNestsForm look fixes and the Irregular block-fill work
budget fix.

Conflicts:
- MainForm.Designer.cs: keep the branch's Plate menu order (Arrange
  submenu, View in CAD last) and add master's Plan Cutting item ahead
  of the lead-in commands.
- SavedNestsForm.cs: keep master's browser rewrite (details, preview,
  columns) and apply the branch's intent to it: open maximized with
  MaximizedBounds clear of the taskbar, the lighter grid palette on all
  three grids, and B/KB/MB/GB file sizes.
2026-10-06 06:39:41 -04:00
aj ea1270e1e7 fix(cutting): check every lead against all other material again
The second delta review compared the filtered checks with the previous
implementation on 5,400 generated cases. Rapid checks matched in every
case, but 114 lead checks differed: a long lead passing a small circle
or arc well over 0.001 away was reported clear, while the native
line/circle query, through rounding in its squared terms, reports a
contact there. A coordinate limit and a fixed margin cannot bound that
cancellation.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The suites have roughly doubled since the last release run (7.5 min),
so raise the job cap to 45 minutes. Validated with actionlint.
2026-10-05 16:26:22 -04:00
ajandClaude Sonnet 5 3fc32e56e1 feat(ui): drag-and-drop to open .nest files in Database mode
Open browses server records in Database mode, with no way to load a local
file. Dragging .nest files onto the window (or an open plate) now opens
them from disk in either storage mode; PlateView forwards file drops via a
new FilesDropped event instead of swallowing them, gated on having a
listener so other PlateView hosts are unaffected. A dropped document is
unbound, so Save in Database mode creates a new record on first save via
the existing storage-mode logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 14:50:36 -04:00
ajandClaude Sonnet 5 3a9bd08f39 fix(ui): fix SavedNestsForm maximize bounds, lighten grid, format file size
- Open maximized, but with explicit MaximizedBounds (WorkingArea minus
  20px) instead of trusting default maximize behavior, since the
  bottom button row was rendering behind the taskbar.
- Lighten the grid's borders, header and row-header colors so the
  default dark gridlines don't dominate the view.
- Format File Size as B/KB/MB/GB instead of a raw byte count.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 14:19:08 -04:00
ajandClaude Sonnet 5 4a55d6664c refactor(ui): reorganize menu bar - move arrangement ops into Plate
Align Selected, Pattern Tile and Expand Spacing lived under Tools even
though they only operate on the current plate's parts, while Center
Parts and Sequence Parts (the same category) lived under Plate. Folded
them into a new Plate > Arrange submenu and left Tools with just
generic tools and app/nest configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 14:18:42 -04:00
aj 3a8419e6e9 fix(ui): show the newly selected plate when a Plates row is chosen
The plate preview read DataGridView.CurrentRow in SelectionChanged, but
that event runs before CurrentCell moves, so CurrentRow still named the
previous row. Choosing a row showed the plate that had been selected
before it, and the next-plate button snapped the preview back to the
old plate. Read the selected row instead.

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

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

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

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

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

NestDetailsSession loads the details of the highlighted nest. A new
load or Clear supersedes the previous one before cancelling it, so a
load that completes synchronously on cancellation and any late result
or failure of a superseded load are discarded; IsLoading follows only
the latest load.
2026-10-05 11:26:02 -04:00
ajandClaude Sonnet 5 eda4a846f0 fix(irregular): charge nested block-fill cost against the work budget
BlockCatalog's PrepareBlocks pre-pass ran a full independent NFP-based
pack (PrivatePlateFill.Run) for up to 8 attempts per part type, but that
cost never touched the engine's WorkCounter/WorkBudget. For jobs with
several part types at double-digit quantities, these uncounted nested
solves could burn the entire 5-minute timeout inside the first sheet of
the first strategy variant, so Solve() never returned and nothing was
ever committed.

Charge quantity^2 * outline vertex count against the shared counter
before each nested fill, and stop issuing new block proposals once the
budget is exhausted, so the existing effort governor actually sees and
bounds this cost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 09:29:46 -04:00
aj 32db086384 perf(irregular): cap surplus rows in private stripe fills 2026-10-05 09:03:49 -04:00
aj 5f67a51bf1 style(tests): sort imports in fill regression fixtures 2026-10-05 09:03:49 -04:00
aj 20f8b046de fix(cutting): fingerprint only the built-in settings types
The second delta review found four more false-equal classes in the
general reflective fingerprint, all reachable only through custom
settings subclasses: cycle markers that dropped the target ancestor,
display-formatted DateTime/DateTimeOffset, ignored dictionary and set
comparers, and arrays flattened without their dimensions. Safe arrays of
OpenNest elements were also newly refused. Every repair of the generic
traversal opened another such case.

Settings capture now supports exactly the types regeneration already
accepts (OwnedCuttingParameters): CuttingParameters, SequenceParameters,
AssignmentParameters and the built-in lead-in, lead-out and tab types.
Each member is written explicitly, doubles by bit pattern with invariant
numerals and text length-prefixed. Every object's runtime type is checked
before any member is read, so no other type's code runs. A plate-scoped
request whose part or plate settings contain any other type, subclasses
included, is UnsupportedGeometry at capture instead of a Ready plan that
can never apply. A nested settings object replaced by such a type after
capture makes Apply Stale. Detached part-list requests are unaffected.

Coverage tests fail when a supported type gains a property or field the
fingerprint does not write, or when a new built-in lead or tab type is
added without fingerprint support.
2026-10-05 03:16:27 -04:00
aj bb104a07bb fix(cutting): fingerprint settings state without executing foreign code
Delta review found three Important defects and a Minor one in the
settings fingerprint that closed the first freshness gap:

- Accepted settings state was silently omitted: dictionary entries render
  as KeyValuePair structs whose Key/Value are properties, property-backed
  custom structs contribute no public fields, and graphs past the depth
  limit wrote a constant marker, so all three edits fingerprinted equal
  and a changed plate still applied.
- Reading public properties executed arbitrary getters, so a capture
  documented as read-only could mutate live settings (Bump => ++Kerf).
- An enumerable settings member was enumerated at Apply, where its
  enumerator could throw out of the public commit call.
- Fingerprint text used ambient-culture interpolation, so an invariant
  capture compared unequal under a digit-substituting culture.

Traversal is now a closed boundary. OpenNest types render their public
readable properties and fields. Foreign types render only declared
instance fields, which include auto-property backing fields, because a
field read executes no code. Only arrays and List/Dictionary/HashSet/
KeyValuePair are enumerated, with insertion-ordered containers sorted;
other enumerables, delegates and unrepresentable shapes refuse to an
Invalid marker that never compares equal, so refused state is Stale
rather than silently equal. Doubles fingerprint by bit pattern rendered
with invariant formatting, and depth or budget overflow refuses instead
of truncating. A reference already on the path renders as a cycle
marker; built-in lead and tab objects reference settings back.
Capture stores refusals as-is, so a plate with uncaptureable settings
stays plannable and every commit against it reports Stale without
re-reading live state, and a fingerprint that fails on re-read is
likewise Stale, never an exception.
2026-10-05 02:49:23 -04:00
aj ea36c47a30 docs(cutting): describe Apply freshness, installer and nested-part bounds
Document the review hardening: classification, settings content and
malformed programs count as changes, a no-op proposal stays current,
parts repeated across plates are refused, the installer is internal, and
nested-part candidates use material bounds only.
2026-10-05 00:29:47 -04:00
aj b8afcc6fdd perf(cutting): build finished ordinals once per search expansion
The readiness filter rebuilt the finished-part list for every candidate.
Build one set per expanded node; the candidates and their order are
unchanged.
2026-10-05 00:29:47 -04:00
aj 1d8c5fe274 fix(cutting): create plate-scoped requests through a factory
A CuttingPlanRequest constructor overload taking a Plate made the existing
detached call new CuttingPlanRequest(null) ambiguous (CS0121). Plate scope
is now requested with CuttingPlanRequest.ForPlate, and the result summary
describes dependencies and Apply as they now behave.
2026-10-05 00:28:59 -04:00
aj e43a7c99d0 fix(cutting): find nested parts from material bounds
The containment prefilter used the inner part's whole clean-program
bounds, which include rapid endpoints and scribe marks. A remote rapid or
mark could push those bounds outside the host and drop a genuine
inner-before-host prerequisite. Candidate pairs now use the material
extent only (cut and display motions); containment is still proven on
native material.
2026-10-05 00:27:31 -04:00
aj b9b475ab77 fix(cutting): keep plan installation behind the verified service
Review of the atomic Apply found two commit-boundary gaps:

- The plan installer checked only root program references, so a public
  caller could install a payload whose subprograms alias another live
  (even locked) part's program, or share settings between parts. The
  installer and its payload types are now internal; CuttingPlanService.Apply,
  which installs owned copies of independently replayed proposals, is the
  only public path.
- A part placed on two plates in one scope was planned once per plate; one
  plate's install replaced the program the other plate verified as fixed.
  A part repeated anywhere in the scope is now invalid input.
2026-10-05 00:25:43 -04:00
aj d37b38622f fix(cutting): treat classification, settings and malformed programs as stale
Review of the atomic Apply found freshness gaps:

- A drawing's cutoff classification decides lead, material, obstacle and
  dependency treatment but was not captured; changing it after planning
  still applied the old proposal.
- Part and plate cutting settings were compared by reference only, so an
  in-place edit after capture applied (and a regenerated part overwrote
  it); plate settings were not compared at all. Settings are now captured
  as an exact public-state fingerprint.
- A live program whose instruction list was set to null made Apply throw
  instead of returning Stale, and a respelled key in a case-insensitive
  binding dictionary compared equal.

Caller-confirmed planning settings remain planning input: editing them
after capture does not stale the plan and does not leak into the result.
2026-10-05 00:23:48 -04:00
aj 4bbd8454b5 feat(cutting): order cutoffs and nested parts before their hosts
The cutting planner now accepts cutoffs on plate-scoped requests and
plans whole-part prerequisites captured from owned values:

- A cutoff precedes every part its nominal span crosses, using the same
  rule and drawing-reference matching as automatic sequencing; a cutoff
  without a definition precedes every part. Cutoffs stay fixed programs,
  need no lead-in and never become rapid obstacles; rapids into and out
  of them are still checked.
- A part proven, on native clean material, to lie inside a cutout of
  another part precedes that host. Touching or crossing boundaries are
  ambiguous and refuse; a part in a concave pocket has no dependency.
- Both searches only expand ready parts, a preserved order that breaks a
  prerequisite is a constraint conflict, and final replay rechecks the
  captured prerequisites instead of trusting the search.
2026-10-05 00:01:29 -04:00
aj f3bca26fb9 refactor(sequencing): share the cutoff crossing rule with cutting planning
Move the nominal-span cutoff rule unchanged into CuttingDependencyGraph so
automatic sequencing and the cutting planner apply the same dependency.
2026-10-04 23:50:58 -04:00
aj 9c530ca7d2 feat(cutting): apply verified plans atomically
Plate-scoped cutting plan requests now record the plate's exact state at
capture, and CuttingPlanService.Apply installs Ready, replayed proposals
for a whole scope at once:

- Any change after capture (order, pose bits, program reference or
  in-place content, drawing program, lock/lead-in flags, settings,
  quantity, size, quadrant or cutoff definitions) returns Stale with
  nothing changed.
- Order changes without PartAdded/PartRemoved, so drawing quantities and
  sentinel plates are untouched; ObservableList.Reorder exposes the same
  operation and Plate.PartsReordered is raised once per changed plate.
- Regenerated parts receive owned copies of the replayed program and of
  the settings captured with the request; fixed programs stay in place.
- An install failure restores every plate exactly; an observer failure
  after publication is reported as a refresh error, not a rollback.
2026-10-04 23:48:28 -04:00
aj ebc54f0653 refactor(cutting): move owned program copies into Core
Core's commit freshness check needs the same lossless program copy the
planner uses at capture. Move it unchanged apart from its namespace.
2026-10-04 23:47:56 -04:00
aj 7127884584 fix(cutting): start tabbed lead-outs at the trimmed cut end
A tab trims the perimeter short of its entry, but the lead-out was still
generated from the nominal entry point. An arc lead-out therefore started
off its own radius (ExecutionMotionReader rejected it as inconsistent), and
a line lead-out ran diagonally back toward the entry.

Every lead-out style on a tabbed perimeter now leaves from the trimmed
cut's actual end, on that entity's normal, so arcs are tangent and the tab
gap stays uncut. Untabbed contours and the corner run-out rules are
unchanged. Malformed legacy output is still refused, never refit.

Red before the fix: the three tabbed arc cases threw "Arc has zero or
inconsistent radius" and the line case ended at y=5 instead of 4.8. Keeping
the entry's normal at the actual end fails the curved-perimeter case.
2026-10-04 23:13:41 -04:00
aj 6f7652ee83 fix(cutting): preserve clean geometry through shared graph rotation 2026-10-04 22:26:22 -04:00
aj fef93cbfa5 style(cutting): format emission characterization initializers 2026-10-04 22:10:41 -04:00
aj cf615dcef8 docs(cutting): document exact replay and fixed-program refusals 2026-10-04 22:02:56 -04:00
aj e8d974a2db fix(cutting): certify complete selected contour programs 2026-10-04 22:02:56 -04:00
aj 9f7580a486 fix(cutting): bind replay proposals to exact captured pose bits 2026-10-04 21:56:49 -04:00
aj fb7ef6fd0d fix(cutting): retain authored motion metadata in owned programs 2026-10-04 21:56:49 -04:00
aj e00aa113a0 fix(cutting): validate original graphs before capture clones 2026-10-04 21:56:49 -04:00
aj d7204e8d07 fix(cutting): share only exact prepared circle programs 2026-10-04 21:36:40 -04:00
aj 1597ee9069 fix(cutting): guard material runtime semantics before capture 2026-10-04 21:36:40 -04:00
aj d1109aaa43 fix(cutting): refuse uncertain native circular contacts 2026-10-04 21:36:40 -04:00
aj 085edeea3b docs(cutting): describe joint planning and refusal boundaries 2026-10-04 21:19:45 -04:00
aj 70d886e4cf feat(cutting): jointly plan contour order and entry points 2026-10-04 21:12:55 -04:00
aj 018e4e3dd4 feat(cutting): validate actual leads against owned material 2026-10-04 20:51:23 -04:00
aj e71fcea73d feat(cutting): emit owned contours in explicit order 2026-10-04 20:45:15 -04:00
aj 6addad7b2e test(cutting): characterize retained contour emission 2026-10-04 20:26:17 -04:00
aj 7451596047 test(cutouts): keep the repeated-call determinism test, skipped on the open Fill defect
Restore RepeatedCallsReturnIdenticalPoses for a 1 x 1 square and a 1.5 x 0.75 rectangle.
Both cases fail intermittently because Default Fill can return different, equally scored
lattices for identical calls (Project Memory opennest c98c21bd), so they are skipped with
that reason until the defect is fixed rather than removed.
2026-10-04 20:07:52 -04:00
aj 15cca0efd5 feat(cutouts): fill a cutout with a shifted Fill lattice
CutoutLatticeFill fills one closed cutout of a frame part with copies of one part, the
first piece of the cutouts-first pass. It runs Default Fill over the cutout's bounds
plus one part step on every side, shifts the lattice over a (2n+1)^2 grid of offsets
up to half a step each way, and at each offset keeps the copies whose reference point
lies in the part's inner-fit region of the inscribed cutout (part circumscribed and
grown by the spacing). The offset keeping the most copies wins; every returned pose
is then certified with NestLayoutCheck.Clears against the frame and the other copies.

20" ring, 10" round cutout, 1" squares, 0.25" spacing: 37 copies, against 25 for a
block sized to the inscribed rectangle and 32 for the unshifted lattice.

Not wired into any engine or pipeline: parts inside cutouts wait on containment-aware
cutting order. Fill can still return different, equally scored lattices between calls
for some parts, so identical results are not yet claimed.
2026-10-04 19:56:05 -04:00
aj 4842b80ceb fix(fill): pick FillPattern winners in angle order, not thread order
FillPattern gathered per-angle results in a ConcurrentBag and kept the first best it
enumerated, so an exact tie between angles went to whichever worker finished first:
identical inputs could return different, equally scored layouts. Results now sit in
slots indexed by angle, V before H within an angle (the order the single-angle bag
already produced and the existing tie tests pin), and ties keep the earliest slot.
Single-angle behaviour is unchanged.

Regression: a never-prefer comparer over one 0-degree and fifteen 180-degree angles
must keep the 0-degree layout in 50 of 50 calls; it failed on the old code.
2026-10-04 19:50:30 -04:00
aj 418f75916b refactor(engine): share the private-plate Fill run between blocks and future callers
Move BlockCatalog's private-drawing Fill invocation (BestFitCache stabilization plus
the Default FillItem call) into PrivatePlateFill so the cutout fill can reuse it
without duplicating the cache handling. Behavior unchanged; Irregular filter 96/96.
2026-10-04 19:40:49 -04:00
aj a15c54fbaf test(desktop): fail WinForms tests on UI-callback exceptions per STA thread
TestAssemblySetup set the unhandled-exception mode and a ThreadException
handler from a module initializer. WinForms stores both per thread
(ThreadContext/NativeWindow thread statics), so they applied only to the
runner thread that loaded the assembly, never to the STA threads the
tests run on. There, an exception escaping a window message or a
BeginInvoke/SynchronizationContext callback still opened a modal
ThreadExceptionDialog in an interactive session, or was silently ignored
otherwise; the handler would also have logged and swallowed it.

Replace the fifteen copied RunSta helpers with StaTestThread.Run, which
selects UnhandledExceptionMode.ThrowException on each STA thread before
it creates a window, so the exception propagates to the test body and
fails the test. Each class keeps its own timeout and message; all
threads are now background threads. Remove TestAssemblySetup.

StaTestThreadTests covers a throwing BeginInvoke callback through
StaTestThread (fails with the original exception) and, as a control,
the default mode on a plain STA thread (the exception goes to the
thread's handler and never reaches the test body). Cross-compiled on
Linux; Windows execution is the windows-desktop CI job.
2026-10-04 18:40:48 -04:00
aj 67fb80e660 ci: kill and name hung Windows tests instead of holding the runner
The first windows-desktop run hung for 25 minutes with no output and
ran until it was cancelled. Pass --blame-hang-timeout 5m so a test that
is silent for five minutes is killed, named in the log and mini-dumped
into TestResults, then cap the job at 20 minutes and upload all of
TestResults, not just the TRX files.

Checked locally with a throwaway xunit project: a Thread.Sleep(Infinite)
test was killed after the configured inactivity, named in the output,
and left a TRX, a test-order (Sequence) file and a mini dump; dotnet test
exited 1.
2026-10-04 18:39:08 -04:00
ajandClaude Sonnet 5 5f9e8814a7 test(desktop): fix Windows-only WinForms and cross-platform test failures
The new windows-desktop CI job surfaced several tests that had never
actually run on Windows:

- SeparatorPenLifetimeTests expected Control.Disposed to fire only
  once; current WinForms re-fires it on every Dispose() call.
- EditNestFocusTraversalTests assumed the Plates tab was selected by
  default, which stopped being true once Drawings became the landing
  tab; select it explicitly before asserting focus.
- NestReportExportTests asserted an exact plate count that ignored
  PlateManager's trailing sentinel plate, and didn't expect the nest
  name in the busy-export message.
- AtomicReportFileTests only expected IOException when replacing a
  directory target; Windows raises UnauthorizedAccessException instead,
  which callers already treat the same way.

Also register a ThreadException handler so an exception inside a
WndProc callback fails the test normally instead of popping a modal
dialog that blocks the desktop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 18:24:20 -04:00
ajandClaude Sonnet 5 3ba8f4e3b2 fix(desktop): avoid AutoNest stock grid hang and name the busy export target
DataGridView.EndEdit() hangs/throws instead of returning false when
CellValidating has already cancelled the pending edit, so an unparsable
stock size froze TryGetPlateOptions. Validate the pending edit text
ourselves before ever asking EndEdit() to commit or discard it.

Also include the nest name in the export-busy message, matching how
the invalid-name rejection already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-04 18:24:13 -04:00
aj 2a5ebf1e8a ci: run WinForms and front-end tests on a Windows runner
Pushes to master only ran the Linux job, which can cross-compile but
not execute OpenNest.WinForms.Tests; Windows test runs happened only on
release branches and tags. Add a windows-2022 job to the cross-platform
workflow that runs OpenNest.WinForms.Tests and OpenNest.FrontEnd.Tests
in Release on every master push and pull request, runs both projects
before failing, and uploads their TRX results.
2026-10-04 12:39:57 -04:00
aj 90587803be fix(desktop): apply saved units to new and BOM-created nests
New only fell back to the legacy DefaultUnit setting when the whole
defaults file was missing or invalid, so a readable file without a
usable unit produced inches. BOM import never assigned units at all,
and the Nest Defaults dialog showed inches for a missing file.

All three now load through MainForm.LoadSavedNestDefaults, which passes
DefaultUnit as the per-field unit fallback. BOM-created nests start from
NestDefaults.ApplyTo, like New, before applying each group's plate size
and spacing.

The ApplyTo test now asserts units; removing the unit assignment from
ApplyTo fails it. WinForms behaviour is compile-verified only on Linux.
2026-10-04 12:03:22 -04:00
aj b7dc7dee13 fix(defaults): accept only defined unit names, with a caller fallback
Enum.TryParse accepted numeric strings ("7" loaded an undefined unit,
"1" loaded millimeters) and comma-joined names, and a JSON number for
units failed the whole file. Units now load only from a defined unit
name; anything else falls back for that field alone, while the other
valid fields still load.

Add Load(path, fallbackUnits, out status) so callers can keep their
existing unit preference when the file is missing, unusable, or lacks a
usable unit (previously a readable file without units silently became
inches). An undefined fallback is ignored in favour of the built-in
units.

The numeric/comma/JSON-number rows failed against the previous commit;
dropping the fallback assignment or its IsDefined guard fails the new
fallback tests.
2026-10-04 12:01:12 -04:00
aj 39a15aee14 fix(defaults): treat a read-denied defaults file as invalid
NestDefaults.Load caught only JsonException and IOException, so a
defaults.json that File.Exists can see but the process cannot read
threw UnauthorizedAccessException out of New. Report such files as
Invalid and return the fallback values, like a corrupt file.

The regression mode-000s the file on Unix (skipped on Windows and when
running as root); it failed with UnauthorizedAccessException before
this change.
2026-10-04 11:59:08 -04:00
aj 23d258b886 perf(geometry): allocate entity IDs from a salted atomic sequence 2026-10-03 10:59:04 -04:00
aj 60d3915af2 docs(test): use Release for routine cross-platform suites 2026-10-03 10:45:21 -04:00
aj c989ba6fd0 ci: run cross-platform suites and synthetic gate in parallel 2026-10-02 22:29:38 -04:00
aj 761cee7c1f feat(cutting): plan verified part order from fixed programs 2026-10-02 21:56:14 -04:00
aj c49c387af5 merge: integrate exact-contact rotated rectangle repair
Brings in the independently reviewed exact-contact repair (c7b4ba4,
25755a8, bd5db59): line-only part outlines keep exact analytic bounds,
so an 8x3 part fits rotated on 8.5x3.5 stock at 0.25 spacing, while
curved outlines keep their conservative padding and no validator,
spacing or fit tolerance changes. The held hole-profile and pocket
commits built on bd5db59 stay unmerged.
2026-10-02 20:50:19 -04:00
aj 9e15d5dceb fix(desktop): track only the latest database browse request
A request is now marked superseded before the previous one is
cancelled, so a query that completes synchronously on cancellation
returns quietly instead of clearing the page and surfacing a stale
cancellation error. NestBrowseSession.IsLoading follows only the latest
request, and the dialog's Previous/Next and Loading text use it rather
than a count that included superseded requests. Each request disposes
its own cancellation source when it ends.
2026-10-02 20:22:24 -04:00
aj eace08f351 fix(server): reject NUL characters in nest query search
SQLite LIKE stops matching at an embedded NUL, so search=Alpha%00x
behaved like 'Alpha' followed by a wildcard and returned rows that do
not contain the whole search text. The shared query validation now
rejects NUL, so the server answers 400 and the client throws before
sending.
2026-10-02 20:16:00 -04:00
aj 96267e760e feat(desktop): page and filter database nests on the server
The Database-mode Open dialog no longer downloads every record. It
browses through NestBrowseSession, which sends one bounded query per
change: the filter box (300 ms debounce) searches on the server and
returns to the first page, column headers sort every match on the
server (a second click reverses), and Previous/Next page by 100 with a
range and total in the status line. A response superseded by a newer
request is cancelled and discarded, refresh steps back when the current
page was emptied, and failures clear the rows and show the error
without a modal box per keystroke.
2026-10-02 20:06:52 -04:00
aj 646d4c3975 feat(server): sort nest queries by allowlisted columns
The query accepts sort=<column>&order=asc|desc over a fixed allowlist
(saved, name, customer, status, material, dates, thickness, plate and
part counts, made by, comments, file size). Text columns sort with
NOCASE, ties break by id in the same direction so pages partition the
matches, and unknown or numeric sort values and other orders return 400.
The client sends the sort as its camelCase name.
2026-10-02 19:57:35 -04:00
aj 1fadf6c4d3 feat(server): add bounded nest query with SQL-side search
GET /api/nests/query filters name, customer, material, made by, comments
and status (stored and display names) with an escaped LIKE parameter,
orders newest saved first with an id tie-break, and returns one page of
at most 500 records plus the total match count, read in one database
hold. Unknown, repeated or out-of-range parameters return 400.

RemoteNestRepository.QueryAsync validates the same bounds before
sending, rejects oversized pages, and reports a 404 from an older server
as a server that needs updating. GET /api/nests stays the full
enumeration used by backup, restore checks and the container smoke.
2026-10-02 19:52:35 -04:00
aj f53bead0a8 fix(server-ci): verify exported image identity across Docker stores 2026-10-02 19:15:53 -04:00
aj 1ec79ae594 ci(server): validate and publish versioned GHCR images 2026-10-02 18:53:00 -04:00
aj d428357c8b feat(server): harden Docker runtime and document persistent deployment
- Run as the .NET image's non-root app user (UID 1654) with root-owned
  application files and an app-owned /app/data that fresh named volumes inherit.
- Add a curl HEALTHCHECK on /healthz (30s/5s/10s/3, 2s start interval), keep the
  explicit 8090 URL and clear the base image's 8080 port default.
- Parameterize VERSION/SOURCE_REVISION and base images; add OCI labels with
  development defaults.
- Add an image-only Compose example (required image and bind address, named
  volume, cap_drop ALL, no-new-privileges) and an env template.
- Document deployment, the upload limit, scoped ownership preparation, and
  checked backup/restore/upgrade functions that refuse existing destinations and
  verify the metadata list and every archive hash before switching volumes.
- Extend the container smoke: image user/healthcheck/label contract, PID 1 UID,
  capabilities and writable paths, Docker-reported health, near-limit and
  oversized uploads, stopped-service backup restored into a second volume, and
  startup failure on read-only and root-owned data mounts.
2026-10-02 17:23:36 -04:00
aj 142c77a27f fix(server): serialize SQLite access and validate storage health 2026-10-02 16:31:46 -04:00
aj b39509a6a3 fix(server): update SQLite to patched native library
Microsoft.Data.Sqlite 8.0.11 bundled SQLitePCLRaw.lib.e_sqlite3 2.1.6 (SQLite 3.41.2), affected by CVE-2025-6965 / GHSA-2m69-gcr7-jv3q. 8.0.31 bundles 2.1.12 (SQLite 3.53.3).
2026-10-02 16:10:24 -04:00
aj 6d1e0cb896 docs: list server integration tests in agent instructions 2026-10-02 16:06:31 -04:00
aj 5d8874a9e7 fix(server): reject malformed multipart uploads with 400 2026-10-02 15:48:55 -04:00
aj b17fe1a318 test(server): cover HTTP storage contracts with isolated SQLite 2026-10-02 15:37:44 -04:00
aj 7999e3cdde fix(server): repair Docker build and verify persistent client round trips 2026-10-02 14:17:13 -04:00
aj 14a7ce43a7 test: prevent selected-area fill from crossing sheet cutoffs 2026-10-02 07:12:15 -04:00
aj bd5db59e42 fix(irregular): preserve analytic line bounds after polygon cleanup 2026-10-01 20:45:22 -04:00
aj 25755a8850 fix(irregular): keep exact bounds for line-only part outlines
Orientation bounds were padded by the arc chord tolerance even when the outline has no arcs, so a part that exactly fills the work area after rotation was rejected before packing. Pad only outlines with flattened arcs; footprints and NFPs still use the chord tolerance.
2026-10-01 18:44:51 -04:00
aj c7b4ba4457 test(irregular): cover exact-contact rotated rectangle placement 2026-10-01 18:44:51 -04:00
aj d8b763da13 test(irregular): add six synthetic nests and bounded validity gate 2026-10-01 17:39:08 -04:00
aj c01a348243 feat(irregular): offer certified Fill blocks for repeated parts 2026-10-01 16:17:25 -04:00
aj e886bcfa35 fix(overlap): retain unchanged pair highlights during rechecks 2026-10-01 15:10:17 -04:00
aj 4fa0f636ce ci: run cross-platform suites on pull requests and master 2026-10-01 13:44:40 -04:00
aj 94e23e4b7a feat(irregular): place best-fit pairs through member free regions 2026-10-01 12:31:38 -04:00
aj 591d08c627 docs: make GitHub the primary repository
GitHub (ajisaacs/OpenNest) now owns branches, tags and pull requests.
Gitea keeps a read-only pull mirror that syncs hourly and refuses pushes,
so PRs merged on GitHub are no longer closed by the old Gitea push mirror.
2026-10-01 08:34:01 -04:00
aj 6b6928213d fix(packing): apply work-area slack only at plate boundaries 2026-10-01 08:15:57 -04:00
aj a26e663f01 feat(geometry): find maximal rectangles inside any region
MaximalRectangles.InRegion finds the largest axis-aligned rectangles that fit
wholly inside a Clipper region, such as a cutout shrunk by the part spacing.
It grids the region on its vertex coordinates plus even divisions, keeps a cell
only when no edge enters it and an even-odd row scan puts it inside, then runs
the shared histogram search.

Exact for regions with only horizontal and vertical edges; slanted and curved
edges are followed as a staircase that never crosses the boundary, short of the
true maximum by up to about one cell per side. Tests cover a rectangle, an
L shape, a frame with a hole, a round hole (inscribed square), a diamond and a
star; disabling the edge-crossing check fails the three slanted-edge tests.
2026-10-01 07:29:05 -04:00
aj 694d4b28cc refactor(geometry): move maximal-rectangle search out of RemnantFinder
The histogram search over a free/blocked cell grid never depended on the
obstacles being boxes. Move it to OpenNest.Core as MaximalRectangles.FromGrid
so other callers can build their own grids; RemnantFinder keeps building its
obstacle grid and calls it. No behavior change.
2026-10-01 07:28:13 -04:00
aj d14071692b feat(geometry): seed drawing alignment with robust contour ICP
Add DrawingAligner: bounded multi-start rigid ICP that maps a revised
drawing's geometry into the old drawing-local frame as an operator-review
seed. Material-filtered programs flatten through the shared chord-error
machinery, resample via the shared ContourSampler ring scheduler, and fit
closest bounded target segments with trimmed weighted least squares
(reflection never solved, never applied). Seeds come from outer-centroid
translation and MBR angle deltas with the 90-degree family plus identity.
The stable outer boundary is fitted first; retained hole evidence only
reorders candidates inside the outer tie band, so a moved hole cannot
drag the perimeter. Structured AlignmentResult reports convergence,
residual quantiles, bidirectional coverage, symmetry ambiguity,
reflection uncertainty, and a bounded diagnostic IoU. Refusals
(invalid input, insufficient support, unsupported topology, sample
budget, cancellation) are reported as reasons, not thrown.

Tests verify recovered poses against exact fixture geometry (true
segments/arcs/circles, not sample clouds) for known transforms,
symmetric rectangle/circle ambiguity, mirror non-selection, moved-hole
perimeter preservation, and bounded region arithmetic (identical=1,
disjoint=0).
2026-10-01 04:57:24 -04:00
aj a368ef0115 refactor(geometry): share cut-direction sampling primitives
Extract the pure world-space position/tangent math from the WinForms
cut-direction arrow renderer into Core (ContourSampler), keeping screen
conversion, arrowheads, and the per-move display policy in the view.
Add a contour-wide arclength scheduler (RingMoves) for measurement use:
distance carries across segment boundaries, short segments are never
omitted, and the closing vertex is not duplicated. Characterization
tests pin the arrow policy (counts, short-move skipping, CW/CCW and
full-circle sweeps, subprogram offsets, suppressed/rapid moves,
incremental mode) and the scheduler's start-vertex invariance.
2026-10-01 01:54:00 -04:00
aj 62fd42346e fix(irregular): prevent overlaps from false NFP gaps 2026-10-01 01:05:07 -04:00
aj 56ab3a5f30 style(irregular): sort NoFitCache imports 2026-10-01 01:04:56 -04:00
aj 7a52058b0f fix(ui): refresh plate list when parts are added or removed
The plates ListView showed stale part-count and utilization columns
after interactive placement, clone, or fill, because only the drawing
list was refreshed on the debounced PartAdded/PartRemoved timer. Also
refresh the plate rows when a drawing is deleted from every plate.
2026-09-30 23:16:07 -04:00
aj 7ce53008f1 fix(rectangles): place parts within the layout check's work-area slack
Parts drawn a few millionths over their sheet's work area (for example a
36.125006 panel on a 36.125 sheet with no edge spacing) pass NestLayoutCheck,
which allows Tolerance.Epsilon of overhang, but the Rectangles engine refused
them and left them unplaced. The engine now allows 90% of that slack: a box
that exceeds the sheet by no more than the allowance packs as exactly the
sheet's size, so it spans the axis and the overhang lands only past the edge.

The check's bounds slack is named NestTolerances.WorkAreaSlack (same value,
no behavior change) so the engine and the test layout assertion share it.

Rectangle-lane benchmark (91 strict + 77 boxable jobs): every layout valid;
4 jobs that left panels unplaced now place them (strict complete 86 -> 88,
boxable complete 74 -> 75, one more boxable job places 2 more parts); no other
job changed.
2026-09-30 22:58:41 -04:00
aj 4b6e131cec style(tests): apply dotnet format to RectanglesNestingEngineTests 2026-09-30 22:58:41 -04:00
aj 2e700df8ed feat(fill): pack leftover parts with maximal rectangles
DefaultPlateFiller.PackArea (Default, Strip and both Remnant strategies)
now packs leftover parts with the shared maximal-rectangles packer instead
of bottom-left corner points. Every fit rule and pick mode is tried; the
layout placing the most parts, then box area, per priority tier wins, and
the strategy's own fill comparer breaks ties so remnant strategies keep
their clear side. PackBottomLeft and PackEngine are removed.

Rectangle-lane benchmark (91 strict + 77 boxable jobs) against the old
packer: no strategy lost a valid layout; Default, Vertical Remnant,
Horizontal Remnant and Strip complete 2-3 more strict jobs, and Strip
gains a valid one. Cost on jobs complete in both runs falls for every
strategy (Default -0.6% strict, -2.7% boxable). Default is about 7-10%
slower on the changed jobs.

Golden layouts for Default and both Remnant strategies are re-captured;
each is complete, passes NestLayoutCheck and repeats exactly.
2026-09-30 22:19:25 -04:00
aj c6f8a06dc7 refactor(packing): share the maximal-rectangles packing loop
Move the Rectangles engine's global and ordered pick loops into
RectanglePacking.MaxRectsPacker, which works on plain box sizes so other
fill paths can reuse it. SheetPacker maps part orientations onto it.
No behavior change: rectangle-lane benchmark results are identical job for
job (91 strict and 77 boxable jobs).
2026-09-30 22:11:12 -04:00
aj 96bcf6ded1 refactor(packing): move maximal-rectangles sheet into shared rectangle packing
MaxRectsSheet only tracks free rectangles and has no job dependencies, so it
moves from the Rectangles engine into OpenNest.Engine.RectanglePacking where
the interactive fill packer can use it. No behavior change.
2026-09-30 22:11:12 -04:00
aj e9c312b9fb refactor(packing): remove unused rectangle fill and pack engines
FillNoRotation, FillSameRotation and FirstFitDecreasing had no callers
in production code or tests.
2026-09-30 22:11:12 -04:00
aj a854e34a3d test(geometry): pin near-zero-sweep arc bounds without a local DXF
DxfImport_ArcBoundingBoxes_Diagnostic read a drawing from one user's
desktop, returned early elsewhere and asserted nothing, so the near-zero
sweep bounding-box fix (4053f1f) had no regression test that could fail.

Replace it with ArcBoundingBoxTests: a forward and a reversed arc with a
sweep below Tolerance.Epsilon must be bounded by its endpoints, and an
ordinary arc crossing 90 degrees must still reach the top of its circle.
With the 4053f1f guard reverted, both near-zero cases fail (the box grows
to the full circle); with it in place all three pass.
2026-09-30 22:02:46 -04:00
aj 3b9f5ca5fa test: remove customer drawing names from tests
GeometrySimplifierTests hardcoded a customer DXF on one user's desktop and
returned early when it was missing, so the test passed without running
anywhere else. Read the path from the optional "SimplifierGapDxfPath"
entry in test-config.json and report a skip when it is not configured,
matching the other external-fixture tests.

Replace the customer part name in a RemnantFinderTests comment with a
neutral description. Customer drawings stay outside the repository.
2026-09-30 21:46:29 -04:00
aj 0209a24008 style(tests): apply dotnet format to RemnantFinderTests 2026-09-30 21:45:12 -04:00
aj 8450ceee40 refactor(geometry): share signed-angle accumulation through ArcFit
EllipseConverter, SplineConverter and GeometrySimplifier each carried an
identical private SumSignedAngles. Move the unchanged body to
ArcFit.SumSignedAngles and call it from all three, keeping the ordered
accumulation, strict half-turn comparisons and empty/single-point result.

ArcFitTests compares the shared method bit-for-bit with a separate
test-local accumulator across half turns, the atan2 seam, multiple
turns, translated centres and NaN inputs, and checks that inputs are not
mutated. The converter winding characterization from 8664656 still
passes unchanged.
2026-09-30 21:20:58 -04:00
aj c55e375cf3 refactor(engine): remove legacy multi-plate auto-nest orchestration
Desktop Auto Nest, Console --autonest, MCP autonest_plate and the API
NestRunner all run through NestPipeline, so nothing calls the old
orchestration any more.

Delete MultiPlateNester (with MultiPlateNestOptions, MultiPlateResult,
PlateResult, PartClass and PartSortOrder), PlateOptimizer and
PlateOptimizerResult, plus their tests. The explicit-strategy contract
those tests checked now lives at PlateFillService.ResolveStrategy, which
keeps its null-means-Default, canonical-name and unknown-name tests.
CreateFiller loses its internal visibility, which only the deleted
orchestrators used.

This breaks source and binary compatibility for external callers of the
removed types; whole-job callers use NestPipeline.Run or INestingEngine.
2026-09-30 21:07:32 -04:00
aj 5e9eaf8a4c perf(fill): reuse validation for an unchanged row
Skip FillGrid's Step 2 overlap check only when Step 1 checked this exact
row clean and the perpendicular tiling appended zero parts, so gridResult
still holds the same Part objects in the same order and poses. The local
rowIsVerified flag is cleared by every Step 1 skip or bbox fallback, and
any nonzero append (including partial-copy parts) keeps the eager check.

Debug work assertions pin the reduced overlap work (horizontal stripe
8->4 exact calls, 4->2 preparations, 16->8 triangulations; vertical
stripe 36->18, 4->2, 38->19) and keep single-seed, Horizontal full-grid,
partial-only and PerpOnly work at base; the invalid overlapping-seed
control pins both fallback stages plus the retained exact-call total.
Three mutants (forced eager check, dropped flag, complete-rows-only)
are caught by their designated controls. Serial whole-job layout is
byte-identical to the frozen 62b5a8d0 oracle before and after.
2026-09-30 17:01:11 -04:00
aj dbcf7dea65 refactor(geometry): share directional part-line preparation 2026-09-30 13:40:22 -04:00
aj 98870e78a7 refactor(cincinnati): share hole subprogram call output 2026-09-30 13:40:22 -04:00
aj c9bb32470c refactor(geometry): share ray and contact-angle primitives 2026-09-30 13:40:22 -04:00
aj e84d7f0e72 fix(ui): traverse nested active controls when restoring focus 2026-09-30 13:40:22 -04:00
aj 6426a98f82 fix(geometry): identify invalid ellipse axes in exceptions 2026-09-30 13:16:00 -04:00
aj 23ffc14f3f fix(controls): dispose owned separator pens 2026-09-30 13:16:00 -04:00
aj b2deed6c5a test(bestfit): observe posted async failures during dispatcher drainage 2026-09-30 13:16:00 -04:00
aj 1cc95c8115 fix(bestfit): own async viewer operations and report failures 2026-09-30 13:16:00 -04:00
aj 038886a757 fix(mcp): bound engine harness execution and output drainage 2026-09-30 13:16:00 -04:00
aj 8664656658 test(geometry): characterize converter winding before ArcFit extraction 2026-09-30 08:57:34 -04:00
aj d7fd2f8468 style(geometry): normalize diagnostic interpolation alignment 2026-09-30 08:56:14 -04:00
aj 5bf3c5f9aa docs(packing): clarify PackingId is caller-supplied and clone-preserved 2026-09-30 07:40:54 -04:00
aj 984d31a391 fix(packing): preserve item type and separate packing identity in clones 2026-09-30 07:24:39 -04:00
aj 4f46fa15ea style(packing): normalize circle-packing file encoding 2026-09-30 07:24:02 -04:00
aj 38324628b0 test(editor): prove per-operation highlighting with a status sentinel
The reorder event snapshot could previously show the reverse pass's
stale timeout label. Set a sentinel before each click so the
ProgramChanged handler can only observe the label applied by the
current operation's highlighting pass.
2026-09-30 01:39:26 -04:00
aj 132bb07bc9 test(editor): make control assertions handle-state independent and order-proving
Quality review of 50884da found the unshown RichTextBox oracle compared
cached CRLF text against the highlighted editor's native LF-normalized
text, which would fail on first Windows execution. Compare generated
text with normalized line endings on both sides instead, capture the
status label and preview state inside the ProgramChanged handler to
prove fallback-before-notify ordering, document the HighlightSpan
UTF-16 and rule-index contract, and pin the null-text argument check.
2026-09-30 01:29:11 -04:00
aj 50884daca1 fix(editor): bound highlighting without interrupting program updates 2026-09-30 00:14:43 -04:00
aj fc53c7732e fix(ui): format stock sizes invariantly and bound validation notices
Address Task 3b quality review:
- FormatSize now emits invariant decimal notation so a comma-decimal
  locale cannot produce stock text its own invariant parser rejects.
- Validation notices embed a bounded single-line preview instead of the
  full cell value.
- Documented GetPlateOptions' all-or-nothing throwing contract.
- STA test helpers run the worker as a background thread with a
  diagnostic timeout message; added a de-DE fractional stock round-trip
  regression.
2026-09-29 23:29:20 -04:00
aj ab1f3a28d1 fix(import): abort failed CAD text extraction 2026-09-29 22:31:26 -04:00
aj 43ebd61fdf fix(ui): reject timed-out stock and shape input 2026-09-29 22:27:05 -04:00
aj bf608fa271 test(math): name fraction culture coverage accurately 2026-09-29 22:02:32 -04:00
aj fa5e5a8e29 fix(io): bound bend-note regex execution without partial imports 2026-09-29 21:41:27 -04:00
aj 388816b015 fix(math): bound fraction regex execution 2026-09-29 21:41:20 -04:00
aj 3bdefb1d1c docs(engines): document built-in engines, renames and change rules
Adds docs/nesting-engines.md: which engine suits which jobs, the old plug-in
names the registry maps, retired engines, and the rule that an engine change
lands only when it beats that engine's current benchmark result with every
layout valid. README, release and automatic-nesting docs drop the bundled
plug-in wording; AGENTS.md now places built-in engines in
OpenNest.Engine/NestingEngines and keeps only external plug-ins out of the
solution.
2026-09-29 21:19:27 -04:00
aj 7f648e6400 build(release): package built-in engines instead of external plug-ins
Rectangles and Irregular now ship inside OpenNest.Engine.dll, so the Windows
package no longer fetches, tests and bundles OpenNest-Engines at a pinned
commit. Removes scripts/external-engines.json and the Engines/ folder,
manifest and license from the package; build-info.json drops enginesCommit.

ReleaseSmoke now checks the packaged registry: every built-in engine must
instantiate from the packaged OpenNest.Engine.dll, the Opus55NestingEngine
name must resolve to Irregular, and an unknown name must be rejected. Linux
check against a published OpenNest.Engine: pass case exit 0; missing engine
DLL exit 1. Full Windows packaging still needs the Windows runner.
2026-09-29 21:18:53 -04:00
aj b688ce896f feat(engine): add built-in Rectangles and Irregular nesting engines
Moves the two production plug-in engines into OpenNest.Engine under names
that describe the jobs they suit:

- Rectangles: plain and near-rectangular plates, maximal-rectangles box
  packing (was the RectanglesNestingEngine plug-in)
- Irregular: irregular profiles, no-fit-polygon frontier packing (was the
  Opus55NestingEngine plug-in)

Their tests and the shared engine contract/layout test kit move into
OpenNest.Engine.Tests/NestingEngines.

The registry maps the old plug-in names to the new engines, so saved desktop
selections, scripts and API requests keep working, and a leftover plug-in DLL
under an old name cannot shadow its replacement. Desktop startup passes the
registry's lookup when restoring the saved Auto Nest engine.
2026-09-29 21:18:37 -04:00
aj 1eb509740e test(export): assert drawing reference identity and dispose form on setup failure 2026-09-29 20:40:03 -04:00
aj ad36101b7f fix(export): report write failures and return truthful outcomes 2026-09-29 20:16:49 -04:00
aj 00ede7c5d0 fix(math): normalize degree interval checks in degrees 2026-09-29 19:27:23 -04:00
aj 314ca2f2a6 feat(desktop): add File -> Export Nest Report command
Capture the report target and reject whole-job nesting, open progress
windows, interactive fill and busy plate actions across every view
sharing the nest; revalidate after the save dialog, capture the snapshot
synchronously on the UI thread and render it through OpenNest.Reporting.
Windows adapter tests cover enablement, guards, cancel, success and a
write failure against an existing destination (compile-only on Linux;
Windows runtime acceptance still owed).
2026-09-29 18:29:05 -04:00
aj 64045be968 feat(ui): wire file and database nest storage 2026-09-29 16:21:17 -04:00
aj 437d659c9d feat(server): add OpenNest.Server SQLite nest storage API
SQLite-backed (Microsoft.Data.Sqlite, WAL) minimal API storing NestRecord
metadata plus the .nest archive as a BLOB. Endpoints: GET/POST /api/nests,
GET /api/nests/{id}[/file], PUT /api/nests/{id}/file, PUT
/api/nests/{id}/metadata, DELETE /api/nests/{id}, GET /healthz. Multipart
upload contract matches RemoteNestRepository (metadata JSON part + file
part). Added to OpenNest.sln, builds standalone on Linux. Dockerfile
publishes to a runtime image listening on :8090 with a /app/data volume
for the SQLite file. docs/nest-storage.md documents the wire contract,
endpoints and deployment.

Full curl round trip verified manually against a running instance:
upload (server-assigned id + computed fileSize), list, get, byte-exact
file download, metadata-only update (archive unchanged), file update
(new archive persisted), 404s for unknown ids, delete, post-delete 404.
2026-09-29 16:21:17 -04:00
aj 10ed3d3a30 feat(data): storage-mode settings and remote nest repository
NestStorageSettings persists the file/database toggle and server URL at
%APPDATA%\OpenNest\storage.json following the EngineSelectionSettings
never-throwing pattern; missing config means File mode. NestRecord carries
the shareable job metadata, INestRepository defines the backend contract,
and RemoteNestRepository implements it against the nest server with
multipart uploads (metadata JSON part + .nest file part).
2026-09-29 16:21:17 -04:00
aj ef2f9aa714 feat(core,io): persist nest status and made-by in nest files
Add NestStatus (Quote/ToBeCut/HasBeenCut) plus MadeBy on Nest, written as
additive camelCase nest.json fields with PascalCase enum strings matching
the units convention. Legacy files and unknown status values fall back to
Quote. The nest info dialog gains a Status dropdown and Made By box.
2026-09-29 16:21:17 -04:00
aj b15c4cf6c6 feat(reporting): add multi-plate pagination and dense-label fallback
Slice 2 of the nest report plan: pagination, overflow and dense-label
coverage on top of the Slice 1 one-plate library.

- NestPdfWriter: general multi-plate pagination. Summary/plate tables
  continue across pages with repeated (HeadingFormat) header rows; notes
  flow as an ordinary paragraph instead of a bounded cell; page headers
  are wrapped and sized into the top margin; drawing areas are located
  per page via DocumentRenderer.GetRenderInfoFromPage.
- ReportText: lossless pre-wrapping (MigraDoc clips an over-tall row and
  lets an unbroken token overflow a narrow cell without warning), capped
  table-cell line counts, plate-range compression ("1-2, 4"), and
  A/B/.../AA map-grid row names.
- NestReportDiagram: label placement centers each part ID on its
  PolyLabel pole (matching PlateView's LayoutPart), computed on a
  placement-independent quantized copy so identical parts share one
  label position. Parts whose ID cannot fit legibly at overview scale
  get a lettered/numbered map grid and a zoomed, framed detail page per
  crowded cell; the writer fails with plate/part/ID when even that
  cannot place a label, or a plate would need more than 24 detail views.
- Tests: NestPdfLayoutTests (multi-plate totals/ranges/same-named
  references, table continuation with no lost rows, long name/notes
  wrapping without column overflow, mm units in all four quadrants,
  dense-label detail views with hole avoidance, save/reload of the
  tabbed/lead-in fixture with stale tab flags, invalid later-plate data,
  late write failures against an existing destination, source
  unchanged on success/failure), ReportPdf test helper (page/word/
  content-stream extraction).
- docs/nest-reports.md: replace the Slice 1 one-plate limits section
  with the general pagination/dense-label contract and the process-wide
  PDFsharp/MigraDoc render lock.

Verification: Reporting filter 70/70; full OpenNest.Tests 2524
passed/21 skipped/0 failed; Engine 351/351; IO 77/77;
EnableWindowsTargeting=true full-solution build 0 errors; scoped
dotnet format --verify-no-changes exit 0. Preview PDFs rendered and
visually inspected (evidence: /home/aj/extracted/2026-09-29/opennest-report-slice2/).
Windows runtime, the desktop adapter and packaged-app font deployment
remain unverified (Slice 3).
2026-09-29 15:43:00 -04:00
aj e4d64a88eb fix(reporting): serialize concurrent nest report exports
PDFsharp/MigraDoc layout and font state is process-wide. Parallel exports
of the same snapshot laid text out differently (for example merged words
like "Total physicalsheets" and shifted table columns). Serialize the
whole render-and-save in NestPdfWriter.Write; reports are rare.

Regression test exports one snapshot 128 times in parallel and compares
page content streams with a sequential export; it failed with dozens of
mismatches per run before the lock.
2026-09-29 14:04:53 -04:00
aj 74d2b6aa68 feat(reporting): add one-plate nest report PDF library slice
Add OpenNest.Reporting (net8.0, PDFsharp-MigraDoc 6.2.4) with a detached
report snapshot and a PDF writer for an empty/demand-only job or a single
plate layout: Letter portrait summary with vector part thumbnails and a
landscape plate page with a vector sheet diagram, both with Page X of Y.

- Snapshot uses reference identity for document-local R### IDs, recounts
  nested quantities from placements x copies with checked wide integers,
  excludes cutoffs from accounting, and retains no live domain objects.
- Geometry keeps Cut/Display material paths, drops rapid/scribe/lead paths,
  preserves native arcs, holes and intentional tab gaps, and rejects
  malformed programs with plate/part identification.
- Open contours and cutoffs are stroked per contour so PDFsharp cannot join
  a tab gap to the next contour.
- Bundled DejaVu Sans via a custom resolver (including MigraDoc's error
  font); unsupported text fails with the field and code point.
- Layouts beyond this slice (multiple plates, page overflow, labels that
  do not fit) fail with NotSupportedException before the destination is
  replaced; output is rendered to a temporary sibling and moved atomically.
2026-09-29 13:13:32 -04:00
aj 64d0e50df3 fix(ui): keep an add-new row in Auto Nest stock options 2026-09-29 12:43:55 -04:00
aj 7d179c696a fix(sequencing): snapshot plates before sequencing the entire nest 2026-09-29 12:23:19 -04:00
aj 662bc133b1 refactor(nesting): unify console MCP and API validation and commit policies 2026-09-29 11:15:05 -04:00
aj b3a4390169 refactor(ui): route every Auto Nest engine through the validated pipeline 2026-09-29 10:56:38 -04:00
aj b8bded1b09 fix(engine): reject unrepresentable nesting output before geometry checks 2026-09-29 10:47:36 -04:00
aj ac901f7ee9 feat(ui): remember the selected Auto Nest engine 2026-09-29 10:43:36 -04:00
aj 43253cd9ab feat(engine): share stock construction across nesting callers 2026-09-29 10:33:16 -04:00
aj 7aeb636ca2 fix(engine): discard cancelled pipeline results from uncooperative engines 2026-09-29 10:31:23 -04:00
aj c914d2bbd9 Add NestPipeline: one validated engine path for all front ends
build NestJob -> resolve engine by name -> Solve -> independent
NestLayoutCheck validation -> bind placements to caller drawings.
The pipeline never commits to plates and never mutates caller items;
validation failures are returned as messages naming real drawings
(unknown-requirement placements are reported, not dropped). Console,
MCP, API and desktop Auto Nest will adopt this path in later phases.

- NestPipeline.Run(request): registry-resolved engine, unknown names
  list the registered engines; cancellation propagates untouched.
- NestPipeline.Run(engine, ...): stub/plug-in engines take the same path.
- NestResultBinder: pose semantics identical to NestResultMaterializer.
- NestLayoutCheck: Violations overload with per-requirement display names.

Tests: overlap stub -> violations w/ drawing names, no throw; ghost
placement -> violation + excluded from further checks; unknown engine
-> NotSupportedException listing engines; cancelled token -> no result;
happy path -> bound by reference, caller quantity untouched.
2026-09-29 10:23:19 -04:00
aj 481c3e5128 docs: trim agent instructions and remove historical performance report 2026-09-29 10:18:50 -04:00
aj 29953ea601 feat(overlap): auto-recheck the active plate after layout edits settle
Nest windows now rerun the overlap check once the layout has been
unchanged for 0.5 s, instead of leaving 'Overlaps: not checked'. Edits
show 'Overlaps: check pending...'; drags are caught by the paint-time
pose stamp, collection edits by their events. The check waits while a
mouse button, modal dialog or fill is active, supersedes a running check
when the layout moves again, and does not retry a canceled or failed
layout until it changes. Rechecks use the incremental analyzer, so only
the moved parts' neighbors are recomputed.

Automatic results update only the canvas label (the status bar keeps the
last command's message) and keep Display > Off. Check Active Plate still
runs immediately. InvalidateOverlapCheck now also drops cached material.
2026-09-29 09:31:51 -04:00
aj 99c31748ba feat(diagnostics): cache overlap material and recheck incrementally
PlateOverlapAnalyzer.Capture(parts, OverlapMaterialCache) reuses each clean
program's converted entities and prepared material across requests, and
Analyze(snapshot, previous) reuses pair results whose two parts kept the
same source, exact pose and relative order, renumbering them. A recheck
after moving one part only clips that part's neighbors again.

On 501 real PEP plates: full check median 1 ms / max 6.4 s; incremental
recheck after one move median 0.1 ms / max 38 ms. Incremental results
matched uncached full analysis exactly across 2505 edits.
2026-09-29 09:31:26 -04:00
aj 2b8a3ce7db fix(leadin): add close button to Place Lead-in side panel
The docked side panel could only be closed with Escape, which was
unreliable: MainForm consumes Escape before ActionLeadIn's KeyDown
handler, so with a part selected ActionManager called the action's
empty CancelAction and nothing happened; with nothing selected the
panel closed but was stored as the previous action, so the next
Escape reopened it.

- EditNestForm side panel gets a header with the panel title and an
  always-visible close button that ends the action.
- ActionLeadIn.CancelAction now steps back (unlock contour, then
  deselect part), replacing the dead KeyDown handler.
- Actions can opt out of Escape-resume (ResumeOnEscape); the lead-in
  action does, so a closed panel stays closed.
2026-09-29 08:38:48 -04:00
aj 36de55f10f style(actions): remove BOM from Action.cs per .editorconfig 2026-09-29 08:38:41 -04:00
aj 55fe0ef228 fix(cnc): extend first-cut edge for outside corner lead-ins
A straight lead-in at a convex outside-perimeter corner now runs along the
extension of the edge cut first, so the torch enters on that line and keeps
cutting it. The result no longer depends on which of the corner's two edges
auto-assign or the manual cursor picked, which made placement flip between
straight and 90 degrees. The approach angle is ignored at such corners.

The straight lead falls back to the first-cut edge normal when its pierce
would be closer than PierceClearance to the contour (very flat or tessellated
corners). Reflex perimeter corners bisect the notch. Line lead-outs run on
straight past a convex corner along the last-cut edge, except on tabbed
perimeters. Program generation and the Place Lead-in preview share
ResolveLeadIn/ResolveLeadOut.
2026-09-29 07:46:20 -04:00
aj 8720580004 fix(sequencing): cut scrap cutoffs before crossed parts 2026-09-29 00:05:28 -04:00
aj c3dd346b7a feat(cutoffs): apply nest-wide with minimum retained tail 2026-09-28 23:43:06 -04:00
aj 4afab63046 fix(cnc): advance rapid display through cutoff cutting moves 2026-09-28 23:19:08 -04:00
aj 2cc06d1dc7 fix(cutoffs): exclude leads from material perimeter chaining 2026-09-28 23:08:47 -04:00
aj 45d2dfced4 feat(posting): require verification and explicit risk acknowledgment 2026-09-28 22:50:03 -04:00
aj a5fddec0e9 fix(cutoffs): account for bounds translation roundoff safely 2026-09-28 22:19:02 -04:00
aj a8204bb51f feat(plateview): annotate overlap centroids 2026-09-28 22:17:49 -04:00
aj e4d07121c8 feat(plateview): show material overlap areas 2026-09-28 21:35:05 -04:00
aj 76044f6bd9 merge: integrate polygon overlap diagnostics 2026-09-28 20:56:46 -04:00
aj 1581260e31 feat(cutoffs): add automatic skeleton cutoffs with tail preservation 2026-09-28 20:50:53 -04:00
aj 19c3a89c32 feat(core): report hole-aware part overlap polygons 2026-09-28 20:44:51 -04:00
aj 4dbdc2678d fix(cutoffs): preserve clearance at ambiguous vertex intersections 2026-09-28 20:37:49 -04:00
aj fb32d508f8 fix(geometry): stabilize triangulation winding at large coordinates 2026-09-28 20:37:38 -04:00
aj 1f637ca43c merge: integrate cutout corner lead-in bisectors 2026-09-28 19:40:02 -04:00
aj 648b0eaca5 fix(cnc): bisect inside cutout corners for straight lead-ins 2026-09-28 19:25:40 -04:00
aj b254a0bd3e feat(io): persist part cutting programs and plate parameters 2026-09-28 19:17:59 -04:00
aj 74302761ce fix(io): restore hole sub-programs with negative IDs 2026-09-28 19:05:11 -04:00
aj 4da48eed68 fix(core): keep cut-offs in their cut sequence through regenerate and save
A cut-off's place in Plate.Parts is its cut sequence number, but
RegenerateCutOffs removed every cut-off part and appended it again, so
any part drag, fill or cut-off move sent the cut-offs to the end. The
nest file didn't store the position either, so reopening did the same.

RegenerateCutOffs now puts each cut-off back at its previous index (new
cut-offs go at the end), and CutOffDto.Sequence saves the index. Older
files without it load the cut-offs at the end, as before.
2026-09-28 18:44:04 -04:00
aj 6803d7519f fix(posts): post CI Fiber cut-offs in their sequence, not last
b0997f6 moved every cut-off after the parts. Plate.Parts order is the
cut sequence, and the user sets each cut-off's place in it (Set
Sequence), so the post must follow it rather than reorder. Cut-offs
still post as uncompensated open lines with no lead-in.
2026-09-28 18:41:06 -04:00
aj 86dbd8819b style(core): apply dotnet format to Plate.cs
Formatter-only: drops the UTF-8 BOM (.editorconfig charset = utf-8).
No behavior change.
2026-09-28 18:40:50 -04:00
aj 20395eed9b fix(core): keep lead-in state and rotation through part copy and rotate
Part.Clone and CloneAtOffset copied the lead-in program but not
HasManualLeadIns, LeadInsLocked, CuttingParameters or the tracked
pre-lead-in rotation. A copy of a rotated lead-in part reported
rotation 0 (the rebuilt program's), so saving it wrote the wrong
rotation and Remove Lead-ins restored an unrotated part.

Part.Rotate on a lead-in part set the tracked rotation from the
lead-in program's own Rotation, which starts at zero when the cutting
strategy rebuilds it, so a further quarter turn left Rotation unchanged.
Lead-in parts now accumulate the applied angle instead.
2026-09-28 18:22:36 -04:00
aj a886735040 fix(cnc): keep hole sub-programs private to each program copy
Program.Clone deep-copied the SubPrograms dictionary but left every
SubProgramCall pointing at the source's sub-program, and
SubProgramCall.Clone went through the Rotation setter, which re-rotated
that shared program to the call's stale angle. Copying a program with
hole lead-ins therefore rotated the source's holes, and rotating the
copy rotated the source again.

Program.Rotate also rotated a shared sub-program once per call, so two
identical holes (one deduplicated sub-program) turned twice.

Clone now binds calls to one private copy per shared sub-program
without re-aligning it, and Rotate turns each distinct sub-program once.
2026-09-28 18:22:36 -04:00
aj 75d41f3bb7 style(cnc): apply dotnet format to Program and SubProgramCall
Formatter-only: re-indents braced switch sections in Program.cs and drops
the UTF-8 BOM from SubProgramCall.cs (.editorconfig charset = utf-8).
No behavior change.
2026-09-28 18:22:36 -04:00
aj 26252b333a docs(posts): record M50 as the confirmed CI Fiber pallet change
The machine owner confirmed M50 swaps pallets (the sample's M50 before
M30 moves the cut sheet out for unloading). Only the single-program
between-sheet sequence remains unconfirmed.
2026-09-28 18:22:36 -04:00
aj c219ec4e66 feat(posts): CI Fiber one program per sheet, configurable pallet change
Multi-sheet nests previously went into one program with a single header
size and one M50 at the end, so sheet 2 would cut into sheet 1's
skeleton. New Sheets settings:

- One program per sheet (default on): JOB.cnc -> JOB-1.cnc, JOB-2.cnc,
  each a full program with its own size and pallet change (CL-series
  batch rule, EM-423 7.4). Single-sheet nests keep the chosen name.
- Off: one program, with /L "L0" + pallet change between sheets;
  mixed sheet sizes are rejected.
- Pallet change code (default M50, unconfirmed for multi-sheet CI Fiber
  runs; documented as a release blocker).

All sheets are validated and rendered before any file is written.
IMultiFilePostProcessor lets the desktop app confirm overwrites of every
target file and list what was saved, and the console print each file.
2026-09-28 18:22:36 -04:00
aj 32586149c6 refactor(posts): remove CI Fiber part comment override
A fixed override wrote the same text on every part, making parts
indistinguishable in the program. Each part's ( PART:... ) comment now
always uses its source file or drawing name. Saved configs that still
contain PartComment load normally; the key is ignored.
2026-09-28 18:21:21 -04:00
aj 9ec28add80 fix(posts): keep CI Fiber material codes case-insensitive after reload
System.Text.Json rebuilds MaterialCodes with the default ordinal comparer,
so a saved config matched 'Mild Steel' but not 'mild steel' and silently
fell back to the default code. The setter now re-keys assigned maps
case-insensitively.
2026-09-28 18:21:07 -04:00
aj 35aa64fe4e feat(posts): sectioned settings editor for CI Fiber post
Replace the generic PropertyGrid for configs that opt in via
PostSettingAttribute: a section list (Machine, Material, Program output,
Macros) with labelled fields, help text, numeric ranges and an editable
material-code table. Edits apply only when OK validates every field.
Unannotated configs (Cincinnati CL, GravographIS) keep the PropertyGrid.
2026-09-28 18:21:07 -04:00
ajandClaude Opus 5.5 c33337cea2 fix(cnc): stop double-counting first incremental rapid in rapid display
RapidEnumerator primed the walk position at the first pierce point, then
the skipped first rapid advanced it again. Raw programs start with a zero
rapid so this was invisible, but lead-in programs start with a real
incremental offset to the pierce, which shifted every later rapid by that
delta and drew rapids off the sheet. Start the walk at the program origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:21:37 -04:00
ajandClaude Opus 5.5 b0997f614b fix(post): post sheet cut-offs in CI Fiber without comp or lead-in
Posting a nest with a sheet cut-off threw because the CI Fiber writer
treated every contour as a compensated part contour and requires a
linear lead-in after G41/G42 (TF5200 13.2.4.1). Cut-offs are open
centreline cuts with no lead-in and no inside/outside, so they now post
without G41/G42 and run after every part on the sheet so the sheet is
not severed before the parts are cut (matching the CL post).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:14:07 -04:00
aj 6d7c98ef4a docs: document defaults.json nest-defaults mechanism replacing .nstdot 2026-09-28 08:22:49 -04:00
aj f44c1d465f feat(ui): defaults.json-backed new nests; retire .nstdot templates
New nests now load plate defaults from %APPDATA%/OpenNest/defaults.json
instead of a .nstdot nest template:

- Tools > Nest Defaults... edits the file directly (new dialog).
- Tools > Save Current Plate as Defaults captures the active plate with
  no dialog (visible when a nest is open).
- New_Click no longer unzips a template; a corrupt defaults file warns
  once per session and falls back to built-in values.
- First run converts an existing NestTemplatePath .nstdot to
  defaults.json and clears the legacy setting (kept readable on a
  failed conversion); the setting itself stays, marked legacy.
- Save As no longer offers the .nstdot filter; SaveTemplate removed.
- BomImportForm and OptionsForm template plumbing removed in favor of
  the shared NestDefaults store.
2026-09-28 08:20:49 -04:00
aj bdb78a689d feat(defaults): JSON nest defaults store with per-field fallback
Replaces the .nstdot nest-template mechanism for new-nest plate
defaults. NestDefaults persists units, plate size, quadrant, part
spacing, and edge spacing to a single JSON file (DefaultPath:
%APPDATA%/OpenNest/defaults.json). Load never throws: a missing,
corrupt, or partially valid file degrades field-by-field to the
built-in fallback values, and unknown fields or a future version
number are ignored.
2026-09-28 07:09:52 -04:00
aj 3f89f8f5dd feat(ui): Tools > Expand Spacing for selected parts
Mirrors PushSelected: PlateView/SelectionManager.ExpandSelected runs
Expander.Expand on the selection, marks parts dirty, regenerates cut
offs, and reports achieved spacing plus blocked-pair count in the
status bar. Menu item joins Align Selected's enable surface.
2026-09-28 03:17:55 -04:00
aj 3cab90747a feat(expand): plateview spacing expander
Grows part-to-part spacing of a selected group with the work area and
non-selected parts as hard boundaries. Doubling + bisection search over
the target spacing; Gauss-Seidel straight-line relaxation with anchor
mover policy (later-index selected part moves, first selection never).
Overlapping input is separated along penetration MTVs instead of being
rejected. Cancel/failure never mutates part positions. Clearance gains
BoundaryDistance for ring-pair gaps (part-in-cutout legality).
2026-09-28 03:05:45 -04:00
aj fff3bef4e4 feat(geom): signed clearance kernel between polygons
Omnidirectional minimum distance with separating direction (positive)
and penetration depth with minimum-translation direction (negative),
for the PlateView spacing expander. Overlap verdict defers to
Collision.HasOverlap so kernels never disagree. Basis for the fixed-s
separation solver.
2026-09-27 23:36:26 -04:00
aj 2b78fb3a75 fix(geometry): resolve blocking contacts across directional slide paths 2026-09-27 23:24:31 -04:00
aj a04460c57b refactor(ui): drop duplicate plate preview from nesting progress dialog
The dialog rendered the same best-parts layout as transparent ghost
parts already shown live on the main plate view, and its embedded view
reset zoom on every improvement while lacking placed parts, work-area
and cut-off context. The progress dialog is now a compact stats strip
(266px wide) with the plate counter preserved for multi-plate runs.
Removes the never-called SetStationaryParts stationary-preview path
along with PreviewPlate/UpdatePreview/CreatePreviewPlate.
2026-09-27 20:43:34 -04:00
aj 27948ba8cf docs(posts): replace bundled manual with implementation references 2026-09-27 19:52:25 -04:00
aj 1fdede396b ci(release): bundle and verify external nesting engines 2026-09-27 19:11:14 -04:00
aj 1b9988a1ba ci(release): build and verify Windows desktop packages 2026-09-27 18:57:38 -04:00
aj ca1902e075 fix(bestfit): stop at internal arc tangency 2026-09-27 16:35:53 -04:00
aj bc5fd86996 test(fill): characterize unchanged-row validation reuse 2026-09-27 15:10:01 -04:00
aj 1b23ad79f2 perf(fill): reuse part triangulations within an overlap check
After 1b, triangulating both polygons on every pair was the largest
remaining overlap cost (27% of main-thread samples on the corpus job).
PartOverlapChecker now triangulates each part at most once per check,
lazily after the bounding-box gate, and passes the triangles to a new
internal Collision.HasOverlap overload that runs the unchanged
OverlapRegions body. Triangles are only read by clipping and hole
subtraction, so reuse gives identical verdicts.

Verification:
- 49,000 seeded decisions with reused triangles match LegacyCollision;
  triangles stay bit-identical to a fresh triangulation afterwards.
- Debug PolygonTriangulations: 246 -> 40 and 64 -> 36 per grid check;
  sharing triangles per Program instead fails 23 tests.
- Corpus job (169 parts, --engines Default --parallel 1): median
  13,398 -> 12,702 ms over 4+4 alternating runs vs 1b, identical
  outcomes; serialized layout byte-identical to the base.

Also records the Follow-up B' (Slices 1a, 1b, 2a) measurements in
docs/performance/fill-performance.md.
2026-09-27 13:49:46 -04:00
aj a27290a29c perf(fill): prepare overlap polygons once per check
Both HasOverlappingParts loops rebuilt each part's polygon from its
Program on every pair. PartOverlapChecker prepares each distinct Program
(reference identity) once and each part's world polygon once per call,
then uses the overlap-only Collision.HasOverlap. Loop order, bounding-box
prefilter, early exit and returned indices are unchanged; Part.Intersects
shares the material/polygon recipe and still returns crossing points.

Verification:
- Frozen LegacyPartOverlap differential (original Intersects and both
  loops): verdicts, indices and world polygons bit-identical across fill
  grids, patterns, touching/epsilon gaps, scribe/rapid/empty programs.
- Debug OverlapPolygonPreparations: 246 -> 1 and 64 -> 2 per check.
- Corpus job (169 parts, --engines Default --parallel 1, with 1a):
  median 18,885 -> 13,464 ms over 4+4 alternating runs, identical
  outcomes; serialized layout byte-identical to the base.
2026-09-27 13:34:04 -04:00
aj f44a1a3d2c style(console): consolidate usage text into one multiline write 2026-09-27 13:17:35 -04:00
aj 2b5485f6cf perf(core): skip unused crossing points in overlap-only checks
Collision.HasOverlap only needs the verdict, but it went through Check,
which also collected crossing points. Triangulation, clipping and hole
subtraction now live in one private OverlapRegions method shared by Check
and HasOverlap, so verdict arithmetic stays single-sourced; Check output is
unchanged.

Tests: a frozen copy of the previous Collision is the oracle. 50,000 seeded
HasOverlap verdicts and 2,400 bitwise Check results match it, plus
containment, contact, hole and input-immutability cases. A Debug-only
PerfCounters.CrossingPointScans counter proves HasOverlap no longer scans.
Malformed polygons with null outer vertices still throw when the bounding
boxes overlap (now ArgumentNullException from triangulation rather than
NullReferenceException from ToLines).

Measured (Release, same harness in both trees): about 44% less time per
overlap-only polygon check, allocations 10.0 -> 7.9 MB per 155-pair sweep.
The 169-part serialized corpus layout is byte-identical.
2026-09-27 12:54:20 -04:00
aj a98a49c00a test(fill): add opt-in overlap-check micro benchmark
Times Collision.HasOverlap over the box-overlapping neighbour pairs of two
FillLinear grids, and the FillHelpers.HasOverlappingParts grid checks
themselves. Opt-in via OPENNEST_RUN_FILL_PERF=1 (Category=FillPerformance).
Uses only APIs that predate the overlap-check work, so the same file can be
copied into a before tree for same-harness comparisons.
2026-09-27 12:54:20 -04:00
aj fdbc1fb99f style(console): combine adjacent output with readable multiline strings 2026-09-27 12:48:52 -04:00
aj 82feb78b0f feat(posts): add Cincinnati CI Fiber (TF5200) post-processor
New post-processor plugin OpenNest.Posts.CincinnatiCIFiber for the CI
Fiber laser family (nLight CLX / Beckhoff TF5200 / Precitec ProCutter,
e.g. the CI Fiber 4020 8kW). Named by machine family, not table size.

Emits the machine program contract of the Cincinnati-supplied sample NC
(12992-4SS_NEST.nc): V.E.* header, restart jump, per-part V.E.R4 blocks,
per-contour N labels with V.E.R3, skippable /L macro lines (L0/L2+G41
interior, L4+G42 exterior, L6 cut-on, ZHSOFF cut-end), G162 incremental
arc I/J, trimmed 3-decimal spaceless coordinates, CRLF, M50/M30/%.

Contour classification (interior vs exterior) derives from the material
side of the closed cut path, not hardcoded winding. SubProgramCall holes
are flattened to sheet coordinates (rotation-safe). Arc lead-ins are
rejected per TF5200 13.2.4.1 (first motion block after G41/G42 selection
must be linear). Table envelope (default 160.25 x 81.25 in) validated.

Tests: structure golden on a square-with-hole nest, rotated-hole flatten,
coordinate format, arc-lead-in rejection, table validation, suppressed/
scribe skipping, plus a SkippableFact regression against the real sample
NC (109 parts, 2071 contours, L2=1962, L4=109, perimeter vertices match
within 0.001). Fixtures configure through OpenNest.Tests/test-config.json
and the regression skips when absent.
2026-09-27 04:10:22 -04:00
aj c825f40213 refactor(posts): move post-processor projects under Posts/
Group the Cincinnati and GravographIS plugin projects in a Posts/
folder so new machine posts have one home. Project names, namespaces,
and the runtime Posts/ deploy target are unchanged; only relative
paths in the solution and project references move.
2026-09-26 22:35:23 -04:00
aj 0df2587cf2 perf(fill): reuse offset geometry for translated copies
FillLinear re-prepared offset perimeter geometry (ConvertProgram ->
ShapeProfile -> OffsetOutward) for every part it measured, although
tiled copies share one Program and differ only by Location. A CPU
profile of a 169-part Default job put 62% of wall time there.

Prepare each distinct Program (reference identity) once per public
Fill/FillRow call in local frame, then clone and translate for each
location. The cache is created per call and passed down privately
because FillHelpers.FillPattern calls Fill concurrently on one
instance. PartGeometry gains a local-frame Program overload that the
Part overload now delegates to.

Evaluation order, lazy preparation, fallbacks and tiling are
unchanged. Differential tests against a frozen copy of the previous
FillLinear check bitwise equality, including concurrent calls; Debug
work tests pin preparation counts. With the thread pool capped at one
worker, before/after whole-job layouts are byte-identical. The
Default corpus job median drops from 40,715 to 18,810 ms.
2026-09-26 20:24:34 -04:00
aj 094c4c196b fix(bestfit): never reject pair candidates by utilization
Thin-framed, hollow, or concave parts (e.g. SULLYS-035's frame) have
inherently low part-to-bbox utilization yet nest tightly, so the 30%
MinUtilization floor wrongly dropped every candidate for them. Pair
quality is judged by the rotated pair bounding-box area the results
are already sorted on; utilization now only ever serves as the
high-aspect exception (UtilizationOverride), never as a rejection.

Adds a hollow-frame helper plus regression tests that kept pairs
exist with low utilization and results stay sorted by pair area.
2026-09-26 14:14:44 -04:00
aj 77b729e58c docs(readme): drop opt-in fill performance measurements section
Keeps the README approachable for new users; microbenchmark details
remain in docs/performance/ for maintainers.
2026-09-26 13:00:32 -04:00
aj 1d1c60daa7 docs(fill): record combined initial-batch acceptance 2026-09-26 11:25:17 -04:00
aj 1e8e532063 perf(fill): skip feature extraction without an angle model 2026-09-26 10:02:15 -04:00
aj d70505b7c0 docs(fill): sync shared guidance for scalar-only extraction 2026-09-26 07:36:25 -04:00
aj 8188533d72 perf(ml): support scalar-only angle features 2026-09-26 00:02:28 -04:00
aj 6863c8bdb1 docs(fill): clarify Task 3 evidence retention 2026-09-25 21:46:31 -04:00
aj 4553f8afad perf(fill): avoid redundant bounds recomputation 2026-09-25 21:45:03 -04:00
aj 4ec92c95ec docs: share coding-agent guidance through AGENTS.md 2026-09-25 20:03:42 -04:00
aj b2c864a328 docs(fill): synchronize performance workflow guidance 2026-09-25 19:59:02 -04:00
aj 6efa6b1117 perf(fill): remove discarded extents pitch geometry 2026-09-25 19:52:19 -04:00
aj cec7396da6 perf(fill): avoid unused scores with custom comparers 2026-09-25 17:18:15 -04:00
aj b318950a54 perf(fill): short-circuit default comparisons by count 2026-09-25 16:43:09 -04:00
ajandClaude Opus 5.5 1b862dc1a8 fix(engine): allow 0.0005 spacing slack in layout validation
Layouts placed exactly at the part spacing can land ~1e-4 short once
rotated, rounded (e.g. PEP's 4-decimal exports) and snapped to the
Clipper grid, so both validators rejected layouts that were correct in
practice. NestTolerances.SpacingSlack (0.0005, far below anything a
cutting machine resolves) is now subtracted from the spacing by
NestLayoutCheck's inflation and NestJobPlacementValidator's edge-distance
check. The frozen LegacyNestValidator takes the same rule so the
equivalence tests keep comparing like with like.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:19:04 -04:00
aj d1f3907505 Merge branch 'fix/mcp-config-path' 2026-09-25 14:19:01 -04:00
ajandClaude Opus 5.5 4fb924b03a chore: restore LF line endings in CLAUDE.md
39db4dc committed CLAUDE.md with CRLF endings while the repo stores it
as LF (text=auto), turning a one-line doc addition into a whole-file
diff. Renormalized; the content is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:12:40 -04:00
ajandClaude Opus 5.5 39db4dc10f feat(ui): route Auto Nest through whole-job engines
StockLadder and Engines/ plug-ins only implement INestingEngine.Solve, so
selecting them in Auto Nest had no path to run. MainForm now solves the
whole job through JobEngineNest when the selected engine is not a
built-in fill strategy, feeding NestJobProgress into NestProgressForm and
binding the result poses back onto the nest's own drawings. Whole-job
engines throw on cancel rather than returning a partial layout, so the
progress form hides Accept for these runs. Built-in strategies keep the
existing per-plate fill path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:03:10 -04:00
ajandClaude Opus 5.5 23dd99fa2f feat(benchmark): add --progress logging for engine solves
Long whole-job solves ran silently, so there was no way to tell a slow
engine from a hung one until the timeout fired. --progress hands each
solve a JobProgressLog that prints [job/engine] lines for start, finish
(or failure/timeout), every plate commit, and candidate evaluations
throttled to one line per 2 s so parallel runs stay readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:03:10 -04:00
695ccc0a3b perf(engine): use cached triangulations in the layout check
The benchmark validator and every engine test re-triangulated both parts
for each nearby pair. NestLayoutCheck now uses TriangulatedRegion, with
Collision.HasOverlap as the fallback when it cannot decide. Verdicts are
unchanged (the frozen-validator equivalence tests still pass); validating
100 discs went from 1,254 ms to 94 ms.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:08:36 -04:00
98d0124172 perf(core): add cached-triangulation collision and an edge-grid prefilter
Collision.HasOverlap re-triangulates both polygons on every call; Qwen
measured that as its dominant cost (over 400 s -> ~110 s on a 219-part
job once cached). TriangulatedRegion (from Qwen's TriSet) triangulates a
part once and takes translation as a parameter; it returns null when it
cannot decide so callers fall back to Collision, which stays the
reference. EdgeGridPolygon (from Qwen's FastPoly) certifies clearly
disjoint shells and never reports Clear for an overlap. A seeded harness
of 100,000 decisions (concave shapes, arcs, holes, touching contacts)
finds 0 mismatches against Collision.HasOverlap.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:08:36 -04:00
4161e6d1c7 feat(core): add a concave no-fit polygon to NoFitPolygon
Core only had a convex NFP, so Opus55 and Gpt6Astra each built concave
NFPs from Clipper's Minkowski sum, and only Opus55 added the terms that
cover one part lying inside or swallowing the other - Gpt6Astra instead
filled every positive path and lost real interlocks. NoFitPolygon.Compute
ports Opus55's construction (boundary sweep united with A + p0 and
-B + a0; convex pairs use the linear edge merge). It works on filled
perimeters only; hole-aware clearance stays with collision testing.
Tests port Opus55's NFP tests and add a notch fit and a seeded property
check against Collision.HasOverlap.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:08:36 -04:00
896ed2026a fix(engine): leave scribe marks out of layout bounds and salvage
The job runner already checked sheet bounds on material contours only,
but the benchmark validator and salvage scoring used Part.BoundingBox,
which includes scribe/etch moves. A PEP bend tick that ends a hair past
the part's edge passed the runner yet failed the benchmark when placed
flush to the sheet edge, and it could shrink the credited offcut. Marks
only mark the surface, so bounds and salvage now use material only.

Benchmark before/after (all five built-in engines, local fixtures,
salvage 0.5): no job changed validity or cost. Regression tests pin the
new rule: a protruding tick flush to the sheet edge is valid in all four
quadrants, and a tick past the parts envelope no longer shrinks salvage
(targeted fixture cost 130 -> 120).

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:56:49 -04:00
7f63c725e6 refactor(engine): expose the layout validation contract to engines
Engines had to reverse-engineer the benchmark validator: Opus55 assumed a
0.01 arc tolerance (the validator uses 0.001), Gpt6Astra added hand-tuned
paddings and copied the validator's check order, Qwen picked its chord
tolerance to stay under a constant it could not reference.

NestTolerances publishes the validator's arc tolerance, the Clipper grid
and SafeClearanceMargin (with its derivation). NestLayoutCheck moves the
benchmark NestValidator's checks into OpenNest.Engine as a public API
(Clears for a part pair, Violations for a whole result); NestValidator is
now a thin wrapper. Verdicts are unchanged: tests compare ordered
violation lists against a frozen copy of the old validator, and a
tangent-disc stress test covers 432 pairs at the safe margin.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:54:54 -04:00
ec5f57171f feat(engine): add RotationPolicy.EnumerateAngles and RotationCandidates
All three plugin engines turned a RotationPolicy into trial angles by hand
(fixed angle, stepped sweep, or right angles plus the minimum-bounding-
rectangle angle for Automatic), each with its own normalization, dedup and
sweep caps. EnumerateAngles gives one deterministic, Allows-checked list;
RotationCandidates.ForShape adds the MBR-aligning angles via the existing
Polygon.FindBestRotation, and DistinctOutlines drops angles where the part
looks identical. A cap of one returns the sweep start rather than throwing,
since engines request a single sample for small orientation budgets.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:33:52 -04:00
dd1a958f5c feat(engine): expose NestJobCost, the benchmark's scoring
Engines optimized guesses at the benchmark cost: Opus55 re-implemented
salvage credit, Qwen used plate area per part area, Gpt6Astra ignored
salvage. NestJobCost moves StockLadder's EstimateNetArea into a public
home (net sheet area, unplaced-part penalty, whole-result Evaluate) and
the benchmark and StockLadder now call it. Scores are unchanged: tests pin
it against a frozen copy of the old computation and real benchmark runs.
Bounds still include marks, as before, so scores do not move.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:12:07 -04:00
ae2b0beb45 feat(engine): expose JobPartGeometry for reading snapshot material
Every plugin engine rebuilt part material from a snapshot by hand and
filtered only rapids, so all three kept counting scribe/etch marks as
material after 1b5e1b1 fixed it in the host. JobPartGeometry is the
validator's own reader made public: SpecialLayers.IsMaterial, validated
closed contours, material area, and TryRead returning null for unreadable
parts. The job validators now use it, so engines and validation agree.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:12:07 -04:00
ajandClaude Opus 5.5 6ab45e6de7 test(engine): seed spacing brute-force sampling deterministically
The seed came from string.GetHashCode, which .NET randomizes per process,
so each run drew different samples and the "ring" case occasionally drew
fewer than six rejections and failed its coverage assertion (1 in 6 runs),
even though every validator decision matched the brute-force reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:12:07 -04:00
029299ccf8 feat(engine): add NestJobResultBuilder for engine result assembly
Engines assembled NestJobResult by hand - instance indices, fulfillment,
stock usage, status and PlateCommitted progress - and Qwen38FlashNext got
PlateIndex wrong (stock index instead of sheet order). The builder assigns
plate and instance indices itself and rejects overproduction and exhausted
stock, so engines only decide placements.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:55:10 -04:00
4cdb39870b feat(engine): expose NestPlateStock.WorkArea, Area and Fits
Every plugin engine re-derived the quadrant/edge-spacing work area by hand
(Gpt6Astra, Opus55 and Qwen each had a copy, as did the placement
validator). One definition on the stock removes that duplication and the
chance of an engine disagreeing with the validator's bounds.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:55:10 -04:00
ajandCodex 95833236cf docs(engine): clarify priority so scarce stock favors lower numbers
Document constructor and property semantics and cover priority zero versus nine on one sheet.

Co-Authored-By: Codex <noreply@openai.com>
2026-09-25 07:53:16 -04:00
ajandClaude Opus 5.5 1b5e1b14a6 fix: leave etch/scribe marks out of nesting geometry
Every nesting-geometry consumer filtered only rapids, so scribe/etch
moves counted as part material. An etch tick that ends a hair outside
the outline (PEP bend ticks start on the notch edge) made the part
"open geometry leaving the material region": the job validator threw
and every built-in engine plus Gpt6Astra crashed on real PEP jobs
(PT75, drawing 4980 A01 PT77). Marks are only on the surface, so they
should never affect placement, collision, area, or validation.

- SpecialLayers.IsMaterial excludes Rapid and Scribe; used by drawing
  area, canonical angle, part collision, PartGeometry, plate perimeter,
  best-fit/pair evaluation, rotation analysis, GPU evaluators, and both
  validators. Timing, display, splitting and posts still see marks.
- ConvertGeometry also maps the saved SCRIBE layer name to Scribe, so
  programs rebuilt from stored entities keep their marks.
- NestReader repairs older files (e.g. PepNestExport output) whose
  programs saved etch as cut moves while source entities kept SCRIBE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:44:29 -04:00
ajandClaude Opus 5.5 e3d10e95ae chore: move plugin engines to the OpenNest-Engines repo
Engines now live in https://git.thecozycat.net/aj/OpenNest-Engines (history
carried over) so they can be published independently, and so a copy of
OpenNest handed to a model for an engine-building run contains no
competing engines. Engines still load at runtime from an Engines/ folder
next to the app/benchmark output; nothing in the solution referenced them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:02:29 -04:00
aj 22d21924b8 feat(astra): add independent nesting engine and fix hole validation
Replace the Terra scaffold with an independent configuration-space contact placer and bounded stock-plan search. Include plugin tests, synthetic and DXF benchmark drivers, results, and deployment documentation.

Correct shared collision clipping and hole subtraction so curved-hole inserts validate consistently. Cover translated layouts, spacing violations, operand order, winding, and independent boolean-area comparisons.

Validation: 1,293 tests passed with 12 fixture skips; all 34 synthetic/generated and four DXF cases are valid and complete.
2026-09-24 00:14:36 -04:00
ajandClaude Sonnet 5 87f7ddad1a docs(engines): allow fill/pattern components in Terra and Qwen scaffolds
Reusable Fill/, BestFit, RectanglePacking and CirclePacking components are
fair game; whole-engine delegation and run-all-pick-best stay banned.
Improvements to shared components go in the engine's own project and are
reported, not applied to OpenNest.Core/OpenNest.Engine.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 19:15:19 -04:00
aj 23fcec4b6c docs: compress README to a skimmable overview
Drop deep engine/validator/benchmark internals (covered by CLAUDE.md and
code comments) and per-option reference tables in favor of a quick-start
level guide.
2026-09-23 17:37:07 -04:00
aj d6d819bfed chore: remove tools directory
One-off helper utilities don't belong in the repo. PepNestExport moved to
/home/aj/src/PepNestExport as a standalone companion project; NestDxfJob and
StreamGravographJob are removed (recoverable from history).
2026-09-23 15:15:45 -04:00
aj c45909e397 Merge branch 'perf/fill-pipeline' 2026-09-23 14:30:46 -04:00
ajandClaude Opus 5.5 1c363504f5 perf(engine): key fill caches by source drawing so they hit across trials
Every DefaultPlateFiller.Fill makes a fresh canonical copy of the drawing,
and BestFitCache/FillResultCache keyed by drawing reference, so fills never
shared results and the static caches grew without bound.

CanonicalFrame now records which drawing each canonical copy came from.
Both caches key weakly on that source drawing, so every canonical copy
shares one entry and released drawings can be collected. An entry is
dropped when the drawing's Program instance or canonical angle changes.
Best-fit candidates are computed once per (drawing, spacing) through
BestFitFinder.FindCandidates and filtered per plate size with the same
filter FindBestFits uses. FillResultCache keeps canonical and
non-canonical callers apart.

Adds Debug-only PerfCounters for best-fit runs, offset perimeter builds
and Part.Intersects calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:29:43 -04:00
ajandClaude Opus 5.5 dc9e83ef17 perf(jobs): flatten validator contours once, at a 0.001 chord tolerance
The candidate validator flattened every arc into 1000 segments and rebuilt
both parts' polygons and edge lists for every pair it compared, so spacing
checks on filleted parts cost millions of edge pairs each. Validation, not
the fill pipeline, was nearly all of a solve's wall time.

Placed contours are now flattened once with ToPolygonWithTolerance(0.001),
the tolerance the benchmark NestValidator and Part.Intersects already use,
and each part's shape is built once per candidate. Arcs stay inscribed, so
a layout placed exactly at the spacing still passes.

12-nest PEP corpus, Default + StockLadder, --parallel 1: 2820 s -> 227 s.
Every run that finished before gives the same validity, count, plates and
cost. Three StockLadder runs that used to hit the 5-minute timeout now
finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:29:43 -04:00
aj 1a7ed3c759 Merge branch 'refactor/engines-subfolder' 2026-09-23 11:42:39 -04:00
ajandClaude Opus 5.5 b122298f57 refactor(engines): move plugin engines into an Engines/ subfolder
Plugin engines (Opus55, Qwen, Terra) each add two projects at the repo
root, and more are coming; at a dozen they would outnumber the core
projects. They are also a different kind of thing: out-of-solution,
runtime-loaded plugins. Grouping them under Engines/ keeps the root
readable.

Engines/Directory.Build.props now holds the shared TFM, nullable and
implicit-usings settings and the OpenNest.Engine reference, so a new
engine's csproj is nearly empty. The tests/ compile exclusion lives in
Directory.Build.targets because a removal in .props runs before the SDK
adds its default Compile glob and has no effect.

Build-Engines.ps1 replaces the per-README manual build-and-copy steps
for deploying engines into the benchmark's runtime Engines/ folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:34:18 -04:00
ajandClaude Opus 5.5 2ecd94c705 Merge branch 'feat/pep-nest-export'
Opus55 NFP nesting engine and the PepNestExport tool for benchmarking
against PEP layouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:09:17 -04:00
ajandClaude Opus 5.5 26c17a386b Merge branch 'refactor/clipper-geometry'
Spacing offsets move onto Clipper (ClipperBridge) for CPU preparation,
fixing spikes and inverted loops where features are narrower than the
spacing; Collision stays hand-rolled for the GPU path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:09:17 -04:00
ajandClaude Opus 5.5 4dd00c14c4 feat(tools): PepNestExport converts PEP nests into benchmark .nest files
Converts a PepApi year of PEP nests into .nest files that keep PEP's own
placements, so OpenNest.Benchmark can score PEP as its Baseline row.

Micro-joint tabs: PEP leaves tabs uncut by jumping them with a rapid, at a
contour's seam or partway along it (a cutout cut as two halves 0.02 apart).
The part still occupies that material, so open cut runs are chained end to
start across gaps up to 0.25 and bridged with a cut line, but only where
they close into a loop, so separate contours that lie close together (two
circles 0.25 apart) are never merged. Previously only seam tabs were closed,
leaving mid-contour tabbed cutouts open and the validator reading them as
garbage regions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:09:06 -04:00
ajandClaude Opus 5.5 5064340eb3 fix(benchmark): validate spacing at a 0.001 flattening tolerance
Conservative flattening circumscribes arcs, so at 0.01 the validator was up
to 0.01 too strict along curves: PEP's P260626-03 layout, exactly 0.25 apart
along an arc, failed with a 0.004 sliver. At 0.001 the worst error on either
side is 0.001. Validating the 26-job benchmark set with Opus55 went from
about 2 s to about 5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:08:58 -04:00
ajandClaude Opus 5.5 062c7fa08f fix(geometry): tighten circumscribed flattening; stop padding the validator
The Clipper validator flagged valid Opus55 and PEP layouts (P260805-03,
P260626-03). Two causes:

- Arc.ToPoints(circumscribe) scales every vertex out by 1/cos(step/2),
  endpoints included, so a 0.03125 corner fillet flattened at 0.01 poked
  0.013 past the straight edges it meets. ClipperBridge now flattens itself:
  circumscribed arcs keep their endpoints on the arc and put interior
  vertices on tangent intersections, with the segment count chosen so the
  outward error stays within the tolerance. Arc.ToPoints is unchanged for
  its other callers.
- The conservative padding made a layout exactly at the spacing fail.
  NestValidator now uses OffsetForValidation: the same conservative
  flattening, round joins at a tenth of the tolerance, no padding. Its only
  leniency is that join chord error at convex corners.

With both, Opus55 is valid on all 26 benchmark jobs (25 before the
Clipper migration; the old failure was a spike artifact).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:40:59 -04:00
ajandClaude Opus 5.5 9af97c70b0 docs: document ClipperBridge, Clipper2 dependency and GPU-portable Collision
Spacing offsets now go through Clipper for CPU preparation while the
per-pair Collision test stays hand-rolled for a future GPU port; record
that split, which offset path each caller uses, and the missing Clipper2
entry in the NuGet list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:43:42 -04:00
ajandClaude Opus 5.5 01789c5929 fix(geometry): harden the arc-preserving per-entity offset
GetOffsetPerimeterEntities/GetOffsetPartEntities feed directional-distance
loops (FillLinear, Compactor, RotationSlideStrategy) that handle arcs
natively. Switching them to Clipper line output (plan option B) made
OpenNest.Tests run 48s -> 8m19s, Fill tests ~3x slower, and broke 20
exact-fit tests through tessellation and conservative padding, so they keep
the per-entity offset (option A), hardened:

- Arc, Circle and Line offsets are now side-symmetric. Right on a CCW arc
  shrank instead of growing, Right on a CW circle grew, and Right on a line
  offset to the left and reversed it. Only Left was used on hot paths, so
  this was latent (SimplifierViewer drew both tolerance bands on one side).
- Shape.OffsetEntity closes every gap between consecutive offset pieces:
  convex non-tangent line/arc corners get a round join about the original
  corner, lines across a collapsed fillet are mitered, and any other gap
  (concave arc corner, collapsed entity) is bridged with a line. Before,
  only line-line corners were joined, so a vertex could slip through.
- Zero-area spikes are left in place and documented: they lie inside the
  offset envelope, which is harmless for directional distance.
- OffsetOutward/OffsetInward become internal; PartGeometry is their only
  caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:43:02 -04:00
ajandClaude Opus 5.5 dceb5f7d18 test(geometry): cover Collision with ClipperBridge inputs; document GPU contract
Collision stays hand-rolled because it is the reference for a future GPU
kernel, but its inputs now come from Clipper region offsets. Pin down that
lines-only, round-join, 1e-4-precision polygons keep the contact and
part-in-part semantics: a neighbor inside a collapsed slot, a part inside
a hole that shrank by the spacing, and zero-spacing edge contact.

Document which steps are per-polygon preparation to cache and upload once,
and which are per-pair kernel-shaped work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:25:26 -04:00
ajandClaude Opus 5.5 10fe00d8ab refactor(geometry): delete RemoveSelfIntersections
Its callers now take Clipper region offsets, which never produce the
self-intersections it patched over (and it only caught proper crossings,
so spikes survived it anyway). Polygon.OffsetEntity was its last caller;
the override is required by Entity but has no callers, so it becomes a
Clipper miter offset that keeps the Left/Right semantics and the input
winding. FindCrossing, SplitAtCrossing, SegmentsIntersect and the static
CalculateArea helper go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:24:37 -04:00
ajandClaude Opus 5.5 9b9386b029 refactor(geometry): move polygon offset callers onto ClipperBridge
Per-entity offsetting left spikes and inverted loops wherever a feature is
narrower than the spacing (1.nest), and RemoveSelfIntersections only
caught proper crossings. The callers that already flatten to polygons now
take a single Clipper region offset instead:

- PolygonHelper (BestFit) and PartBoundary use the conservative mode, which
  keeps their never-under-estimate guarantee. PartBoundary also keeps holes
  that appear when a perimeter curls back on itself.
- NestValidator offsets perimeter and cutouts in one region; Clipper drops
  collapsed cutouts, so the collapsed-or-flipped heuristic goes away.
- CutOff.IntersectPerimeter offsets through the bridge. The old
  OffsetEntity(Left) grew CW perimeters but shrank CCW ones, so with the
  plate's perimeter cache a cut-off ran through the part; slots narrower
  than twice the clearance now close up instead of leaving a gap.
- GetOffsetPartLines (3 overloads) and the AddOffset* helpers had no
  callers and are removed, as is EntityView's never-defined DRAW_OFFSET
  block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:21:48 -04:00
ajandClaude Opus 5.5 12f97474b7 refactor(geometry): remove dead concave NFP path
NoFitPolygon.Compute, its triangulate-and-union MinkowskiSum branch and
UnionPolygons had no callers; only ComputeConvex (NfpSlideStrategy) is used.
The Clipper path helpers they relied on now live in ClipperBridge.
ConvexDecomposition.Triangulate stays because Collision uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:13:59 -04:00
ajandClaude Opus 5.5 a6bc9d8be6 feat(geometry): add ClipperBridge for region offsetting
Offsetting entity by entity leaves spikes and inverted loops wherever a
feature is narrower than the spacing, and RemoveSelfIntersections only
catches proper crossings. ClipperBridge flattens a ShapeProfile into one
region (perimeter positive, cutouts negative) and inflates it in a single
Clipper pass with round joins, so narrow features collapse and holes that
close up disappear.

Conservative mode circumscribes perimeter arcs, inscribes cutout arcs and
pads the inflation by the chord tolerance, so the result never
under-estimates the spacing. It replaces the circumscribed-polygon
guarantee the BestFit/PartBoundary callers rely on.

Clipper stays confined to CPU preparation whose output is cached; the
per-pair Collision path remains hand-rolled for GPU portability.
LayoutPart's display offset now goes through the bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:12:34 -04:00
ajandClaude Opus 5.5 7964c87eb9 fix(geometry): fit arc centers to endpoints; Clipper offset for spacing display
PEP-exported programs carry arc centers that are not equidistant from the
start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from
the end radius left its start off the previous move's end, so contours
failed to chain. Project the center onto the chord's perpendicular bisector.

The Draw Offset display offset each entity separately, which left spikes and
inverted loops wherever a feature is narrower than the spacing (1.nest,
P260417-06). Inflate the flattened region with Clipper instead, which
collapses narrow features and drops holes that close up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 08:56:15 -04:00
ajandClaude Opus 5.5 45a59c6b02 feat(engine): Opus55 frontier-advance NFP nesting engine
Rename the OpenNest.Engine.Sonnet5 scaffold to OpenNest.Engine.Opus55 and
implement an independent whole-job INestingEngine (no built-in engine,
registry, or best-fit internals are called or copied).

- PartCatalog: snapshot perimeter -> polygon per allowed orientation, with
  adaptive chord tolerance and MBR-aligned rotations for Automatic parts.
- NoFitCache: spacing footprints and cached Clipper2 Minkowski NFPs (convex
  fast path; concave sweep plus both containment terms).
- FrontierPacker: per-(type, orientation) free regions (inner-fit rectangle
  minus NFPs), updated incrementally; gap-fill-largest, else least front
  advance per area^beta.
- Engine: look-ahead stock choice by estimated whole-job net area, six
  deterministic variants, tail re-plan of the last 1-3 sheets.
- Tests judged by OpenNest.Benchmark's NestValidator, including an NFP
  containment regression guard.

P260805-10.nest (219 parts), all 9 stock sizes: 219/219 valid, 27 sheets,
91.7% utilization, ~7 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 05:10:50 -04:00
ajandClaude Sonnet 5 1c8305e8a1 feat(engine): scaffold independent nesting engine plugins for Sonnet5, Terra, Qwen
Three standalone INestingEngine plugin projects (Solve() unimplemented,
throws NotImplementedException), each outside OpenNest.sln and discovered
at runtime via NestingEngineRegistry.LoadPlugins, same pattern as
OpenNest.Engine.Aurora. Each README spells out that Solve() must be an
independently designed placement algorithm and must never call/wrap/select
over the built-in engines (StockLadderNestingEngine, FixedStrategyNestingEngine,
PlateNesterFactory, NestingEngineRegistry).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:06:47 -04:00
aj 828500f984 chore: replace CSharpier with dotnet format 2026-09-22 16:45:54 -04:00
aj 8e4735d25d [verified] add benchmark baseline and rotation fixes 2026-09-22 16:35:25 -04:00
aj 5bbb7b7461 Merge branch 'fix/benchmark-scoring' 2026-09-22 15:06:02 -04:00
ajandClaude Opus 5.5 57e9f625b6 fix(benchmark): rank by sheet cost so engines can't game the score
The benchmark is about to be used as the objective for LLM-designed
engines, and several gaps would have rewarded the wrong behavior:

- Ranking was utilization-first, so dropping awkward parts raised the
  score. Rank valid > fully placed > cost > plates, where cost is
  salvage-credited sheet area plus a largest-sheet penalty per unplaced
  part; placing a part is never scored worse than omitting it.
- Salvage rate was ignored in scoring; cost now uses EstimateNetArea,
  recomputed from job geometry rather than trusted from the engine.
- Rotation constraints were never validated. Add RotationPolicy.Allows
  (shared with NestJobPlacementValidator) and check every placement.
- Returned sheets were trusted, so an engine could loosen spacing or
  invent a size. Sheets must now match offered stock.
- Part-in-part placements were flagged as overlaps; spacing now accounts
  for cutouts, with an X-sorted sweep to prune distant pairs.
- Summary averaged per-job percentages; it now sums areas and cost.
- --spacing and sheet sizes parsed with the current culture.
- Warn when .nest jobs offer only their original sheet sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:05:43 -04:00
aj e65f849c7a refactor(engine): remove legacy nesting engine surface 2026-09-22 14:25:46 -04:00
ajandClaude Opus 5.5 1be0904506 fix(mcp): make opennest server launchable on any machine
.mcp.json pointed at C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd, a
profile path and wrapper script that only existed on one machine, so the
server failed with CONNECTION_CLOSED everywhere else. Launch the published
exe via ${USERPROFILE} instead.

Also route console logging to stderr: the host's default console logger
wrote to stdout, interleaving log lines with the JSON-RPC stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 13:02:28 -04:00
ajandClaude Sonnet 5 98c4929d14 Merge branch 'fix/pairs-overlap'
Fixes Part.Clone() double-counting baked drawing rotation, which caused
overlapping placements when tiling interlocking pairs for drawings needing
canonical-frame axis correction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:46:17 -04:00
ajandClaude Sonnet 5 014c071716 fix(core): stop Part.Clone from double-counting baked drawing rotation
Part.Clone() rebuilt the clone from BaseDrawing and then re-applied the
part's absolute Rotation on top of it. Since BaseDrawing.Program.Rotation
is itself absolute (baked in), this double-counted it whenever the base
drawing already carried a nonzero rotation, silently corrupting the
clone's orientation while its Location stayed unchanged.

This only manifests for drawings needing canonical-frame axis correction
(nonzero Source.Angle), since DefaultPlateFiller wraps every drawing in a
rotated canonical copy before running any fill strategy. FillHelpers.
BuildRotatedPattern clones parts before tiling, so any strategy that
tiles interlocking pairs (Pairs, Strip/Remnant, Column/Row) could produce
overlapping placements for such drawings.

Fix: clone the already-composed Program directly instead of re-deriving
rotation from BaseDrawing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:46:11 -04:00
ajandClaude Sonnet 5 da1f8120f1 Merge branch 'fix/bestfit-overlap-test'
Fixes BestFit overlap check to agree with the geometry BuildParts
actually places, resolving BestFitOverlapTests.KeptPairs_NoOverlap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:58:46 -04:00
ajandClaude Sonnet 5 0512f3f84a fix(engine): check BestFit overlap in the same frame BuildParts places
PairEvaluator checked overlap on the raw candidate geometry before
applying the pair's OptimalRotation, but BestFitResult.BuildParts (and
everything downstream) rotates both parts by -OptimalRotation before
placing them. Re-tessellating a rotated arc at the overlap chord
tolerance samples different chord points than rotating an
already-tessellated polygon, so a few tangent-corner candidates came
out overlap-free in the raw frame but overlapping once actually
placed.

Move the landscape-normalization step before the overlap check and
rotate part1/part2 the same way BuildParts does before tessellating
and running Collision.HasOverlap, so Keep agrees with the geometry
that's actually placed. Fixes
OpenNest.Tests.BestFit.BestFitOverlapTests.KeptPairs_NoOverlap (was
failing 3/1082 candidates).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:57:40 -04:00
aj b14cb9d10a style(io): apply consistent formatting and remove unused usings
Whitespace/indentation cleanup (formatter pass) plus unused using
directives. No behavior change.
2026-09-22 09:51:18 -04:00
aj a0417a6077 fix(ui): avoid double-counting baseline rotation in part label placement
_labelPoint is computed from BaseDrawing.Program's current geometry,
which already carries BaseDrawing.Program.Rotation (non-zero for
canonical-frame drawings). BasePart.Rotation is cumulative from that
same baseline, so it must be re-applied net of the baseline already
baked into _labelPoint, mirroring CanonicalFrame.RebindToOriginal.
2026-09-22 09:51:15 -04:00
aj 56f39556d1 feat(engine): rebind live fill previews to the drawing's original frame
DefaultPlateFiller runs its search in a canonical (MBR-axis-aligned)
copy of the drawing. Intermediate progress reports — the Nesting
Progress dialog, PlateView's active-parts overlay — were showing that
transient canonical orientation instead of the drawing's real one.

FillContext.OriginalDrawing carries the pre-canonicalization drawing
through the pipeline; ReportProgress rebinds reported parts to it via
CanonicalFrame.RebindToOriginal before they reach the UI. Uses a
shallow list copy rather than per-part Part.Clone() — Clone() re-derives
its target rotation from BaseDrawing.Program.Rotation + Rotation, which
would double-count the canonical drawing's baked source angle.
2026-09-22 09:51:11 -04:00
aj 86f6c9efa1 refactor(engine): extract PairFiller candidate selection and remnant filling
Splits PairCandidateSelector (strip-mode candidate ranking) and
PairRemnantFiller (leftover L-shaped area fill) out of PairFiller into
their own classes. Pure extraction — logic is unchanged, just relocated
and given dedicated unit boundaries so each piece can be tested and
reasoned about on its own.
2026-09-22 09:51:06 -04:00
aj 6f250b5730 fix(geometry): correct overlap detection for real-world CNC shapes
Part.Intersects has been silently non-functional everywhere it's used.
Shape.ToPolygon()/ToPolygonWithTolerance() never called UpdateBounds(),
so every freshly-built polygon kept Entity's constructor-default
zero-size bounding box regardless of its actual vertices. Collision.Check's
first step is a bounding-box pre-filter, and a zero-size box can never
overlap anything, so it always short-circuited to "no overlap" no matter
what the real geometry looked like.

That masked a second bug: Part.Intersects built its polygons via the
unconditional 1000-segments-per-arc ToPolygon() default instead of an
adaptive tolerance. For parts with several small fillets/holes this
produced tens of thousands of vertices, making the now-correct bbox
check fall through into a triangulation/clip step too slow to return
in practice. Switched to ToPolygonWithTolerance at a named tolerance
matching PartGeometry's existing convention.

PairEvaluator's own Keep/overlap check had a third, independent bug:
it used Shape.Intersects (edge-crossing detection only) at a coarse
0.01 chord tolerance, which misses containment-style overlaps and can
polygonize rounded corners coarsely enough to hide a genuine sliver
overlap. Switched to Collision.HasOverlap (full polygon clip, handles
containment) at a tighter dedicated tolerance.

Verified against a real nest file: PairFiller was tiling a BestFit
pair that PairEvaluator had incorrectly marked Keep=true, producing
visibly overlapping parts on the plate that no downstream overlap
check ever caught.

Known follow-up: OpenNest.Tests.BestFit.BestFitOverlapTests.KeptPairs_NoOverlap
still fails on 3/1082 synthetic candidates that overlap by a sub-0.001
sliver right at a rounded-corner tangent point — a separate, much
smaller precision edge case in PairEvaluator's raw (pre-transform)
coordinate frame, not a regression from this change.
2026-09-22 09:51:02 -04:00
aj c28bc0da21 refactor(engine): select desktop nesting through app-scoped jobs engine
Replace the MainForm/AutoNestForm engine combo bindings and every
desktop NestEngineRegistry call: selection now lives in app-scoped
EngineSelection addressing jobs engines in NestingEngineRegistry, the
combo lists the four built-in strategies (StockLadder stays out per the
frozen UI decision), and Engines/ plug-ins load through
NestingEngineRegistry.LoadPlugins. Whole-job fill routes through the
public PlateFillService with the selected strategy and plate number;
interactive group fill (PlateView) and area fill (ActionFillArea) go
through PlateFillService with identical accept/cancel preview behavior.
Multi-plate and size-search orchestrators receive the explicit strategy.
No desktop code reads or writes process-global engine state.
2026-09-22 00:30:40 -04:00
aj 88966d118c refactor(engine): route console and MCP nesting through named job engines
Console --engine now names a jobs engine for --autonest (solved once
through NestingEngineRegistry.Create and committed onto the plate) or a
built-in fill strategy for single-plate fill through the public
PlateFillService; unknown names exit with the valid choices instead of
consulting the process-global legacy registry. MCP nesting tools take an
explicit engine argument per call with the session default, never read
process-global active-engine state, and reject whole-job engine names on
single-plate fill tools. NestingEngineRegistry gains an explicit Create
(name) resolution; PlateFillService gains a public ResolveStrategy and a
plate-number Nest overload used by interactive callers.
2026-09-22 00:30:03 -04:00
aj f36e124039 refactor(engine): thread explicit placement strategy through multi-plate orchestrators 2026-09-21 20:22:45 -04:00
aj 856dbfd8af refactor(engine): add public plate fill service 2026-09-21 19:51:35 -04:00
aj eb8fbec1aa refactor(engine): make jobs plate nesters filler-backed 2026-09-21 18:08:25 -04:00
aj 073ead9b79 refactor(engine): share jobs placement identity and progress mechanics 2026-09-21 16:36:24 -04:00
aj bb671cb176 refactor(engine): retarget ML runner to plate filler 2026-09-21 15:52:45 -04:00
aj 6bcea7919a refactor(engine): remove unused stripe engine hook 2026-09-21 15:43:15 -04:00
aj ed908b9706 refactor(engine): extract strip plate filler 2026-09-21 15:40:13 -04:00
aj e69ec07830 refactor(engine): extract default and remnant plate fillers 2026-09-21 15:27:03 -04:00
aj eafa0fab01 refactor(engine): extract plate fill orchestration 2026-09-21 15:01:00 -04:00
aj 9b9e510510 refactor(engine): extract progress reporting seam 2026-09-21 14:43:46 -04:00
aj 451841401a refactor(engine): align namespaces with directory layout under OpenNest.Engine
All 132 OpenNest.Engine source files now declare namespaces matching
their nested directories: Jobs/, Jobs/Placement/, Jobs/Adapters/,
Fill/, RectanglePacking/, CirclePacking/, and engine-root types moved
from 'OpenNest' to 'OpenNest.Engine'. RootNamespace updated accordingly.
Consumers (Api, Console, Mcp, Benchmark, Training, desktop app, tests)
gained the explicit usings the move requires; CLAUDE.md updated.
2026-09-21 13:18:34 -04:00
aj fd3375cde6 test(engine): golden-layout parity fixtures for all four strategies
Pin committed fulfillment and exact placement poses for Default, Strip,
Vertical Remnant, and Horizontal Remnant through the production
PlateNesterFactory + NestJobRunner path, plus deterministic poses for
OrderedPlateNester via its StockLadder wiring (no legacy counterpart) and
authoritative progress-stage sequences for StockLadder and
FixedStrategyNestingEngine. Captured at 42bbde7 (post ShrinkFiller axis
fix); these fixtures are the regression net for the legacy-engine removal.

Note: Strip is pinned on a rectangle-variety job. On dense mixed-shape
jobs the iterative shrink path intermittently proposes overlapping
candidates (pre-existing scheduling nondeterminism), so its mixed-geometry
layout is deliberately not pinned.
2026-09-21 12:17:24 -04:00
aj 42bbde7433 Merge branch 'fix/failing-tests-after-master-pull' 2026-09-21 11:07:49 -04:00
ajandClaude Sonnet 5 64d38c452c fix(engine): route restricted-rotation requirements to OrderedPlateNester
The legacy engine reads RotationStart == RotationEnd == 0 as
unconstrained and its Pairs/RectBestFit strategies rotate freely, so it
returned poses a Fixed/BoundedSweep RotationPolicy forbids and the job
validator threw. DefaultPlateNester now delegates such requests to the
policy-aware OrderedPlateNester.

Fixes RunAsync_FiniteStockExhaustion_PreservesUnplacedRequirementAndLockedRotation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:07:45 -04:00
ajandClaude Sonnet 5 630d514b0e fix(engine): don't drop the topmost part when no other drawing is waiting
RemnantFiller removes the topmost placed part to keep a clean rectangular
obstacle for the next drawing, but the envelope then walls that slot off,
so the part was lost for nothing (4 squares on a 9x9 plate became 3).
Only remove it while another drawing still has demand.

Fixes the mixed-stock NestRunner tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:07:45 -04:00
ajandClaude Sonnet 5 a764a70e52 fix(engine): make canonical-frame fills orientation-invariant
Part.Rotation is cumulative, so rebinding canonical parts with
CreateAtOrigin(original, p.Rotation) double-counted the drawing's own
rotation, and FromCanonical rotated each part about its Location, which
moved it off its slot and out of the work area. Add
CanonicalFrame.RebindToOriginal (rotation = part - original program
rotation, footprint aligned to the canonical part) and use it in the
three places that duplicated the old logic.

The MBR only fixes the frame modulo 90 degrees and nest results are not
90-degree symmetric (an L gave 56/43/42/42 parts by orientation).
CanonicalAngle.Compute now picks one of the four orientations from the
centroid offset; symmetric shapes keep the MBR orientation.

Fixes the three NestInvarianceTests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:07:44 -04:00
ajandClaude Sonnet 5 2a855139d2 fix(core): stop Program.BoundingBox including the origin
Min/max were seeded at 0, so any geometry not touching the origin got an
inflated box, and the first move only updated max (else-if). Rotated
canonical drawings are the common trigger: their origin ends up outside
the shape, which skewed Part bounds and bbox-based alignment.

Track the real extents and keep returning a zero box for empty programs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:07:44 -04:00
aj f23f0fa566 feat(tools): headless DXF+workbook job verification tool
NestDxfJob imports a DXF folder plus a quantity workbook, runs a
registered whole-job engine, and only publishes a nest that passes
quantity, bounds, overlap/spacing, and cut-only checks both before and
after save/reload. Writes import and validation reports; partial or
invalid results exit nonzero. Standalone project (not in the solution);
documented in the README.
2026-09-21 10:05:55 -04:00
aj 6f38c11383 feat(io): workbook part-quantity reader and cut-only import coverage
PartQuantityReader parses a Parts worksheet (Part Name / Qty Required)
with exact-name matching and strict rejection of invalid, fractional,
negative, or duplicate quantities. Cut-only import drops case-insensitive
ETCH/SCRIBE mark layers before geometry so bend detection cannot
regenerate them. Tests cover both.
2026-09-21 10:05:39 -04:00
aj f34b3c4449 perf(engine): bounds-based short-circuit in placement validation
Bounding-box distance is a conservative lower bound on true contour
clearance, so pairs far apart can skip the polygon Overlaps/Distance
checks without letting an overlap or spacing violation through. Also
gate the per-contour-pair BoundaryDistance work on a running minimum.
Validation is on the hot path for every candidate placement.
2026-09-21 10:05:39 -04:00
aj 365825bc5a fix(engine): shrink the requested axis in ShrinkFiller estimates
ShrinkAxis.Length must shrink the Y extent and ShrinkAxis.Width the X
extent to agree with MeasureDimension/TrimToCount; Box's constructor
takes the X extent first. The estimate previously shrank the wrong
dimension and returned a mis-ordered box. Add a parameterized regression
test over both axes and both translated-remnant orientations.
2026-09-21 10:05:18 -04:00
ajandClaude Sonnet 5 02fc0ea3db chore: remove unused NFP nesting code
Delete OpenNest.Engine/Nfp (AutoNester, BottomLeftFill, NfpCache,
SimulatedAnnealing, INestOptimizer, PlacedPart, SequenceEntry), the Core
InnerFitPolygon, and the NestPhase.Nfp member. None had callers outside the
folder: console --autonest and MCP autonest_plate call engine.Nest(), not
AutoNester.

Drop the Nfp cases from NestPhaseExtensionsTests, fix the --autonest help
text, and update CLAUDE.md. NoFitPolygon stays; BestFit pair evaluation
still uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:20:30 -04:00
ajandClaude Sonnet 5 451876c02f fix(engine): stop PairFiller toggling the global fill-strategy registry
PairFiller.EvaluateCandidates wrapped its candidate loop in
FillStrategyRegistry.SetEnabled(...) and reset it with SetEnabled(null)
afterwards. Nothing inside that window re-enters the strategy pipeline
(EvaluateCandidate and FillRemnantBox only use FillLinear), so the
restriction had no effect on the running solve. It did mutate process-global
state: concurrent solves (e.g. benchmark --parallel) could observe the
narrowed strategy list, and the reset cleared any filter set elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:20:30 -04:00
ajandClaude Sonnet 5 5061b41a5d feat(benchmark): build jobs from DXF manifests and run solves in parallel
Benchmark jobs could only come from .nest files. A JSON manifest now lists
DXF files with quantities (plus sheet sizes, spacing, edge spacing, quadrant
and per-part allowRotation), imported through CadImporter. DXF paths resolve
relative to the manifest; sheet sizes are required from the manifest or
--sheet-sizes and are read in the DXFs' own units. Folder scans pick up
*.nest and *.manifest.json, and invalid manifests fail loudly.

BenchmarkRunner now runs (job x engine) solves concurrently, capped by
--parallel N (CLI default 3; --parallel 1 is sequential). Results are written
by index so report order is unchanged. Concurrent solves compete for cores,
so Time(ms) is only clean at --parallel 1; the run prints a note when N > 1.

Also fixes --output for manifest jobs, which tried to read the manifest as a
.nest to copy metadata from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 21:43:41 -04:00
aj e7cbd99db6 Separate WinForms tests so main test suite runs on Linux 2026-09-20 17:40:06 -04:00
aj 27684c3782 style: apply CSharpier formatting to files merged from arc-tangency branch 2026-09-20 16:54:16 -04:00
aj 54694f9b17 Merge branch 'chore/csharpier-sweep'
# Conflicts:
#	OpenNest.Core/Geometry/ArcFit.cs
#	OpenNest.Core/Geometry/GeometrySimplifier.cs
#	OpenNest.Posts.GravographIS/GravographISWriter.cs
#	OpenNest.Posts.GravographIS/NestPolylineExtractor.cs
2026-09-20 16:53:52 -04:00
aj de1248589a Merge branch 'feat/outer-profile-only'
# Conflicts:
#	CLAUDE.md
2026-09-20 16:52:18 -04:00
aj a9ebd8bb55 chore: add .git-blame-ignore-revs for the CSharpier sweep
Skip the formatting commit in git blame.
Enable with: git config blame.ignoreRevsFile .git-blame-ignore-revs
2026-09-20 16:42:45 -04:00
aj aec0523062 style: apply CSharpier formatting to all C# sources
Repo-wide sweep with the pinned CSharpier 1.3.0 tool. Whitespace and
line-wrapping only; OpenNest.Engine.Tests (109) and OpenNest.IO.Tests
pass after reformat, full solution builds 0 errors.

Added .csharpierignore so csproj/config XML keeps its existing layout
(CSharpier's XML wrapping churns attributes with zero benefit).

Formatting is now enforceable: dotnet csharpier check . passes.
2026-09-20 16:41:50 -04:00
aj 8e6fa677fb chore: add .editorconfig and pinned CSharpier tool manifest
Mirrors CSharpier conventions (4-space indent, Allman braces,
System-first usings, 100-col wraps) so IDE auto-format and
'dotnet format' agree with the canonical formatter.
Usage: dotnet tool restore && dotnet csharpier format .
2026-09-20 16:04:04 -04:00
aj 589d341455 feat(io): conservative opt-in bend repair with tests and console CLI
Add OpenNest.IO/Bending/BendRepair: opt-in repair of unambiguous paired
ETCH/SCRIBE bend ticks, bounded to <=3.175 mm endpoint movement with
explicit source units. Cut geometry is never modified.

- CadImportOptions.BendRepair configures it; CadImportResult exposes
  per-bend BendRepairReports; CadImporter/Dxf wire it into import.
- Console: --repair-bends-mm <limit> --cad-units inches|mm prints
  per-bend reports for newly imported DXFs.
- New OpenNest.IO.Tests project (net8.0, synthetic DXFs, 30 tests)
  covering bend detection and repair, added to the solution.
- Update README.md and CLAUDE.md for the new pipeline and build/test
  instructions.
2026-09-20 15:22:46 -04:00
aj 1a05391d94 fix(engine): reject small corner overlaps in placement validation
The witness-probe overlap test missed small corner intersections: its
candidate points (crossing-edge midpoints and vertex-centroid midpoints)
can all land on a part boundary or outside the intersection, so two 10x10
parts at (0,0) and (9,9) with zero spacing were accepted despite sharing
a 1x1 unit of material.

Route the overlap decision through Collision, which clips triangulated
polygons and keeps only positive-area regions, catching corner overlaps,
containment, and coincident poses while legal edge/corner contact stays
legal. Collision's hole subtraction was conservative (partially-clipped
triangles were kept whole), so a part inside another part's cutout could
false-positive depending on triangulation alignment; subtract holes
exactly instead: a piece outside a convex hole triangle is the union of
its clips against each edge's outside half-space.
2026-09-20 13:40:45 -04:00
aj f5d27652f4 Merge branch 'fix/simplifier-arc-tangency'
Arc-tangency fitting fix in GeometrySimplifier/ArcFit plus layered
engrave/cut passes for the GravographIS post processor.
2026-09-19 12:02:08 -04:00
aj ea4bd836cd Add tested caller-stock StockLadder baseline with strict geometry validation 2026-09-19 11:24:36 -04:00
ajandClaude Sonnet 5 9b69c67572 fix(engine): use required spacing, not a sampled gap, when resequencing shrink-fill strips
SortStrips measured the gap between only the first two strips in original
placement order and replayed that single value between every strip after
reordering by height/width. Real (non-uniform) geometry produces varying
inter-strip gaps, so resequencing could expand the total footprint beyond
the plate's already-fitted work area, crashing StripPlateNester with
"Candidate placement falls outside the usable stock area." Using the
actual required spacing guarantees the resequenced span never exceeds
the original, since real gaps are always >= spacing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:04:33 -04:00
ajandClaude Sonnet 5 aa88eee484 fix(benchmark): use reference-based drawing identity in NestValidator, fix duplicate-sheet-size crash, document Engines/ plugin contract
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:44:09 -04:00
ajandClaude Sonnet 5 e0e3b96bed fix(benchmark): match drawing identity across materialization boundary in NestValidator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:29:48 -04:00
aj 424ff15ebc docs: describe INestingEngine-based benchmark comparison 2026-09-19 08:25:57 -04:00
aj 9888fe6083 feat(benchmark): switch CLI to NestingEngineRegistry and its Engines/ plugin directory
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 08:23:31 -04:00
ajandClaude Sonnet 5 a2dcfc7484 refactor(benchmark): drive engines through INestingEngine.Solve instead of a hand-rolled multi-plate loop
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:19:08 -04:00
aj ae704478af feat(engine): add NestingEngineRegistry for whole-job INestingEngine plugin discovery 2026-09-19 08:15:40 -04:00
ajandClaude Haiku 4.5 ecca71e185 feat(engine): add FixedStrategyNestingEngine adapting IPlateNester strategies to INestingEngine
Implements a sealed adapter class that forces a fixed IPlateNester strategy onto
any NestJob, overriding the job's own PlacementStrategy while preserving MaxPlates.
Delegates all multi-plate allocation and stock selection to NestJobRunner.

This allows single-plate nesting strategies to compete as full whole-job
INestingEngine solvers in benchmarks, enabling comparative performance testing
of placement algorithms across various job configurations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 08:12:10 -04:00
aj f0fe79f0f1 Merge remote-tracking branch 'origin/master' 2026-09-19 07:44:40 -04:00
aj a68e252ac7 docs: describe whole-job engine architecture 2026-09-18 20:18:24 -04:00
aj bc6bcae419 docs: document whole-job nesting contracts and migration boundaries 2026-09-18 16:27:32 -04:00
aj 02141f6ca5 refactor(engine): separate plate placement from job allocation 2026-09-18 08:38:23 -04:00
aj ad69023c17 feat(api): accept complete nesting jobs and report fulfillment
Task 6 of the whole-job engine API: adapt the public NestRequest/NestRunner/
NestResponse surface to delegate to the whole-job runner instead of a manual
quantity loop.

- NestRequest: optional explicit Plates stock list (null keeps the legacy
  unlimited SheetSize fallback; empty list means no available stock),
  optional per-part Id (derived as part-{index} when absent), and an explicit
  PlacementStrategy that takes precedence over the legacy Strategy.
- NestRequestPlate: one physical-stock type (id, size, quantity, spacing,
  quadrant).
- NestRunner: imports each DXF once, propagates priority/rotation constraints,
  runs a single NestJobRunner solve, materializes ID/pose placements exactly
  once, and reports aggregate utilization as total placed part area over total
  physical sheet area.
- NestResponse: exposes status, stop reason, part fulfillment, stock usage,
  and plate-to-stock mapping; .nestquote save/load gains a schema version and
  reports completion as unknown for old archives lacking fulfillment metadata.
- Tests: extend the Api request/runner/persistence suites for legacy SheetSize,
  explicit mixed finite stock, stock exhaustion, weighted utilization, old
  archive loading, and new-archive round trips.

Verification: cross-compiles clean on net8.0-windows (Linux). The Api tests
require a Windows runner (net8.0-windows) and are NOT executed here; the
delegated engine logic is covered by the 70-test net8.0 Engine.Tests suite
(committed in Task 5). Windows runtime verification remains outstanding.
2026-09-18 06:15:04 -04:00
aj 2b0b962c8f fix(engine): enforce whole-job safety invariants
Task 5 of the whole-job engine API: add a geometry safety gate that
validates every candidate trial before the runner commits accounting.

- NestJobPlacementValidator: closed-contour validity, rotation-policy
  compliance, work-area containment per quadrant, hole-aware material
  overlap, and required part spacing. Overlap is interior-only, so
  zero-clearance edge/corner contact remains a valid placement.
- NestJobValidator: route candidate validation through the geometry
  gate; reject unusable/unclosed/degenerate contours up front.
- NestJobRunner: wrap candidate evaluation in a progress bridge that
  tags legacy engine detail with the current candidate context.
- LegacyPlateNesterAdapter: forward IProgress to the legacy engine so
  its progress surfaces under the active candidate.
- Tests: geometry (quadrants, rotations, touching, containment, holes,
  empty stock, real Default/Strip smoke), validation, and cancellation
  suites; repaired test fakes that emitted out-of-bounds or overlapping
  placements the gate now correctly rejects.

Engine.Tests: 70 passed, 0 failed, 0 skipped in Debug and Release.
Windows-only OpenNest.Tests not run on Linux.
2026-09-18 05:56:37 -04:00
aj 75c8adc76c feat(engine): select from mixed plate inventory 2026-09-18 02:09:06 -04:00
aj 67f5fb8eca refactor(jobs): single-source strategy resolution in adapter
LegacyPlateNesterAdapter.Create now delegates to PlateNesterFactory
instead of carrying its own minimal Default-only switch, so built-in
strategy resolution has one source of truth. Behavior unchanged:
unknown keys still reject; all four built-ins now resolvable.

44 net8.0 tests pass in Debug and Release; 0 warnings.
2026-09-17 16:26:45 -04:00
aj 5b88d85937 fix(engine): isolate job identity and engine selection
Replace name-based quantity deduction with reference-based drawing
identity in the engine paths the whole-job runner reaches
(NestEngineBase fill/pack, StripNestEngine deduction, RemnantFiller
ledger, IterativeShrinkFiller leftovers). Add instance-scoped
PlateNesterFactory that resolves built-in strategies without touching
the global NestEngineRegistry. Add identity and engine-selection tests.

44 net8.0 tests pass in Debug and Release; no new warnings.
2026-09-17 15:35:59 -04:00
aj 0963b051be feat(engine): execute inventory-bounded multi-plate jobs 2026-09-17 13:55:11 -04:00
aj 71dffce72c feat(engine): introduce whole-job nesting contracts 2026-09-17 13:44:10 -04:00
aj a9e0f8a1d4 Rework OpenNest.Benchmark into a full multi-plate, multi-size nest
Previously each job fixed one plate size and ran a single Nest() call,
which doesn't reflect the actual problem: a real job is fulfilled
across however many plates are needed, drawn from a pool of standard
sheet sizes, not forced onto one fixed sheet.

NestEngineBase.Nest() has no way to pick its own plate's size - it
fills whatever Plate it's given - so size selection now lives in the
harness itself, applied identically to every engine:

- BenchmarkJob carries the full candidate size pool (CandidateSizes)
  instead of one fixed PlateSize; one job per file, not one per size.
- BenchmarkRunner drives a loop: while items remain, pick the smallest
  candidate size that fits the largest still-unplaced drawing (reusing
  the codebase's own MultiPlateNester.CreatePlate/FitsBounds), build a
  fresh plate of that size, and run one Nest() call to fill it. Repeat
  until everything is placed, no candidate size fits what's left, or a
  safety cap (40 plates) is hit.
- NestValidator now validates bounds/spacing per plate but the
  quantity cap once globally across all plates, since that limit
  belongs to the whole order, not any one sheet.
- JobResult/Report report PlatesUsed and a per-size breakdown instead
  of a single-plate bounding-box compactness metric; utilization is
  now aggregated across every plate the engine used. Ranking keeps the
  same rule (utilization first), with fewer plates as the tie-break
  when both are fully placed and tied - the natural multi-plate
  analogue of the old single-plate compactness tie-break.

Smoke-tested against the synthetic sample across 5 candidate sizes:
correctly builds one job, picks the smallest fitting size, uses
however many plates each engine needs (1-2 here), and still catches
StripNestEngine's pre-existing out-of-bounds bug.
2026-09-15 21:36:31 -04:00
aj 20da5477b6 Fix NestValidator: add area-budget backstop, cheaper polygon conversion
Collision.HasOverlap (and Part.Intersects, which shares the same
underlying algorithm) was observed to return false negatives on real,
complex production geometry: a layout with a combined placed area over
7x the plate's work area passed the polygon-based spacing/overlap
check with zero flagged pairs. This is a pre-existing gap in
OpenNest.Core's Collision detection, not something introduced here,
but it let an obviously-invalid layout score as "valid".

ValidateAreaBudget adds a hard mathematical backstop that does not
depend on Collision at all: non-overlapping parts confined to the work
area can never have a combined area greater than the work area itself.

Also switch WorldPolygon from Shape.ToPolygon() (default up to 1000
segments per arc) to ToPolygonWithTolerance(0.01), matching the
convention already used elsewhere in the codebase (e.g.
BestFit.PolygonHelper) - arc-heavy real parts were producing
thousands-of-vertex polygons for a simple spacing check.
2026-09-15 20:54:12 -04:00
aj 6a0fba0fec Add OpenNest.Benchmark: generic head-to-head engine comparison harness
Loads any .nest file (or folder of them) via NestReader and nests every
drawing with quantity > 0 using each registered NestEngineBase, so it
works sight-unseen against arbitrary real jobs without any hardcoded
geometry. Optionally sweeps a fixed --sheet-sizes list instead of each
file's own plate size.

- BenchmarkJob/JobLoader build immutable job specs; a fresh Plate and
  NestItem list is created per (job, engine) run so state never leaks
  between engines or jobs.
- NestValidator rejects a layout if any part falls outside the work
  area, any two parts are closer than PartSpacing (checked via each
  part's own world-space polygon inflated by the spacing, so it holds
  for arbitrary concave/holed geometry, not just bounding boxes), or a
  drawing gets more parts than requested.
- Scoring matches Plate.Utilization() (placed area / full sheet area);
  ties among fully-placed layouts break on the smaller used bounding
  box (more usable remnant).
- Report prints a per-job ranked breakdown plus a per-engine summary
  (wins, avg utilization, time), and can write a flat CSV.

Verified end-to-end against a synthetic .nest file (not committed)
against the four built-in engines; caught a genuine out-of-work-area
bug in StripNestEngine in the process.
2026-09-15 18:31:36 -04:00
aj 587000f68a Fix push broad-phase checks to preserve part spacing 2026-09-11 22:32:37 -04:00
aj d99e47f241 Fix duplicate bend-note rendering in CAD converter 2026-09-11 21:48:13 -04:00
ajandClaude Opus 4.6 a085339ba9 fix: improve arc-tangency fitting and add layered engrave/cut passes for GravographIS
GeometrySimplifier/ArcFit now fit arcs that pass exactly through run
endpoints while balancing tangency error between trusted and estimated
directions, fixing arcs that previously bulged or broke tangent
continuity at fillet/compound-curve junctions.

GravographIS post processor gains per-layer (engrave/cut) tool passes
via a new GravographISPostConfig, so ENGRAVE/ETCH-tagged geometry runs
as a separate scribe pass with its own feed/depth and an operator
pause before the cut pass (spring-floated spindle needs a tool swap).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-06 23:15:18 -04:00
ajandClaude Sonnet 4.6 bcb85805d9 fix: remove Windows-specific TFM for Linux/Docker compatibility
Change net8.0-windows to net8.0 in Core, IO, and Engine projects so
the libraries can be consumed in Docker containers running on Linux.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:33:32 -04:00
ajandClaude Opus 4.8 e493d83899 feat(io): add Gravograph .CHR font reader with text-to-geometry
Add ChrFont, a reader for Gravograph .CHR engraving fonts, plus UI to
convert placed text into engraved geometry in the CAD converter.

The .CHR files are obfuscated with a single-byte XOR. Different
GravoStyle releases use different keys (0x2F in older versions, 0xCF in
the 7000 series, and others across the font library), so the key is
auto-detected from byte 1 of the file: the font name is ASCII stored as
UTF-16LE, so the high byte of its first character is 0x00 in plaintext
and the raw byte equals the key. This reads every font in a GravoStyle
install regardless of version, not just one hardcoded key.

UI: right-clicking a text item in EntityView raises TextConvertRequested;
CadConverterForm renders it via ChrFont with H/V alignment and adds the
result on an ENGRAVE layer.

Tests use Xunit.SkippableFact and a gitignored test-config.json so the
suite points at a local .CHR file without committing proprietary assets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 14:37:48 -04:00
aj 987a5e25bc Add Gravograph IS post processor 2026-05-23 12:40:53 -04:00
ajandClaude Opus 4.6 86582d28c3 fix(io): map DXF text vertical alignment for correct rendering
TextEntity import was only mapping HorizontalAlignment to CadText,
leaving VAlign at its default (Near/top). Middle-center text rendered
shifted to the bottom instead of vertically centered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 07:17:47 -04:00
ajandClaude Opus 4.6 f064368008 fix(io): remove zero-sweep arcs during DXF import
DXF files can contain degenerate arcs where start angle equals end angle
(zero sweep), often left as construction artifacts by CAD software.
These create spurious shapes in ShapeBuilder — e.g. SULLYS-033.dxf
showed 5 loops instead of 4 (3 cutouts + perimeter).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 06:50:20 -04:00
ajandClaude Opus 4.6 9148797897 fix(ui): remove cut-off preview debounce for immediate cursor tracking
The 16ms timer delay made the preview feel laggy. Regenerate directly
on mouse move instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 06:50:17 -04:00
aj da77cc9270 Fix best-fit viewer bounds for angled pairs 2026-05-18 22:17:47 -04:00
ajandClaude Opus 4.6 27f0685058 fix(engine): skip intersecting parts as obstacles during compactor push
Parts that already overlap the moving group are now excluded from the
obstacle list so they don't block the push direction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 19:07:42 -04:00
ajandClaude Opus 4.6 53988acefc fix(io): deduplicate circles and full-circle arcs during DXF import
Duplicate circle entities at the same location inflated pierce counts
and cut pricing (e.g. SULLYS-035 showed 9 pierces instead of 8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:03 -04:00
ajandClaude Opus 4.6 a8d90be2ea feat: add layer filter overloads to Dxf.GetGeometry()
Add optional Func<string, bool> layerFilter parameter to ConvertEntities
and two new GetGeometry overloads (path and stream) that accept a layer
filter. This lets callers control which layers to exclude instead of
being limited to the hardcoded IsNonCutLayer check. Existing overloads
without the filter continue to use the default IsNonCutLayer behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:02 -04:00
ajandClaude Opus 4.6 c25b6bc23a feat(ui): render DXF text annotations in CAD converter preview
Extract MText and TextEntity from the CadDocument during DXF import
and render them in the EntityView. Handles text alignment (left/center/
right via InsertPoint vs AlignmentPoint) and replaces AutoCAD control
codes (%%p → ±, %%d → °, %%c → ⌀). MText formatting codes are
stripped before display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:45:44 -04:00
ajandClaude Opus 4.6 1c994718fb feat(io): add DWG file import support via ACadSharp DwgReader
ACadSharp already includes DwgReader, so this wires it up across the
entire import pipeline — Dxf.Import, CadConverter drag-drop, nest
import dialog, console CLI, BOM analyzer, and training data collector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
ajandClaude Opus 4.6 9d58e6fba8 fix(ui): stay on drawings tab after DXF import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
ajandClaude Opus 4.6 2bae5340f0 test: add nest invariance tests for fill count across import orientations
Verify that filling an L-shaped part produces consistent counts
regardless of the orientation it was imported at, and that all
placed parts stay within the plate work area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:56 -04:00
ajandClaude Opus 4.6 0b322817d7 fix(core): use chain tolerance for entity gap check to prevent spurious rapids
Ellipse-to-arc conversion creates tiny floating-point gaps (~0.00002")
between consecutive arc segments. ShapeBuilder chains these with
ChainTolerance (0.0001"), but ConvertGeometry checked gaps with Epsilon
(0.00001"). Gaps between these thresholds generated spurious rapid moves
that broke GraphicsPath figures, causing diagonal fill artifacts from
GDI+'s implicit figure closing.

Root cause fix: align ConvertGeometry's gap check with ShapeBuilder's
ChainTolerance so precision gaps are absorbed instead of generating rapids.

Defense-in-depth: GraphicsHelper no longer breaks figures at near-zero
rapids, protecting against any programs with residual tiny rapids.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:08 -04:00
ajandClaude Sonnet 4.6 e41f335c63 feat: remove duplicate arcs matching circles on same layer during DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:44:54 -04:00
ajandClaude Opus 4.6 0ab33af5d3 feat: add WeldEndpoints to ShapeBuilder for gap repair on import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 10:40:43 -04:00
ajandClaude Sonnet 4.6 e04c9381f3 feat: add IComparable<Box> and comparison operators to Box
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:36:23 -04:00
ajandClaude Sonnet 4.6 ceb9cc0b44 refactor: move Fraction from OpenNest.IO.Bom to OpenNest.Math
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:33:57 -04:00
ajandClaude Opus 4.6 4cecaba83a fix(core): emit line instead of arc for near-zero sweep to avoid full-circle misinterpretation
Near-zero-sweep arcs with large radius (e.g. from ellipse converter) have
nearly-coincident start/end points. Downstream code (ConvertProgram, Program
BoundingBox) treats coincident start/end as a full 360° circle, inflating the
bounding box and rendering wrong geometry. Emit a LinearMove when sweep is
negligible — geometrically equivalent and avoids the ambiguity. Also fix the
ellipse converter to produce lines instead of degenerate arcs at the source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:50:38 -04:00
ajandClaude Opus 4.6 4053f1f989 fix(core): arc bounding box inflated for near-zero sweep arcs
Arcs with sweep angles smaller than Tolerance.Epsilon were treated as
full circles by IsBetweenRad's shortcut check, causing UpdateBounds to
expand the bounding box to Center ± Radius. This made zoom-to-fit zoom
out far beyond the actual part extents.

Skip cardinal angle expansion when sweep is near-zero so the bounding
box uses only the arc's start/end points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:22:20 -04:00
ajandClaude Opus 4.6 ca67b1bd29 fix(io): handle flipped OCS normal on DXF ellipse import
Ellipses with extrusion direction Z=-1 had their parametric direction
reversed, causing the curve to appear mirrored. Negate start/end
parameters when Normal.Z < 0 to correct the minor-axis traversal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:22:20 -04:00
aj 199095ee43 fix(engine): canonicalize PlaceBestFitPairs builds to match BestFitCache frame 2026-04-23 08:22:20 -04:00
aj eb493d501a feat(engine): wrap single-item Fill with canonicalize/un-rotate bookends 2026-04-23 08:22:20 -04:00
aj 6c98732117 feat(engine): BestFitCache operates in canonical frame; TryPlaceBestFitPair builds from canonical drawing 2026-04-23 08:22:20 -04:00
aj a2e9fd4d14 feat(engine): extract ML features from canonical drawing frame 2026-04-23 08:22:20 -04:00
aj d228b6b812 refactor(engine): share MBR between PartClassifier and CanonicalAngle 2026-04-23 08:22:20 -04:00
aj c634aecd4b docs(core): refresh SourceInfo.Angle doc now that setter wiring lands 2026-04-23 08:22:19 -04:00
aj 14b7c1cf32 feat(core): store Source.Angle; recompute when Program changes 2026-04-23 08:22:19 -04:00
aj 402af91af5 feat(engine): add CanonicalFrame helper for drawing-to-canonical rotation 2026-04-23 08:22:19 -04:00
aj 9a6b656e3c feat(core): add CanonicalAngle helper for MBR-aligning angle 2026-04-23 08:22:19 -04:00
ajandClaude Opus 4.7 d2f9597b0c refactor(fill): use native entity geometry for linear copy distance
Replaces PartBoundary polygon edges with PartGeometry.GetOffsetPerimeterEntities
(inflated Line/Arc entities) so arcs are handled exactly without the polygon
sampling error that previously required a bboxDim + PartSpacing clamp. Adds
bbox DirectionalGap / PerpendicularOverlap early-outs to skip pair checks
that can't produce a valid slide, and removes the now-unused PartBoundary
cache, GetPatternLines/GetOffsetPatternLines helpers, and ComputeCopyDistance
clamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:26:21 -04:00
ajandClaude Opus 4.7 c40dcf0e25 chore: remove unused debug logging to desktop
NfpSlideStrategy wrote to nfp-slide-debug.log on the Desktop on every
call. The console's SetUpLog created test-harness-logs/ next to input
files but nothing in the codebase wrote to Trace, so those files were
always empty. Drop both along with the --no-log flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:24:40 -04:00
ajandClaude Opus 4.6 28653e3a9f feat(shapes): generate unique drawing names from parameters and add toolbar button
Shape library drawings now get descriptive names based on their
parameters (e.g. "Rectangle 12x6", "Circle 8 Dia") instead of generic
type names, preventing silent duplicates in the DrawingCollection
HashSet. Added a Shape Library button to the Drawings tab toolbar
and removed separators between toolbar buttons for a cleaner look.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:48:45 -04:00
ajandClaude Opus 4.6 7c3246c6e7 fix(cutting): restrict tabs to external perimeter and clarify tab UI
Tabs were being applied to internal cutouts and circle holes, which is
incorrect — only the external perimeter should be tabbed. Restructured
the Tabs panel to use radio buttons ("Tab all parts" vs "Auto-tab by
smallest dimension") so the two modes are clearly mutually exclusive
instead of the confusing implicit override behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:55:30 -04:00
ajandClaude Opus 4.6 bd48f57ce0 feat(ui): distinct Dark palette and recolor drawings on scheme switch
- Replace Dark part colors with high-contrast neon/electric palette
- Recolor existing drawings in open nests when scheme changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:06:14 -04:00
ajandClaude Opus 4.6 a6ec21accc fix(ui): address code review issues in color scheme feature
- Sync PlateView.BackColor on repaint so live scheme switch updates background
- Guard FromHex against truncated hex strings (< 6 chars)
- Cache disk schemes to avoid re-reading Schemes/ folder on every access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:56:15 -04:00
ajandClaude Opus 4.6 320cf40f41 feat(ui): ship Schemes folder for user-defined color scheme JSON
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:52:45 -04:00
ajandClaude Opus 4.6 3beca10429 feat(ui): add color scheme picker to Options dialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:52:00 -04:00
ajandClaude Opus 4.6 8bea5dac6c feat(ui): apply active color scheme at startup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:50:03 -04:00
ajandClaude Sonnet 4.6 12f8bbf8f5 feat(ui): add ActiveColorScheme user setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:49:31 -04:00
ajandClaude Opus 4.6 d15790b948 feat(ui): add ColorSchemeRegistry with Classic/Pastel/Dark built-ins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:48:46 -04:00
ajandClaude Opus 4.6 d80f76e386 feat(ui): add ColorScheme.Name/PartColors instance props and JSON serializer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:46:51 -04:00
ajandClaude Sonnet 4.6 07bce8699a refactor(core): make Drawing.PartColors mutable for scheme overrides
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:45:18 -04:00
ajandClaude Opus 4.6 9b84508ff4 refactor(shapes): generalize OctagonShape to NgonShape
Parameterize side count so users can generate any regular n-gon
(n>=3). Width remains the inscribed-circle diameter, preserving n=8
behavior; circumradius derives as Width / (2*cos(pi/n)).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:42:02 -04:00
ajandClaude Opus 4.6 6fdf0ad3c5 refactor(cnc): extract rapid enumeration into RapidEnumerator
Pulls the rapid-walk logic (sub-program unwrapping, first-pierce lookup,
incremental-vs-absolute handling, first-rapid skipping) out of
PlateRenderer.DrawRapids into a reusable RapidEnumerator in Core so it
can be unit-tested and reused outside the renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:49:04 -04:00
aj 4f7bfcc3ad Merge remote-tracking branch 'origin/master' 2026-04-15 12:46:40 -04:00
ajandClaude Opus 4.6 3c53d6fecd fix(engine): default FillContext.Policy to avoid null-deref in ReportProgress
FillContext.ReportProgress dereferences Policy.Comparer, so any caller
that forgot to set Policy hit a NullReferenceException. Default to
FillPolicy(DefaultFillComparer) so tests and ad-hoc callers work without
boilerplate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:28:58 -04:00
ajandClaude Opus 4.6 e239967a7b feat(cincinnati): emit SubProgramCall features as M98 hole calls
When a feature is a single SubProgramCall, wrap the call with a G52
offset shift, emit M98 P<num>, reset G52, and add M47 between features.
Accepts an optional hole subprogram id map so the post can remap
drawing-local subprogram ids to machine subprogram numbers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:17:31 -04:00
ajandClaude Opus 4.6 9d57d3875a fix(cnc): offset SubProgramCall positions in Program.Offset
Program.Offset only adjusted Motion codes, so subprogram calls kept
their original offsets after a part was translated. Apply the offset
to SubProgramCall.Offset too so hole subprograms follow the part.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:17:26 -04:00
ajandClaude Opus 4.6 0e299d7f6f feat(cincinnati): seed material library defaults and add selector dropdown
Adds the full Cincinnati material/etch library list as the committed
default config (seeded into Posts/ on build only when no runtime config
exists), plus a Selected Library override in the PropertyGrid backed by
a TypeConverter that populates from MaterialLibraries. MainForm calls
the new IPostProcessorNestAware hook before showing the config so the
dropdown opens preselected to the best match by nest material and
nearest thickness.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:16:29 -04:00
ajandClaude Opus 4.6 c6f544c5d7 feat(ui): populate material combobox from post processors
Replaces the material textbox on EditNestInfoForm with a combobox whose
items are aggregated from every loaded post processor that implements the
new IMaterialProvidingPostProcessor interface. CincinnatiPostProcessor
exposes its configured MaterialLibraries entries. Free-text entry still
works so custom materials remain usable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:12:54 -04:00
ajandClaude Opus 4.6 9563094c2b fix(ui): show Drawings tab before Plates in EditNestForm
Users need to import a drawing first, so Drawings tab should be the
default landing tab to reduce steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:10:58 -04:00
ajandClaude Opus 4.6 a3ae61d993 fix(cutting): emit open contours raw instead of applying lead-in/lead-out
Open (non-closed) shapes like scribe lines or partial cuts don't have
a meaningful pierce point or closing segment, so applying lead-in/out
would produce invalid toolpaths. Skip the lead-in/out logic and emit
them as raw contours in both Apply and ApplySingle paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:37:56 -04:00
ajandClaude Opus 4.6 838a247ef9 fix(geometry): replace closest-point heuristic with analytical arc-to-line directional distance
ArcToLineClosestDistance used geometric closest-point as a proxy for
directional push distance, which are fundamentally different queries.
The heuristic could overestimate the safe push distance when an arc
faces an inclined line, causing the Compactor to over-push parts into
overlapping positions.

Replace with analytical computation: for each arc/line pair, solve
dt/dθ = 0 to find the two critical angles where the directional
distance is stationary, evaluate both (if within the arc's angular
span), and fire a ray to verify the hit is within the line segment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:33:48 -04:00
ajandClaude Opus 4.6 a5e5e78c4e refactor(geometry): deduplicate axis branches in SpatialQuery.OneWayDistance
Merge the near-identical Left/Right and Up/Down pruning loops into a
single loop that selects the perpendicular axis via IsHorizontalDirection().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:58:45 -04:00
ajandClaude Opus 4.6 c386e462b2 docs(readme): add CAD converter section with screenshots
Add a CAD Converter workflow section and inline thumbnail screenshots.
Rearrange existing screenshots as side-by-side thumbnails with
click-to-enlarge links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:39 -04:00
ajandClaude Opus 4.6 2c0457d503 feat(ui): add bend line editing to CAD converter
Add Edit link and double-click handler to the bend lines list so
existing bends can be modified without removing and re-adding them.
BendLineDialog gains a LoadBend method to populate fields from an
existing Bend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:26 -04:00
ajandClaude Opus 4.6 b03b3eb4d9 fix(bending): detect bend lines on layer "0" in addition to "BEND"
SolidWorks drawings sometimes place centerline bend markers on the
default layer instead of a dedicated BEND layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:36:21 -04:00
ajandClaude Opus 4.6 29c2872819 fix(geometry): add Entity.Clone() and stop NormalizeEntities from mutating originals
ShapeProfile.NormalizeEntities called Shape.Reverse() which flipped arc
directions on the original entity objects shared with the CAD view. Switching
to the Program tab and back would leave arcs reversed. Clone entities before
normalizing so the originals stay untouched.

Adds abstract Entity.Clone() with implementations on Line, Arc, Circle,
Polygon, and Shape (deep-clones children). Also adds CloneAll() extension
and replaces manual duplication in PartGeometry.CopyEntitiesAtLocation and
ProgramEditorControl.CloneEntity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:35:13 -04:00
ajandClaude Opus 4.6 3e96c62f33 docs(readme): reformat features as tables and document cutout-aware splitter
Feature list becomes grouped tables (Import/Export, Nesting, Plate
Operations, CNC Output). Nest file format section expands to cover the
newer entities/programs/subs layout. Drawing Splitting section gains a
paragraph explaining cutout-aware clipping: Liang-Barsky line clipping,
arc-vs-region intersection, and connected-component detection that emits
one drawing per physically-disconnected strip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:55:11 -04:00
ajandClaude Opus 4.6 6880dee489 fix(splitter): preserve disconnected strips and trim cuts around cutouts
Splits that cross an interior cutout previously merged physically
disconnected strips into one drawing and drew cut lines through the hole.
The region boundary now spans full feature-edge extents (trimmed against
cutout polygons) and line entities are Liang-Barsky clipped, so multi-split
edges work. Arcs are properly clipped at region boundaries via iterative
split-at-intersection so circles that straddle a split contribute to both
sides. AssemblePieces groups a region's entities into connected closed
loops and nests holes by bbox-pre-check + vertex-in-polygon containment,
so one region can emit multiple drawings when a cutout fully spans it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:46:47 -04:00
ajandClaude Opus 4.6 0e45c13515 feat(shapes): add PlateSizes catalog and wire Ctrl+P to snap-to-standard
PlateSizes holds standard mill sheet sizes (48x96 through 96x240) and
exposes Recommend() which snaps small layouts to an increment and
rounds larger layouts up to the nearest fitting sheet. Plate.SnapToStandardSize
applies the result while preserving long-axis orientation, and the
existing Ctrl+P "Resize to Fit" menu in EditNestForm now calls it
instead of the simple round-up AutoSize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:16:29 -04:00
aj 54def611fa refactor(ui): switch CreateShapeFromInputs to control-type branching 2026-04-10 17:52:03 -04:00
ajandClaude Sonnet 4.6 b1d094104a feat(ui): add filtered pipe size dropdown to shape library
Renders PipeSize as a DropDownList ComboBox, filters entries to those fitting
the current hole geometry, disables the combo when Blind is checked, and
appends an invalid-pipe warning to the preview info when TryGetOD fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:50:01 -04:00
ajandClaude Sonnet 4.6 9d66b78a11 feat(ui): add bool checkbox support to ShapeLibraryForm
BuildParameterControls now creates a CheckBox (wired to UpdatePreview) for bool properties instead of a TextBox; CreateShapeFromInputs reads the Checked value via a short-circuit before the TextBox cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:47:36 -04:00
aj eddbbca7ef test(shapes): verify PipeFlangeShape JSON loading and shipped config integrity 2026-04-10 17:45:46 -04:00
ajandClaude Sonnet 4.6 4e7b5304a0 chore(shapes): migrate flange config to PipeFlangeShape schema
Replace NominalPipeSize (double) with PipeSize (string label) and add
PipeClearance: 0.0625 to all 136 entries in PipeFlangeShape.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:42:16 -04:00
aj 06485053fc test(shapes): cover empty-string PipeSize in addition to null 2026-04-10 17:39:50 -04:00
ajandClaude Sonnet 4.6 92a57d33df feat(shapes): add pipe bore, clearance, and blind flag to PipeFlangeShape
Replaces NominalPipeSize (double) with PipeSize (string), PipeClearance (double), and Blind (bool). GetDrawing cuts a center bore at pipeOD + PipeClearance unless Blind is true or PipeSize is unknown/null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:36:10 -04:00
aj 6adc5b0967 refactor(shapes): rename FlangeShape to PipeFlangeShape 2026-04-10 17:33:28 -04:00
aj d215d02844 style(shapes): remove redundant usings and document PipeSizes bound 2026-04-10 17:31:22 -04:00
ajandClaude Sonnet 4.6 57863e16e9 feat(shapes): add ANSI pipe OD lookup table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:27:25 -04:00
ajandClaude Opus 4.6 091e750e1b chore(cad-importer): remove dead code and cover named detector branch
- Drop CadImportResult.Document: no caller reads it after the
  migrations (BendDetectorRegistry runs inside CadImporter.Import
  itself, and downstream callers only consume the entity/bend data).
- Drop dead CadConverterForm.GetNextColor() helper: zero callers
  since GetDrawings stopped needing it.
- Drop stale 'using OpenNest.Properties;' and unused 'newItems'
  local in OnSplitClicked.
- Add Import_WhenNamedDetectorDoesNotExist_ReturnsEmptyBends to
  cover the previously untested named-detector branch in
  CadImporter.Import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:13:44 -04:00
ajandClaude Opus 4.6 87b965f895 refactor(ui): use CadImporter in BomImportForm
Replaces the hand-rolled DXF->Drawing pipeline (Dxf.Import + bend
detection + normalize + ConvertGeometry + pierce offset extraction)
with a single CadImporter.ImportDrawing call. Brings BomImportForm's
output in line with the rest of the callers: drawings now carry
Source.Offset, SourceEntities, SuppressedEntityIds, and detected bends,
and round-trip cleanly through nest files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:11:49 -04:00
aj 08f60690a7 docs: document CadImporter service in CLAUDE.md 2026-04-10 13:27:46 -04:00
ajandClaude Sonnet 4.6 a4609c816c refactor(ui): use CadImporter.BuildDrawing in CadConverterForm.GetDrawings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:24:26 -04:00
ajandClaude Sonnet 4.6 5a4272696e refactor(ui): use CadImporter.Import in CadConverterForm.AddFile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:18:49 -04:00
ajandClaude Sonnet 4.6 2cf03be360 refactor(training): use CadImporter for DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:13:00 -04:00
ajandClaude Sonnet 4.6 041e184d93 refactor(api): use CadImporter for DXF import in NestRunner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:08:35 -04:00
ajandClaude Sonnet 4.6 26df3174ea refactor(mcp): use CadImporter for DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:05:28 -04:00
ajandClaude Sonnet 4.6 0f5aace126 refactor(console): use CadImporter for DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:03:16 -04:00
ajandClaude Sonnet 4.6 399f8dda6e feat: add CadImporter.ImportDrawing convenience method
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:59:06 -04:00
ajandClaude Sonnet 4.6 d921558b9c feat: add CadImporter.BuildDrawing stage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 12:51:58 -04:00
ajandClaude Opus 4.6 bf3e3e1f42 feat: add CadImporter.Import stage with bend detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:37:12 -04:00
aj e120ece014 feat: add CadImportResult data object for CadImporter 2026-04-10 12:28:17 -04:00
aj 264e8264be feat: add CadImportOptions for CadImporter service 2026-04-10 12:25:04 -04:00
ajandClaude Opus 4.6 24babe353e fix: show both offset and rotation in SubProgramCall.ToString
The either/or format meant a SubProgramCall with both a non-zero
Offset and non-zero Rotation would only show the Offset, hiding the
rotation metadata. The data model supports both independently, so the
display should too.

Also fixes a zero-field leak where the old fallback emitted
`G65 P_ R0` for calls with no rotation. Now each field is only shown
when non-zero, and `G65 P_` with no arguments is emitted when
neither is set.

Note: SubProgramCall.ToString is purely a debug/display aid. The
Cincinnati post emits sub-calls via the G52 + M98 bracket, not via
G65, so this format doesn't correspond to real machine output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:46 -04:00
ajandClaude Opus 4.6 e63be93051 fix: emit G52 bracket for hole sub-program calls
CincinnatiSheetWriter.WriteHoleSubprogramCall emitted
`M98 P<num> X<x> Y<y>`, but per manual §3.98 ("M98 SUB-PROGRAM CALL
WITH NO ARGUMENTS") M98 takes only P and L — the X/Y had no defined
meaning to the control. The intent was to position the sub-program at
the hole center, which is what G52 is for per §1.52 ("local work
coordinate system") and which explicitly does not move the nozzle.

Emit the documented G52 bracket instead:
  G52 X<hole.x> Y<hole.y>
  M98 P<holeSubNum>
  G52 X0 Y0

The hole sub-program is authored in hole-local coordinates, so its
first rapid (the lead-in to the pierce point) resolves to the absolute
pierce under the G52 shift and moves the tool directly there from the
previous feature's end — no phantom rapid to the hole center.

Also add docs/cincinnati-post-output.md as the reference for the full
post output format, with every emitted G/M code cross-referenced to
the Cincinnati programming manual. Un-ignore docs/ (docs/superpowers/
stays ignored) and track the PDF manual alongside the reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:21:15 -04:00
ajandClaude Opus 4.6 ba3c3cbea3 fix: draw sub-program rapid directly to lead-in pierce
The SubProgramCall branch in DrawRapids used to draw a rapid from the
previous feature's end to the hole center, then rely on the sub-program's
own first rapid to draw from center to the lead-in pierce. That rendered
a phantom center-hop segment that doesn't exist physically — a
SubProgramCall is a coordinate-frame shift (emitted as a G52 bracket on
Cincinnati), not a move to the hole center.

Look ahead through the sub-program for its first pierce point in
absolute coordinates and draw a single direct rapid from pos to that
pierce. Recurse into the sub with skipFirstRapid: true so the sub's
first rapid isn't drawn again on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:17:35 -04:00
ajandClaude Opus 4.6 572fa06a21 fix: track tool position through sub-programs in ConvertMode
ConvertMode.ToIncremental skipped SubProgramCall codes entirely when
computing deltas, so parent motions after a sub-call were encoded as if
the tool never moved. Several traversal sites (ConvertProgram,
GraphicsHelper, PlateRenderer, CutDirectionArrows, Program.BoundingBox)
worked around this with save/restore hacks that treated sub-calls as
transparent — but DrawRapids legitimately tracks actual tool position,
so after the last hole the first perimeter rapid was applied to the
wrong base, drifting the rendered perimeter past the plate edge by
roughly the distance to the last hole.

Fix the root cause: ToIncremental and ToAbsolute now walk sub-programs
to compute where they leave the tool, and advance pos accordingly. The
other traversals capture a frameOrigin at entry and compute sub-call
placement as frameOrigin + Offset, letting pos advance naturally
through the sub recursion. All the save/restore workarounds are
removed.

Program.BoundingBox also picks up the same frame-origin treatment,
which corrects a latent bug where absolute-mode endpoints and nested
sub-calls dropped the parent's frame origin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:51:51 -04:00
ajandClaude Opus 4.6 a6c2235647 fix: let DrawRapids track actual tool position through sub-programs
Don't restore pos after SubProgramCall expansion in DrawRapids — the
machine moves from hole to hole sequentially, so rapids should connect
from the previous hole's end to the next hole's center.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:26:39 -04:00
ajandClaude Opus 4.6 5c918a0978 fix: draw rapid move to hole center before sub-program lead-in
The rapid from the previous feature to the hole center is implied by
the SubProgramCall offset but wasn't being drawn. Now DrawRapids
renders this traverse before recursing into the sub-program.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:46:44 -04:00
ajandClaude Opus 4.6 92461deb98 fix: apply SubProgramCall offset additively and restore curpos after expansion
ConvertMode.ToIncremental skips SubProgramCalls when computing deltas,
so all code paths that expand SubProgramCalls must: (1) set curpos to
savedPos + Offset before expanding, and (2) restore curpos afterward
so subsequent incremental codes get correct deltas.

Fixed in ConvertProgram, GraphicsHelper (AddProgram, AddProgramSplit),
PlateRenderer (DrawRapids, DrawProgramPiercePoints, GetFirstPiercePoint),
and CutDirectionArrows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:40:05 -04:00
ajandClaude Sonnet 4.6 bc859aa28c feat: handle SubProgramCall offsets in BoundingBox and Rotate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:47:40 -04:00
ajandClaude Opus 4.6 09eac96a03 feat: handle SubProgramCalls in Cincinnati post feature splitting
SubProgramCalls are now treated as standalone features in the Cincinnati
post-processor. SplitByRapids emits them as single-element features
instead of splitting on rapids within sub-programs. A nest-level hole
sub-program registry deduplicates by content and assigns post numbers.
Sheet writers emit M98 calls with X/Y offsets for hole features, and
hole sub-program definitions are written after part sub-programs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:44:58 -04:00
ajandClaude Opus 4.6 df65414a9d feat: serialize and deserialize hole sub-programs in nest file format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:40:13 -04:00
ajandClaude Opus 4.6 4aed231611 feat: emit SubProgramCalls for circle holes in ContourCuttingStrategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:35:56 -04:00
ajandClaude Sonnet 4.6 c641b3b68e feat: expand SubProgramCalls with Offset in ConvertProgram
Inline sub-program geometry into the parent geometry list using Offset
as the starting curpos, replacing the Shape-wrapping approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:31:13 -04:00
ajandClaude Sonnet 4.6 f3b27c32c3 feat: add SubPrograms dictionary to Program with deep-copy support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:28:37 -04:00
ajandClaude Sonnet 4.6 c270d8ea76 feat: add Offset property to SubProgramCall for hole positioning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:26:55 -04:00
ajandClaude Opus 4.6 de6877ac48 feat: add option to round lead-in angles for circle holes
Snaps lead-in angles on ArcCircle contours to a configurable
increment (default 5°), reducing unique hole variations from
infinite to 72 max. Rounding happens upstream in EmitContour
so the PlateView and post output stay in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:41:33 -04:00
ajandClaude Opus 4.6 3481764416 perf: use perimeter-only drawing in best fit pair evaluation
PairEvaluator was cloning the full CNC program (including all internal
cutouts) for every candidate. For parts with many holes (e.g. 952),
this caused O(n²) overlap checks and thousands of unnecessary polygon
tessellations per candidate.

Now extracts the perimeter shape once, builds a lightweight drawing
from it, and uses that for all Part.CreateAtOrigin calls. Cutouts are
irrelevant for best fit — only the outer boundary matters for pairing.

75x speedup on a 952-hole rectangle (30s → 0.4s).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:14:02 -04:00
ajandClaude Opus 4.6 640814fdf6 fix: marshal timer callbacks to UI thread to prevent GDI+ threading exception
System.Timers.Timer fires on thread pool threads, causing GraphicsPath
objects to be accessed concurrently by hover detection and OnPaint,
triggering "Object is currently in use elsewhere" in DrawParts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:41:18 -04:00
ajandClaude Opus 4.6 6a30828fad feat: optimize external lead-in placement using next-part pierce points
External lead-ins now sit on the line between the last internal cutout
and the next part's first pierce point, minimizing rapid travel. Cutout
sequencing starts from the bounding box corner opposite the origin and
iterates 3 times to converge the perimeter lead-in and internal sequence.
LeadInAssigner and PlateProcessor both use a two-pass approach: first
pass collects pierce points, second pass refines with next-part knowledge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:33:55 -04:00
1379 changed files with 202438 additions and 16990 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
**
!OpenNest.Server/
!OpenNest.Server/**
!OpenNest.Data/
!OpenNest.Data/**
!OpenNest.Core/
!OpenNest.Core/**
**/bin/**
**/obj/**
**/data/**
**/*.db
**/*.db-*
**/.env*
**/*.env
**/*.env.*
**/*.user
**/Properties/launchSettings.json
+68
View File
@@ -0,0 +1,68 @@
# Unified code style for OpenNest.
# Canonical formatter: dotnet format.
# Format only the files or directories being changed:
# dotnet format OpenNest.sln --include path/to/changed-file.cs
# dotnet format OpenNest.sln --include path/to/changed-file.cs --verify-no-changes
# These settings are used by dotnet format and IDE auto-formatting
# (VS / Rider / VS Code).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{csproj,props,targets,xml,config,manifest}]
indent_style = space
indent_size = 2
[*.{json,yml,yaml}]
indent_style = space
indent_size = 2
[*.{cs,csx}]
indent_style = space
indent_size = 4
# dotnet format cannot re-wrap source to this limit, but IDEs can surface it
# as a visual guide and analyzers can flag hard violations.
max_line_length = 100
# --- Using directives (System first, outside the namespace) ---
dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning
# --- Brace placement: Allman (opening brace on its own line) ---
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# --- Spacing ---
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_after_cast = false
csharp_space_after_comma = true
csharp_space_before_comma = false
# --- Code style preferences ---
# Project rule: always use var for locals (see CLAUDE.md).
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = true:suggestion
csharp_prefer_simple_using_statement = true:suggestion
csharp_style_namespace_declarations = file_scoped:silent
dotnet_style_prefer_auto_properties = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+3
View File
@@ -0,0 +1,3 @@
# Commits whose changes git blame should skip (whitespace-only sweeps).
# Enable locally: git config blame.ignoreRevsFile .git-blame-ignore-revs
aec052306234e3c4313c0ee8905e2557d3c3671b
+112
View File
@@ -0,0 +1,112 @@
name: Cross-platform tests
on:
pull_request:
push:
branches: [master]
permissions:
contents: read
jobs:
unit-tests:
name: Unit tests (${{ matrix.project }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 4
matrix:
project:
- OpenNest.Tests
- OpenNest.Engine.Tests
- OpenNest.IO.Tests
- OpenNest.Server.Tests
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Run ${{ matrix.project }}
run: dotnet test "${{ matrix.project }}/${{ matrix.project }}.csproj"
synthetic-nests:
name: Six synthetic Irregular nests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Test fail-closed final CI check
run: python3 scripts/test_ci_results.py -v
- name: Check six synthetic Irregular nests
timeout-minutes: 6
run: |
python3 scripts/test_check_synthetic_nests.py -v
python3 scripts/check-synthetic-nests.py --parallel 2
# Preserve the existing check name for required-status consumers.
tests:
name: tests
needs: [unit-tests, synthetic-nests]
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Require every test job to succeed
env:
UNIT_TEST_RESULT: ${{ needs.unit-tests.result }}
SYNTHETIC_RESULT: ${{ needs.synthetic-nests.result }}
run: bash scripts/check-ci-results.sh "$UNIT_TEST_RESULT" "$SYNTHETIC_RESULT"
windows-desktop:
# WinForms tests need a Windows desktop runtime; the Linux job can only
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
runs-on: windows-2022
timeout-minutes: 20
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Run Windows desktop and front-end tests
# A test silent for 5 minutes is killed and named in the log, with a
# mini dump in TestResults, instead of holding the runner until the
# job timeout.
run: |
$failed = @()
foreach ($project in @('OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { $failed += $project }
}
if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" }
- name: Test acceptance evidence checker
run: python scripts/test_windows_acceptance.py -v
- name: Verify named Windows acceptance coverage
if: always()
run: python scripts/windows_acceptance.py --results TestResults
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-desktop-test-results
path: TestResults/
if-no-files-found: warn
retention-days: 14
+287
View File
@@ -0,0 +1,287 @@
name: Server image
on:
pull_request:
paths:
- 'OpenNest.Server/**'
- 'OpenNest.Data/**'
- 'OpenNest.Core/**'
- 'OpenNest.Server.Tests/**'
- 'scripts/Server.Tests/**'
- 'scripts/Test-ServerContainer.sh'
- 'scripts/test_server_container_cleanup.py'
- 'scripts/server_image_release.py'
- 'scripts/test_server_image_release.py'
- '.dockerignore'
- 'compose.server.yaml'
- '.github/workflows/server-image.yml'
push:
branches: [master]
paths:
- 'OpenNest.Server/**'
- 'OpenNest.Data/**'
- 'OpenNest.Core/**'
- 'OpenNest.Server.Tests/**'
- 'scripts/Server.Tests/**'
- 'scripts/Test-ServerContainer.sh'
- 'scripts/test_server_container_cleanup.py'
- 'scripts/server_image_release.py'
- 'scripts/test_server_image_release.py'
- '.dockerignore'
- 'compose.server.yaml'
- '.github/workflows/server-image.yml'
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Approved existing vX.Y.Z tag to publish (dispatch from master only)'
required: true
type: string
permissions:
contents: read
defaults:
run:
shell: bash
# No tag-push trigger. Windows tag builds produce candidates, not publications.
jobs:
validate:
if: github.event_name == 'pull_request' || github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
VERSION: '0.0.0'
SOURCE_SHA: ${{ github.sha }}
LOCAL_IMAGE: opennest-server:ci
steps:
- name: Isolate logs, safe metadata and ephemeral auth
run: |
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
env:
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
with:
dotnet-version: '8.0.x'
- name: Test fail-closed release and cleanup helpers
run: |
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
- name: Build and test Server
run: |
dotnet build OpenNest.Server/OpenNest.Server.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release --logger 'trx;LogFileName=server.trx' --results-directory "$RESULTS/tests"
- name: Resolve base image digests
id: bases
run: python3 scripts/server_image_release.py bases
- name: Build exact local linux/amd64 image (never cached)
id: build
env:
SDK_IMAGE: ${{ steps.bases.outputs.sdk_image }}
RUNTIME_IMAGE: ${{ steps.bases.outputs.runtime_image }}
run: |
set -euo pipefail
mkdir -p "$RESULTS"
docker build --pull --no-cache --platform linux/amd64 --provenance=false --sbom=false -f OpenNest.Server/Dockerfile \
--build-arg VERSION="$VERSION" --build-arg SOURCE_REVISION="$SOURCE_SHA" \
--build-arg SDK_IMAGE="$SDK_IMAGE" --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \
-t "$LOCAL_IMAGE" . 2>&1 | tee "$RESULTS/build.log"
python3 scripts/server_image_release.py local
- name: Freeze the inspected image identity for smoke and publication
env:
IMAGE_ID: ${{ steps.build.outputs.image_id }}
run: printf 'LOCAL_IMAGE=%s\n' "$IMAGE_ID" >> "$GITHUB_ENV"
- name: Real-client persistence, backup/restore and runtime smoke
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
- name: Retain logs and safe provenance only
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: server-image-validation-${{ github.run_id }}
path: |
${{ runner.temp }}/server-image-results/**/*.log
${{ runner.temp }}/server-image-results/tests/*.trx
${{ runner.temp }}/server-image-metadata/bases.json
${{ runner.temp }}/server-image-metadata/local.json
retention-days: 14
publish:
if: >-
github.repository == 'ajisaacs/OpenNest' &&
((github.event_name == 'release' && github.event.release.prerelease == false) ||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
runs-on: ubuntu-latest
timeout-minutes: 40
permissions:
contents: read
packages: write
concurrency:
group: opennest-server-ghcr-publish
cancel-in-progress: false
env:
LOCAL_IMAGE: opennest-server:release
outputs:
source_sha: ${{ steps.source.outputs.source_sha }}
version: ${{ steps.source.outputs.version }}
manifest_digest: ${{ steps.readback.outputs.manifest_digest }}
config_digest: ${{ steps.readback.outputs.config_digest }}
steps:
- name: Isolate logs, safe metadata and ephemeral auth
run: |
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: master
fetch-depth: 0
persist-credentials: false
- name: Resolve approved tag, release event commit and master ancestry
id: source
env:
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: python3 scripts/server_image_release.py source
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ steps.source.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Use immutable source/version for every remaining gate
env:
VERSION: ${{ steps.source.outputs.version }}
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
run: |
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
printf 'VERSION=%s\nSOURCE_SHA=%s\n' "$VERSION" "$SOURCE_SHA" >> "$GITHUB_ENV"
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
env:
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
with:
dotnet-version: '8.0.x'
- name: Test fail-closed release and cleanup helpers
run: |
python3 -m unittest discover -s scripts -p test_server_image_release.py -v
python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v
- name: Build and test exact Server source
run: |
dotnet build OpenNest.Server/OpenNest.Server.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release --logger 'trx;LogFileName=server.trx' --results-directory "$RESULTS/tests"
- name: Resolve base image digests
id: bases
run: python3 scripts/server_image_release.py bases
- name: Build exact local linux/amd64 image (never cached)
id: build
env:
SDK_IMAGE: ${{ steps.bases.outputs.sdk_image }}
RUNTIME_IMAGE: ${{ steps.bases.outputs.runtime_image }}
run: |
set -euo pipefail
mkdir -p "$RESULTS"
docker build --pull --no-cache --platform linux/amd64 --provenance=false --sbom=false -f OpenNest.Server/Dockerfile \
--build-arg VERSION="$VERSION" --build-arg SOURCE_REVISION="$SOURCE_SHA" \
--build-arg SDK_IMAGE="$SDK_IMAGE" --build-arg RUNTIME_IMAGE="$RUNTIME_IMAGE" \
-t "$LOCAL_IMAGE" . 2>&1 | tee "$RESULTS/build.log"
python3 scripts/server_image_release.py local
- name: Freeze the inspected image identity for smoke and publication
env:
IMAGE_ID: ${{ steps.build.outputs.image_id }}
run: printf 'LOCAL_IMAGE=%s\n' "$IMAGE_ID" >> "$GITHUB_ENV"
- name: Smoke the same local image before any registry write
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
- name: Refuse unknown visibility, wrong association, and both existing tags
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/server_image_release.py preflight
- name: Login and publish only the two immutable tags (no rebuild)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -m 700 -p "$DOCKER_CONFIG"
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
image=ghcr.io/ajisaacs/opennest-server
docker tag "$LOCAL_IMAGE" "$image:$VERSION"
docker tag "$LOCAL_IMAGE" "$image:sha-$SOURCE_SHA"
docker push "$image:$VERSION"
docker push "$image:sha-$SOURCE_SHA"
- name: Exact registry readback of both tags and smoked image config
id: readback
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/server_image_release.py readback
- name: Remove ephemeral Docker credentials
if: always()
run: rm -rf -- "$DOCKER_CONFIG"
- name: Retain logs and safe provenance only (not a verification verdict)
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: server-image-publication-${{ github.run_id }}
path: |
${{ runner.temp }}/server-image-results/**/*.log
${{ runner.temp }}/server-image-results/tests/*.trx
${{ runner.temp }}/server-image-metadata/source.json
${{ runner.temp }}/server-image-metadata/bases.json
${{ runner.temp }}/server-image-metadata/local.json
${{ runner.temp }}/server-image-metadata/preflight.json
${{ runner.temp }}/server-image-metadata/registry.json
retention-days: 14
verify-published:
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
packages: read
env:
VERSION: ${{ needs.publish.outputs.version }}
SOURCE_SHA: ${{ needs.publish.outputs.source_sha }}
MANIFEST_DIGEST: ${{ needs.publish.outputs.manifest_digest }}
CONFIG_DIGEST: ${{ needs.publish.outputs.config_digest }}
steps:
- name: Isolate logs, safe metadata and ephemeral auth
run: |
printf 'METADATA_DIR=%s/server-image-metadata\nRESULTS=%s/server-image-results\nDOCKER_CONFIG=%s/server-image-auth\n' \
"$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.publish.outputs.source_sha }}
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
env:
DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet8
with:
dotnet-version: '8.0.x'
- name: Pull returned digest on a clean runner
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 -c 'import os,sys; sys.path.insert(0,"scripts"); from server_image_release import sha256,commit,version; sha256(os.environ["MANIFEST_DIGEST"]); sha256(os.environ["CONFIG_DIGEST"]); commit(os.environ["SOURCE_SHA"]); version("v"+os.environ["VERSION"])'
mkdir -m 700 -p "$DOCKER_CONFIG"
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
image="ghcr.io/ajisaacs/opennest-server@$MANIFEST_DIGEST"
docker pull --platform linux/amd64 "$image"
printf 'LOCAL_IMAGE=%s\n' "$image" >> "$GITHUB_ENV"
- name: Remove ephemeral Docker credentials
if: always()
run: rm -rf -- "$DOCKER_CONFIG"
- name: Verify pulled digest, config, platform and provenance
run: python3 scripts/server_image_release.py pulled
- name: Real-client persistence smoke of pulled digest
run: scripts/Test-ServerContainer.sh --image "$LOCAL_IMAGE" --results "$RESULTS/smoke"
- name: Mark verified only after pulled-image smoke passes
run: printf 'Verified private server image `%s` from `%s`.\n' "$LOCAL_IMAGE" "$SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY"
- name: Retain logs and safe pulled-image provenance only
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: server-image-pulled-verification-${{ github.run_id }}
path: |
${{ runner.temp }}/server-image-results/**/*.log
${{ runner.temp }}/server-image-metadata/pulled.json
retention-days: 14
+109
View File
@@ -0,0 +1,109 @@
name: Windows release build
on:
push:
branches: ['release/**']
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Release version (for example 0.3.0)'
required: true
type: string
permissions:
contents: read
jobs:
windows:
runs-on: windows-2022
# The suites have roughly doubled since the v0.3.0 run (7.5 min); the hang
# guard below names a stuck test long before this cap.
timeout-minutes: 45
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Resolve version
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
$version = $env:INPUT_VERSION
if ($env:GITHUB_EVENT_NAME -eq 'push') {
$version = $env:GITHUB_REF_NAME -replace '^release/', '' -replace '^v', ''
}
if ($version -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') {
throw "Expected an X.Y.Z version, not '$version'."
}
"RELEASE_VERSION=$version" >> $env:GITHUB_ENV
- name: Build solution
run: |
dotnet build OpenNest.sln -c Release
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
- name: Install pinned Poppler for PDF report tests
run: |
$zip = Join-Path $env:RUNNER_TEMP 'poppler-26.09.0.zip'
Invoke-WebRequest 'https://github.com/oschwartz10612/poppler-windows/releases/download/v26.09.0-0/Release-26.09.0-0.zip' -OutFile $zip
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne '7a6f256a0ddf7536182246a5733331bf4677cbcc34f4663774947ad34556c8d0') { throw 'Poppler archive hash mismatch.' }
$destination = Join-Path $env:RUNNER_TEMP 'poppler-release-tests'
Expand-Archive $zip -DestinationPath $destination
$bin = Join-Path $destination 'poppler-26.09.0/Library/bin'
$tool = Join-Path $bin 'pdftotext.exe'
if (-not (Test-Path $tool)) { throw 'Pinned pdftotext.exe is missing.' }
& $tool -v
if ($LASTEXITCODE -ne 0) { throw 'Pinned pdftotext.exe cannot start.' }
$bin >> $env:GITHUB_PATH
- name: Run all test projects on Windows
# A test silent for 5 minutes is killed and named in the log, with a
# mini dump in TestResults, instead of holding the runner until the
# job timeout.
run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
}
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --blame-hang-timeout 5m --blame-hang-dump-type mini --logger 'trx;LogFileName=OpenNest.Tests.Debug.trx' --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw 'Debug tests failed.' }
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION
- name: Verify overwrite protection
run: |
$zip = Get-ChildItem artifacts/*.zip
$before = (Get-FileHash $zip.FullName).Hash
$rejected = $false
try { ./scripts/Publish-Windows.ps1 -Version $env:RELEASE_VERSION }
catch {
if ($_.Exception.Message -notlike 'Refusing to overwrite existing output:*') { throw }
$rejected = $true
}
if (-not $rejected) { throw 'Existing output was not rejected.' }
if ((Get-FileHash $zip.FullName).Hash -ne $before) { throw 'Existing ZIP changed.' }
Write-Host 'PASS: existing release package preserved.'
- name: Upload verified release candidate
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: OpenNest-${{ env.RELEASE_VERSION }}-win-x64
path: |
artifacts/*.zip
artifacts/*.sha256
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Upload test results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: windows-test-results
path: TestResults/
if-no-files-found: warn
retention-days: 14
+13 -4
View File
@@ -11,6 +11,7 @@
*.userprefs
# Build results
/artifacts/
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
@@ -206,13 +207,21 @@ FakesAssemblies/
*.db
*.db-journal
# Claude Code
# Local agent state and temporary planning/progress documents
.claude/
/.hermes/plans/
/.hermes/progress/
.superpowers/
docs/superpowers/
# Documentation (manuals, templates, etc.)
docs/
/docs/*-plan.md
/docs/*-progress.md
# Launch settings
**/Properties/launchSettings.json
# Local test config (contains user-specific paths to proprietary test assets)
OpenNest.Tests/test-config.json
# Vendor programming manuals: keep reference copies outside source control.
CINCINNATI LASER PROGRAMMING MANUAL.pdf
TF5200_programming_manual_en.pdf
+2 -2
View File
@@ -1,8 +1,8 @@
{
"mcpServers": {
"opennest": {
"command": "cmd",
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"]
"command": "${USERPROFILE}/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
"args": []
}
}
}
+69
View File
@@ -0,0 +1,69 @@
# OpenNest agent instructions
Shared instructions; keep `CLAUDE.md` as the thin `@AGENTS.md` import.
OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform libraries.
## Working rules
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
## Build and test
```sh
# Full solution: Windows
dotnet build OpenNest.sln
# Cross-platform suites: run independently on Linux/macOS/Windows
# Use Release for routine runs; use Debug explicitly for DEBUG-only work counters.
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
# Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
```
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`.
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
## Project map and boundaries
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
## Geometry and ownership safeguards
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures.
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [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
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md)
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
+1 -119
View File
@@ -1,119 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using NFP-based (No Fit Polygon) and rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines.
## Build
This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-windows`. Build with:
```bash
dotnet build OpenNest.sln
```
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
## Architecture
Eight projects form a layered architecture:
### OpenNest.Core (class library)
Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
### OpenNest.Engine (class library, depends on Core)
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine.
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
- **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts.
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
### OpenNest.IO (class library, depends on Core)
File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp.
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
- `ProgramReader` — G-code text parser.
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
### OpenNest.Console (console app, depends on Core + Engine + IO)
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
### OpenNest.Gpu (class library, depends on Core + Engine)
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
### OpenNest.Training (console app, depends on Core + Engine)
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
### OpenNest.Mcp (console app, depends on Core + Engine + IO)
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
- **Tools/InputTools**: `load_nest`, `import_dxf`, `create_drawing` (built-in shapes or G-code).
- **Tools/SetupTools**: `create_plate`, `clear_plate`.
- **Tools/NestingTools**: `fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`.
- **Tools/InspectionTools**: `get_plate_info`, `get_parts`, `check_overlaps`.
- `NestSession` — in-memory state across tool calls (current Nest, standalone plates/drawings).
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
The UI application with MDI interface.
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime.
## File Format
Nest files (`.nest`, ZIP-based) use v2 JSON format:
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
## Tool Preferences
Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary method for exploring and analyzing this codebase. It is faster and more efficient than file-based searches. Use it for finding symbols, references, diagnostics, type hierarchies, and code navigation. Only fall back to Glob/Grep when Roslyn Bridge cannot fulfill the query.
## Code Style
- Always use `var` instead of explicit types (e.g., `var parts = new List<Part>();` not `List<Part> parts = new List<Part>();`).
## Documentation Maintenance
Always keep `README.md` and `CLAUDE.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change.
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
## Key Patterns
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
- OpenNest.Engine uses sub-namespaces: `OpenNest.Engine.Fill` (fill algorithms), `OpenNest.Engine.Strategies` (pluggable strategy layer), `OpenNest.Engine.BestFit`, `OpenNest.Engine.Nfp` (NFP-based nesting, not yet integrated), `OpenNest.Engine.ML`, `OpenNest.Engine.RapidPlanning`, `OpenNest.Engine.Sequencing`.
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
@AGENTS.md
+14
View File
@@ -6,10 +6,24 @@ namespace OpenNest.Api;
public class NestRequest
{
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
/// <summary>
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
/// an empty list deliberately means no stock is available.
/// </summary>
public IReadOnlyList<NestRequestPlate> Plates { get; init; }
public Size SheetSize { get; init; } = new(60, 120);
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
public string PlacementStrategy { get; init; } = "Default";
/// <summary>Registered whole-job engine. Null uses PlacementStrategy (or legacy Strategy).</summary>
public string Engine { get; init; }
public string Material { get; init; } = "Steel, A1011 HR";
public double Thickness { get; init; } = 0.06;
public double Spacing { get; init; } = 0.1;
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
public CutParameters Cutting { get; init; } = CutParameters.Default;
}
+2
View File
@@ -2,6 +2,8 @@ namespace OpenNest.Api;
public class NestRequestPart
{
/// <summary>Optional stable requirement identity. NestRunner derives part-{requestIndex} when omitted.</summary>
public string Id { get; init; }
public string DxfPath { get; init; }
public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true;
+19
View File
@@ -0,0 +1,19 @@
using OpenNest.Geometry;
namespace OpenNest.Api;
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
public class NestRequestPlate
{
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
public double? Cost { get; init; }
public string Id { get; init; }
public Size Size { get; init; }
/// <summary>Available physical sheets; null means unlimited.</summary>
public int? Quantity { get; init; }
public double PartSpacing { get; init; }
public Spacing EdgeSpacing { get; init; }
public int Quadrant { get; init; } = 1;
}
+128 -30
View File
@@ -1,18 +1,59 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.IO;
namespace OpenNest.Api;
/// <summary>Stable fulfillment metadata for one requested part identity.</summary>
public sealed record NestPartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
/// <summary>Copies of one requested part identity the proposal leaves unplaced, and why.</summary>
public sealed record NestUnplacedRequestPart(string PartId, int Count, NestUnplacedReason Reason);
/// <summary>Physical-sheet usage for one stock identity.</summary>
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
/// <summary>Maps each materialized layout (Nest.Plates index) to its stock identity.
/// Its Plate.Quantity gives the physical copies; schema 3 and older used one sheet per entry.</summary>
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
public class NestResponse
{
public const int CurrentSchemaVersion = 5;
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Physical sheets: sum of Plate.Quantity, not materialized layout entries.</summary>
public int SheetCount { get; init; }
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
public double Utilization { get; init; }
public TimeSpan CutTime { get; init; }
public TimeSpan Elapsed { get; init; }
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; }
/// <summary>Fulfillment status is not geometry acceptance. Review this status before using Nest.</summary>
public NestValidationStatus? ValidationStatus { get; init; }
public IReadOnlyList<string> Violations { get; init; } = [];
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
/// <summary>Every unplaced copy by requested part and reason; empty when everything was
/// placed. Archives before schema 5 did not record it and load empty.</summary>
public IReadOnlyList<NestUnplacedRequestPart> Unplaced { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
public NestCostSummary Costs { get; init; }
public Nest Nest { get; init; }
public NestRequest Request { get; init; }
@@ -20,7 +61,8 @@ public class NestResponse
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
IncludeFields = true // Required for OpenNest.Geometry.Size (public fields)
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
Converters = { new JsonStringEnumConverter() },
};
public async Task SaveAsync(string path)
@@ -28,32 +70,46 @@ public class NestResponse
using var fs = new FileStream(path, FileMode.Create);
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
// Write request.json
var requestEntry = zip.CreateEntry("request.json");
await using (var stream = requestEntry.Open())
{
await JsonSerializer.SerializeAsync(stream, Request, JsonOptions);
}
// Write response.json (metrics only)
var metrics = new
{
SheetCount,
Utilization,
CutTimeTicks = CutTime.Ticks,
ElapsedTicks = Elapsed.Ticks
};
// Keep persisted data versioned and detached from the live mutable Nest graph.
var responseEntry = zip.CreateEntry("response.json");
await using (var stream = responseEntry.Open())
{
await JsonSerializer.SerializeAsync(stream, metrics, JsonOptions);
await JsonSerializer.SerializeAsync(
stream,
new NestResponseArchiveDto
{
SchemaVersion = CurrentSchemaVersion,
SheetCount = SheetCount,
Utilization = Utilization,
CutTimeTicks = CutTime.Ticks,
ElapsedTicks = Elapsed.Ticks,
Status = Status,
StopReason = StopReason,
ValidationStatus = ValidationStatus,
Violations = Violations is null ? [] : new List<string>(Violations),
Fulfillment = Fulfillment is null
? []
: new List<NestPartFulfillment>(Fulfillment),
Unplaced = Unplaced is null ? [] : new List<NestUnplacedRequestPart>(Unplaced),
Costs = Costs,
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
PlateStockMappings = PlateStockMappings is null
? []
: new List<NestPlateStockMapping>(PlateStockMappings),
},
JsonOptions
);
}
// Write embedded nest.nest via NestWriter → MemoryStream → ZIP entry
var nestEntry = zip.CreateEntry("nest.nest");
using var nestMs = new MemoryStream();
var writer = new NestWriter(Nest);
writer.Write(nestMs);
new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(nestMs);
nestMs.Position = 0;
await using (var stream = nestEntry.Open())
{
@@ -66,26 +122,41 @@ public class NestResponse
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
// Read request.json
var requestEntry = zip.GetEntry("request.json")
var requestEntry =
zip.GetEntry("request.json")
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
NestRequest request;
await using (var stream = requestEntry.Open())
{
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions);
request =
await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
?? throw new InvalidOperationException("Invalid request.json in .nestquote file");
}
// Read response.json
var responseEntry = zip.GetEntry("response.json")
var responseEntry =
zip.GetEntry("response.json")
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
JsonElement metricsJson;
NestResponseArchiveDto archive;
var hasSchemaVersion = false;
var hasStatusMetadata = false;
await using (var stream = responseEntry.Open())
using (var document = await JsonDocument.ParseAsync(stream))
{
metricsJson = await JsonSerializer.DeserializeAsync<JsonElement>(stream, JsonOptions);
var root = document.RootElement;
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
hasStatusMetadata =
root.TryGetProperty("status", out _)
|| root.TryGetProperty("stopReason", out _)
|| root.TryGetProperty("fulfillment", out _)
|| root.TryGetProperty("stockUsage", out _)
|| root.TryGetProperty("plateStockMappings", out _);
archive =
root.Deserialize<NestResponseArchiveDto>(JsonOptions)
?? throw new InvalidOperationException("Invalid response.json in .nestquote file");
}
// Read embedded nest.nest via NestReader(Stream)
var nestEntry = zip.GetEntry("nest.nest")
var nestEntry =
zip.GetEntry("nest.nest")
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
Nest nest;
using (var nestMs = new MemoryStream())
@@ -95,18 +166,45 @@ public class NestResponse
await stream.CopyToAsync(nestMs);
}
nestMs.Position = 0;
var reader = new NestReader(nestMs);
nest = reader.Read();
nest = new NestReader(nestMs) { BestFitStore = BestFitCacheStore.Shared }.Read();
}
return new NestResponse
{
SheetCount = metricsJson.GetProperty("sheetCount").GetInt32(),
Utilization = metricsJson.GetProperty("utilization").GetDouble(),
CutTime = TimeSpan.FromTicks(metricsJson.GetProperty("cutTimeTicks").GetInt64()),
Elapsed = TimeSpan.FromTicks(metricsJson.GetProperty("elapsedTicks").GetInt64()),
SchemaVersion = hasSchemaVersion ? archive.SchemaVersion : 0,
SheetCount = archive.SheetCount,
Utilization = archive.Utilization,
CutTime = TimeSpan.FromTicks(archive.CutTimeTicks),
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
Status = hasStatusMetadata ? archive.Status : null,
StopReason = hasStatusMetadata ? archive.StopReason : null,
ValidationStatus = archive.ValidationStatus,
Violations = archive.Violations ?? [],
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
Unplaced = hasSchemaVersion && archive.SchemaVersion >= 5 ? archive.Unplaced ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
Costs = archive.Costs,
Nest = nest,
Request = request
Request = request,
};
}
private sealed class NestResponseArchiveDto
{
public NestCostSummary Costs { get; init; }
public int SchemaVersion { get; init; }
public int SheetCount { get; init; }
public double Utilization { get; init; }
public long CutTimeTicks { get; init; }
public long ElapsedTicks { get; init; }
public NestJobStatus? Status { get; init; }
public NestJobStopReason? StopReason { get; init; }
public NestValidationStatus? ValidationStatus { get; init; }
public List<string> Violations { get; init; } = [];
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
public List<NestUnplacedRequestPart> Unplaced { get; init; } = [];
public List<NestStockUsage> StockUsage { get; init; } = [];
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
}
}
+229 -85
View File
@@ -5,127 +5,271 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.IO;
namespace OpenNest.Api;
public static class NestRunner
{
private const string LegacyStockId = "legacy-sheet";
public static Task<NestResponse> RunAsync(
NestRequest request,
IProgress<NestProgress> progress = null,
CancellationToken token = default)
CancellationToken token = default
)
{
if (request.Parts.Count == 0)
ArgumentNullException.ThrowIfNull(request);
var requestParts =
request.Parts
?? throw new ArgumentException("Request parts must not be null.", nameof(request));
if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request));
var stock = CreateStock(request);
var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts);
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
var items = new List<NestItem>(parts.Count);
// 1. Import DXFs → Drawings
var drawings = new List<Drawing>();
foreach (var part in request.Parts)
foreach (var part in parts)
{
if (!File.Exists(part.DxfPath))
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
token.ThrowIfCancellationRequested();
if (!File.Exists(part.Request.DxfPath))
throw new FileNotFoundException(
$"DXF file not found: {part.Request.DxfPath}",
part.Request.DxfPath
);
var geometry = Dxf.GetGeometry(part.DxfPath);
if (geometry.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
{
try
{
drawing = CadImporter.ImportDrawing(
part.Request.DxfPath,
new CadImportOptions { Quantity = part.Request.Quantity }
);
}
catch (Exception exception)
{
throw new InvalidOperationException(
$"Failed to import DXF: {part.Request.DxfPath}",
exception
);
}
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(name);
drawing.Program = pgm;
drawings.Add(drawing);
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException(
$"Failed to import DXF: {part.Request.DxfPath}"
);
// 2. Build NestItems
var items = new List<NestItem>();
for (var i = 0; i < request.Parts.Count; i++)
{
var part = request.Parts[i];
importedByPath.Add(part.Request.DxfPath, drawing);
}
// Each requirement keeps its own identity/constraints even when paths are shared.
var requirementDrawing = new Drawing(part.Id, drawing.Program);
ConfigureDrawingForRequirement(requirementDrawing, part.Request);
requirementDrawing.Quantity.Required = part.Request.Quantity;
items.Add(new NestItem
{
Drawing = drawings[i],
Quantity = part.Quantity,
Priority = part.Priority,
StepAngle = part.AllowRotation ? 0 : OpenNest.Math.Angle.TwoPI,
Drawing = requirementDrawing,
Quantity = part.Request.Quantity,
Priority = part.Request.Priority,
StepAngle = requirementDrawing.Constraints.StepAngle,
RotationStart = requirementDrawing.Constraints.StartAngle,
RotationEnd = requirementDrawing.Constraints.EndAngle,
});
}
// 3. Multi-plate loop
var nest = new Nest();
nest.Thickness = request.Thickness;
nest.Material = new Material(request.Material);
var remaining = items.Select(item => item.Quantity).ToList();
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = NestPipeline.Run(new NestPipelineRequest(
engineName, items, stock, new NestJobOptions(engineName)), jobProgress, token);
while (remaining.Any(q => q > 0))
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
// Invalid but representable proposals retain every pose and carry explicit validation status.
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
if (request.Plates != null)
nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
foreach (var item in items)
nest.Drawings.Add(item.Drawing);
foreach (var proposed in result.Plates)
{
token.ThrowIfCancellationRequested();
var plate = new Plate(request.SheetSize)
var plate = new Plate(proposed.Stock.Size)
{
PartSpacing = request.Spacing,
PartSpacing = proposed.Stock.PartSpacing,
EdgeSpacing = proposed.Stock.EdgeSpacing,
Quadrant = proposed.Stock.Quadrant,
Quantity = proposed.Quantity,
};
// Build items for this pass with remaining quantities
var passItems = new List<NestItem>();
for (var i = 0; i < items.Count; i++)
{
if (remaining[i] <= 0) continue;
passItems.Add(new NestItem
{
Drawing = items[i].Drawing,
Quantity = remaining[i],
Priority = items[i].Priority,
StepAngle = items[i].StepAngle,
});
}
// Run engine
var engine = NestEngineRegistry.Create(plate);
var parts = engine.Nest(passItems, progress, token);
if (parts.Count == 0)
break; // No progress — part doesn't fit on fresh sheet
// Add parts to plate and nest
foreach (var p in parts)
plate.Parts.Add(p);
plate.Parts.AddRange(proposed.Parts);
nest.Plates.Add(plate);
// Deduct placed quantities
foreach (var p in parts)
{
var idx = drawings.IndexOf(p.BaseDrawing);
if (idx >= 0)
remaining[idx]--;
}
}
// 4. Compute timing
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
// returned, bound placements and the original request, never plug-in summaries.
var counts = result.Plates.SelectMany(p => p.Parts.Select(part => (part.BaseDrawing, p.Quantity)))
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity));
var fulfillment = parts.Select((part, i) =>
{
var placed = counts.GetValueOrDefault(items[i].Drawing);
return new NestPartFulfillment(part.Id, part.Request.Quantity, placed,
System.Math.Max(0, part.Request.Quantity - placed));
}).ToArray();
var unplaced = result.Unplaced.Select(u => new NestUnplacedRequestPart(
parts[items.FindIndex(item => ReferenceEquals(item.Drawing, u.Drawing))].Id, u.Count, u.Reason)).ToArray();
var usage = stock.Select(s =>
{
var used = result.Plates.Where(p => p.Stock.Id == s.Id).Sum(p => p.Quantity);
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
}).ToArray();
var complete = fulfillment.All(f => f.Placed == f.Requested);
var stopReason = complete ? NestJobStopReason.Completed
: usage.All(u => u.Remaining == 0) ? NestJobStopReason.StockExhausted
: NestJobStopReason.NoPlacementFound;
var timingInfo = Timing.GetTimingInfo(nest);
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
sw.Stop();
// 5. Build response
var response = new NestResponse
return Task.FromResult(
new NestResponse
{
SheetCount = nest.Plates.Sum(p => p.Quantity),
Utilization = CalculateUtilization(nest),
CutTime = cutTime,
Elapsed = sw.Elapsed,
Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
StopReason = stopReason,
ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
Violations = result.Violations,
Fulfillment = fulfillment,
Unplaced = unplaced,
StockUsage = usage,
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
PlateStockMappings = result.Plates.Select((value, index) =>
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
Nest = nest,
Request = request,
}
);
}
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(
IReadOnlyList<NestRequestPart> requestParts
)
{
var identified = new List<IdentifiedRequestPart>(requestParts.Count);
var ids = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < requestParts.Count; index++)
{
SheetCount = nest.Plates.Count,
Utilization = nest.Plates.Count > 0
? nest.Plates.Average(p => p.Utilization())
: 0,
CutTime = cutTime,
Elapsed = sw.Elapsed,
Nest = nest,
Request = request
var part =
requestParts[index]
?? throw new ArgumentException(
"Request parts must not contain null entries.",
nameof(requestParts)
);
if (part.Quantity < 0)
throw new ArgumentException("Part quantities must be nonnegative.", nameof(requestParts));
var id = part.Id ?? $"part-{index}";
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
if (!ids.Add(id))
throw new ArgumentException("Part IDs must be unique.", nameof(requestParts));
identified.Add(new IdentifiedRequestPart(id, part));
}
return identified;
}
private static IReadOnlyList<NestPlateStock> CreateStock(NestRequest request)
{
if (request.Plates is null)
{
return
[
new NestPlateStock(
LegacyStockId,
request.SheetSize,
quantity: null,
partSpacing: request.Spacing
),
];
}
var stock = new List<NestPlateStock>(request.Plates.Count);
foreach (var plate in request.Plates)
{
if (plate is null)
throw new ArgumentException(
"Request plates must not contain null entries.",
nameof(request)
);
stock.Add(
new NestPlateStock(
plate.Id,
plate.Size,
plate.Quantity,
plate.PartSpacing,
plate.EdgeSpacing,
plate.Quadrant,
plate.Cost
)
);
}
NestJobValidator.ValidateStockCosts(stock);
return stock;
}
private static void ConfigureDrawingForRequirement(Drawing drawing, NestRequestPart part)
{
drawing.Priority = part.Priority;
drawing.Constraints ??= new NestConstraints();
if (!part.AllowRotation)
{
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
drawing.Constraints.StartAngle = 0;
drawing.Constraints.EndAngle = 0;
}
}
private static string ResolvePlacementStrategy(NestRequest request) =>
request.PlacementStrategy
?? request.Strategy switch
{
NestStrategy.Auto => "Default",
_ => throw new NotSupportedException(
$"Unknown legacy nesting strategy: {request.Strategy}."
),
};
return Task.FromResult(response);
private static double CalculateUtilization(Nest nest)
{
var sheetArea = nest.Plates.Sum(plate => plate.Area() * plate.Quantity);
if (sheetArea == 0)
return 0;
var placedArea = nest.Plates.Sum(plate =>
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) * plate.Quantity
);
return placedArea / sheetArea;
}
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
private sealed class JobProgressBridge(IProgress<NestProgress> progress)
: IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
ArgumentNullException.ThrowIfNull(value);
if (value.LegacyProgress is not null)
progress.Report(value.LegacyProgress);
}
}
}
+4 -1
View File
@@ -1,3 +1,6 @@
namespace OpenNest.Api;
public enum NestStrategy { Auto }
public enum NestStrategy
{
Auto,
}
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Api</RootNamespace>
<AssemblyName>OpenNest.Api</AssemblyName>
</PropertyGroup>
+94
View File
@@ -0,0 +1,94 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Benchmark
{
/// <summary>
/// One request to nest a specific drawing, with the quantity and rotation
/// constraints pulled from its source .nest file.
/// </summary>
public class DrawingRequest
{
public Drawing Drawing { get; init; }
public int Quantity { get; init; }
public int Priority { get; init; }
public double StepAngle { get; init; }
public double RotationStart { get; init; }
public double RotationEnd { get; init; }
}
/// <summary>
/// An immutable specification for one benchmark job: the full set of
/// drawings/quantities that must be nested, and the pool of sheet sizes the
/// engine may draw from while doing it. A single run may use several
/// plates - possibly of different sizes - to place everything, the same
/// way a real production job spreads across whatever plates it needs
/// rather than being handed one fixed-size sheet.
/// </summary>
public class BenchmarkJob
{
public string SourceFile { get; init; }
public List<Size> CandidateSizes { get; init; }
public Spacing EdgeSpacing { get; init; }
public double PartSpacing { get; init; }
public int Quadrant { get; init; }
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
public double SalvageRate { get; init; }
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
public List<DrawingRequest> Requests { get; init; }
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
/// <summary>Sheet area charged per unplaced part: the largest candidate
/// sheet. Any single part that fits the stock at all fits on one such
/// sheet, so placing a part is never scored worse than leaving it out.</summary>
public double UnplacedPartPenalty =>
CandidateSizes.Count == 0 ? 0 : CandidateSizes.Max(s => s.Width * s.Length);
/// <summary>
/// Builds the whole-job request this job represents: one NestJobPart per
/// requested drawing, and one NestPlateStock per candidate sheet size
/// (unlimited quantity - the engine under test decides how many of each
/// size it actually uses, and how demand splits across plates). The
/// engine owns its own multi-plate/size strategy; this harness no
/// longer picks plate sizes on the engine's behalf.
/// </summary>
public NestJob BuildNestJob(
int maxPlates,
double? salvageRate = null,
double? minimumSalvageDimension = null
)
{
var parts = Requests.Select(r =>
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
);
var stock = CandidateSizes.Select(size => new NestPlateStock(
size.ToString(1),
size,
null,
PartSpacing,
EdgeSpacing,
Quadrant
));
return new NestJob(
parts,
stock,
new NestJobOptions(
"Fill",
maxPlates,
salvageRate ?? SalvageRate,
minimumSalvageDimension ?? 0
)
);
}
}
}
+398
View File
@@ -0,0 +1,398 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Benchmark
{
/// <summary>
/// Runs every candidate engine against every job. Each engine is a full
/// INestingEngine: it owns its own plate/size selection and multi-plate
/// strategy for the whole job, rather than being handed one already-sized
/// plate at a time by this harness. A per-run timeout guards against a
/// runaway or hanging engine — cooperative cancellation, so it reliably
/// stops engines built on NestJobRunner (all four built-ins) but can't
/// forcibly interrupt an engine that never checks its token.
/// </summary>
public static class BenchmarkRunner
{
/// <summary>Physical-sheet cap passed to every job's NestJobOptions.MaxPlates.</summary>
private const int MaxPlates = 40;
/// <summary>Wall-clock budget for one engine solving one job.</summary>
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
public static List<JobResult> Run(
List<BenchmarkJob> jobs,
IReadOnlyList<NestingEngineInfo> engines,
double? salvageRate = null,
double? minimumSalvageDimension = null,
string outputDirectory = null,
int maxParallelism = 1,
System.IO.TextWriter progressLog = null
)
{
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
.ToList();
var results = new JobResult[pairs.Count];
var options = new ParallelOptions
{
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
};
var baselineResults = new JobResult[jobs.Count];
Parallel.ForEach(
Partitioner.Create(
Enumerable.Range(0, jobs.Count),
EnumerablePartitionerOptions.NoBuffering
),
options,
i => baselineResults[i] = RunBaseline(jobs[i], salvageRate, minimumSalvageDimension)
);
// NoBuffering hands out one pair at a time: solves run for seconds to minutes,
// so chunked partitioning would leave workers idle behind a slow engine.
Parallel.ForEach(
Partitioner.Create(
Enumerable.Range(0, pairs.Count),
EnumerablePartitionerOptions.NoBuffering
),
options,
i =>
results[i] = RunOne(
pairs[i].Job,
pairs[i].Engine,
salvageRate,
minimumSalvageDimension,
outputDirectory,
progressLog
)
);
// Indexed writes keep the report in job-then-engine order whatever finishes first.
var ordered = new List<JobResult>(
results.Length + baselineResults.Count(result => result != null)
);
for (var jobIndex = 0; jobIndex < jobs.Count; jobIndex++)
{
if (baselineResults[jobIndex] != null)
ordered.Add(baselineResults[jobIndex]);
var firstResult = jobIndex * engines.Count;
for (var engineIndex = 0; engineIndex < engines.Count; engineIndex++)
ordered.Add(results[firstResult + engineIndex]);
}
return ordered;
}
private static JobResult RunBaseline(
BenchmarkJob job,
double? salvageRate,
double? minimumSalvageDimension
)
{
if (job.BaselinePlateRuns == null)
return null;
var requested = job.TotalRequestedQuantity;
try
{
var requirements = job.Requests.ToDictionary<
DrawingRequest,
Drawing,
(string Name, int Quantity)
>(
request => request.Drawing,
request => (request.Drawing.Name, request.Quantity),
ReferenceEqualityComparer.Instance
);
var partIds = job.Requests.ToDictionary<DrawingRequest, Drawing, string>(
request => request.Drawing,
request => request.Drawing.Id.ToString(),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(job.BaselinePlateRuns, requirements);
var benchmarkJob = job.BuildNestJob(
MaxPlates,
salvageRate,
minimumSalvageDimension
);
var instanceIndices = new Dictionary<string, int>(StringComparer.Ordinal);
var plateResults = job
.BaselinePlateRuns.Select(
(run, index) =>
{
var stock = new NestPlateStock(
$"baseline-{index}",
run.Plate.Size,
1,
run.Plate.PartSpacing,
run.Plate.EdgeSpacing,
run.Plate.Quadrant
);
var placements = run
.Parts.Select(part =>
{
var partId = partIds[part.BaseDrawing];
instanceIndices.TryGetValue(partId, out var instanceIndex);
instanceIndices[partId] = instanceIndex + 1;
return new NestJobPlacement(
partId,
instanceIndex,
part.Location.X,
part.Location.Y,
part.Rotation
);
})
.ToList();
return new NestJobPlateResult(index, stock, placements);
}
)
.ToList();
var baselineJob = new NestJob(
benchmarkJob.Parts,
plateResults.Select(result => result.Stock),
benchmarkJob.Options
);
var baselineJobResult = new NestJobResult(
NestJobStatus.Complete,
NestJobStopReason.Completed,
plateResults,
Array.Empty<PartFulfillment>(),
Array.Empty<StockUsage>()
);
NestValidator.ValidateAgainstJob(
baselineJob,
baselineJobResult,
job.Requests.ToDictionary(
request => request.Drawing.Id.ToString(),
request => request.Drawing.Name
),
validation
);
var plateRuns = job.BaselinePlateRuns;
var placedArea = validation.Valid
? plateRuns.Sum(run => run.Parts.Sum(part => part.BaseDrawing.Area))
: 0;
var plateArea = plateRuns.Sum(run => run.Plate.Area());
var netSheetArea = validation.Valid
? plateResults.Sum(result =>
NestJobCost.NetSheetArea(baselineJob, result)
)
: 0;
var sizeBreakdown = plateRuns
.GroupBy(run => run.Plate.Size.ToString(1))
.OrderByDescending(group => group.Count())
.ToDictionary(group => group.Key, group => group.Count());
return new JobResult
{
EngineName = "Baseline",
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = plateRuns.Sum(run => run.Parts.Count),
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
NetSheetArea = netSheetArea,
UnplacedPartPenalty = job.UnplacedPartPenalty,
PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = 0,
};
}
catch (Exception ex)
{
return new JobResult
{
EngineName = "Baseline",
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
Error = $"{ex.GetType().Name}: {ex.Message}",
};
}
}
private static JobResult RunOne(
BenchmarkJob job,
NestingEngineInfo engineInfo,
double? salvageRate,
double? minimumSalvageDimension,
string outputDirectory,
System.IO.TextWriter progressLog
)
{
var requested = job.TotalRequestedQuantity;
var log = progressLog == null
? null
: new JobProgressLog(progressLog, $"{job.Name}/{engineInfo.Name}");
log?.Started();
var sw = Stopwatch.StartNew();
try
{
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(SolveTimeout);
var jobResult = engine.Solve(nestJob, log, cts.Token);
log?.Finished(jobResult, sw.ElapsedMilliseconds);
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
var plateRuns = materialized
.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
.ToList();
var requirements = job.Requests.ToDictionary<
DrawingRequest,
Drawing,
(string Name, int Quantity)
>(
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
r => (r.Drawing.Name, r.Quantity),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(plateRuns, requirements);
NestValidator.ValidateAgainstJob(
nestJob,
jobResult,
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing.Name),
validation
);
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
var placedArea = validation.Valid
? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area))
: 0;
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
// Salvage credit is recomputed from the job's own geometry, never taken from the engine.
var netSheetArea = validation.Valid
? jobResult.Plates.Sum(p =>
NestJobCost.NetSheetArea(nestJob, p)
)
: 0;
var sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
if (validation.Valid && outputDirectory != null)
{
System.IO.Directory.CreateDirectory(outputDirectory);
// Keep names and job metadata for a useful inspectable output; never modify source.
// Manifest jobs have no source nest to copy from, so they keep the job's name.
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
{
var source = new OpenNest.IO.NestReader(job.SourceFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
materialized.Nest.Name = source.Name;
materialized.Nest.Units = source.Units;
materialized.Nest.Material = source.Material;
materialized.Nest.Thickness = source.Thickness;
}
else
{
materialized.Nest.Name = job.Name;
}
materialized.Nest.SalvageRate = nestJob.Options.SalvageRate;
foreach (var request in job.Requests)
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request
.Drawing
.Name;
var path = System.IO.Path.Combine(
outputDirectory,
$"{job.Name}-{engineInfo.Name}.nest"
);
if (
System.IO.Path.GetFullPath(path)
== System.IO.Path.GetFullPath(job.SourceFile)
)
throw new InvalidOperationException(
"Output must not overwrite the source nest."
);
new OpenNest.IO.NestWriter(materialized.Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(path);
var report = new
{
Source = job.SourceFile,
Engine = engineInfo.Name,
jobResult.Status,
jobResult.StopReason,
Requested = requested,
Placed = totalPlaced,
SheetArea = plateArea,
PlacedArea = placedArea,
SalvageRate = nestJob.Options.SalvageRate,
MinimumSalvageDimension = nestJob.Options.MinimumSalvageDimension,
EstimatedNetArea = netSheetArea,
Fulfillment = jobResult.Fulfillment,
StockUsage = jobResult.StockUsage,
Plates = jobResult.Plates,
validation.Violations,
};
System.IO.File.WriteAllText(
System.IO.Path.ChangeExtension(path, ".json"),
System.Text.Json.JsonSerializer.Serialize(
report,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }
)
);
}
sw.Stop();
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = validation.Valid,
Violations = validation.Violations,
PartsPlaced = totalPlaced,
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
NetSheetArea = netSheetArea,
UnplacedPartPenalty = job.UnplacedPartPenalty,
PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = sw.ElapsedMilliseconds,
};
}
catch (OperationCanceledException)
{
sw.Stop();
log?.Failed("timed out", sw.ElapsedMilliseconds);
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
};
}
catch (Exception ex)
{
sw.Stop();
log?.Failed($"{ex.GetType().Name}: {ex.Message}", sw.ElapsedMilliseconds);
return new JobResult
{
EngineName = engineInfo.Name,
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"{ex.GetType().Name}: {ex.Message}",
};
}
}
}
}
+175
View File
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Benchmark
{
/// <summary>
/// Builds a BenchmarkJob from a JSON manifest that lists DXF files and the
/// quantity of each to nest. DXF paths resolve relative to the manifest.
/// Sheet sizes come from the manifest or the caller's override; unlike a
/// .nest file there is no plate to inherit them from, so a job with none is
/// an error. Sheet sizes must use the same units as the DXFs.
/// </summary>
public static class DxfManifestLoader
{
/// <summary>Suffix a manifest needs to be picked up when scanning a folder.</summary>
public const string FolderSuffix = ".manifest.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static BenchmarkJob Load(
string manifestPath,
IReadOnlyList<Size> sheetSizeOverrides = null,
double? partSpacingOverride = null
)
{
var manifest = ReadManifest(manifestPath);
var baseDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath));
if (manifest.Parts == null || manifest.Parts.Count == 0)
throw new InvalidOperationException(
$"Manifest '{manifestPath}' has no parts. Add entries to \"parts\"."
);
var sizes = ResolveSheetSizes(manifest, sheetSizeOverrides, manifestPath);
var requests = manifest.Parts.Select(p => BuildRequest(p, baseDir)).ToList();
return new BenchmarkJob
{
SourceFile = manifestPath,
CandidateSizes = sizes,
EdgeSpacing = new Spacing(manifest.EdgeSpacing, manifest.EdgeSpacing),
PartSpacing = partSpacingOverride ?? manifest.Spacing,
Quadrant = manifest.Quadrant,
Requests = requests,
};
}
private static Manifest ReadManifest(string manifestPath)
{
try
{
return JsonSerializer.Deserialize<Manifest>(
File.ReadAllText(manifestPath),
JsonOptions
) ?? throw new InvalidOperationException("The manifest is empty.");
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"Manifest '{manifestPath}' is not valid JSON: {ex.Message}",
ex
);
}
}
private static List<Size> ResolveSheetSizes(
Manifest manifest,
IReadOnlyList<Size> overrides,
string manifestPath
)
{
if (overrides != null && overrides.Count > 0)
return overrides.ToList();
var sizes = new List<Size>();
foreach (var text in manifest.SheetSizes ?? new List<string>())
{
if (!JobLoader.TryParseSheetSize(text, out var size))
throw new InvalidOperationException(
$"Manifest '{manifestPath}': could not parse sheet size '{text}' (expected e.g. \"48x96\")."
);
sizes.Add(size);
}
if (sizes.Count == 0)
throw new InvalidOperationException(
$"Manifest '{manifestPath}' has no sheet sizes. Set \"sheetSizes\" or pass --sheet-sizes."
);
return sizes.Distinct().ToList();
}
private static DrawingRequest BuildRequest(ManifestPart part, string baseDir)
{
if (string.IsNullOrWhiteSpace(part.Dxf))
throw new InvalidOperationException("A manifest part is missing \"dxf\".");
if (part.Quantity <= 0)
throw new InvalidOperationException(
$"Manifest part '{part.Dxf}': quantity must be greater than 0 (was {part.Quantity})."
);
var dxfPath = Path.GetFullPath(Path.Combine(baseDir, part.Dxf));
if (!File.Exists(dxfPath))
throw new FileNotFoundException($"DXF file not found: {dxfPath}", dxfPath);
Drawing drawing;
try
{
drawing = CadImporter.ImportDrawing(
dxfPath,
new CadImportOptions { Quantity = part.Quantity }
);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}", ex);
}
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
if (!part.AllowRotation)
{
drawing.Constraints ??= new NestConstraints();
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
drawing.Constraints.StartAngle = 0;
drawing.Constraints.EndAngle = 0;
}
var constraints = drawing.Constraints;
return new DrawingRequest
{
Drawing = drawing,
Quantity = part.Quantity,
Priority = drawing.Priority,
StepAngle = constraints?.StepAngle ?? 0,
RotationStart = constraints?.StartAngle ?? 0,
RotationEnd = constraints?.EndAngle ?? 0,
};
}
private class Manifest
{
public List<string> SheetSizes { get; set; }
public double Spacing { get; set; }
public double EdgeSpacing { get; set; }
public int Quadrant { get; set; } = 1;
public List<ManifestPart> Parts { get; set; }
}
private class ManifestPart
{
public string Dxf { get; set; }
public int Quantity { get; set; }
public bool AllowRotation { get; set; } = true;
}
}
}
+217
View File
@@ -0,0 +1,217 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Benchmark
{
/// <summary>
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
/// any valid .nest file, using whatever drawings/quantities/plate settings
/// it contains. One job per file, carrying the full pool of candidate
/// sheet sizes the engine may use across the whole nest - by default the
/// distinct sizes already present in that file, or a fixed override list
/// (e.g. a standard sheet-size lineup) applied to every file.
/// </summary>
public static class JobLoader
{
public static List<BenchmarkJob> Load(
string inputPath,
IReadOnlyList<Size> sheetSizeOverrides = null,
double? partSpacingOverride = null
)
{
var files = ResolveFiles(inputPath);
var jobs = new List<BenchmarkJob>();
foreach (var file in files)
{
if (file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
// Hand-written manifests fail loudly rather than being skipped like unreadable .nest files.
jobs.Add(DxfManifestLoader.Load(file, sheetSizeOverrides, partSpacingOverride));
continue;
}
Nest nest;
try
{
nest = new NestReader(file) { BestFitStore = BestFitCacheStore.Shared }.Read();
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"[JobLoader] Skipping '{file}': failed to read ({ex.Message})"
);
continue;
}
var requests = BuildRequests(nest);
if (requests.Count == 0)
{
Console.Error.WriteLine(
$"[JobLoader] Skipping '{file}': no drawings with quantity > 0"
);
continue;
}
var template = ResolvePlateTemplate(nest);
var sizes =
sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
? sheetSizeOverrides.ToList()
: ResolveSheetSizes(nest);
jobs.Add(
new BenchmarkJob
{
SourceFile = file,
CandidateSizes = sizes,
EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant,
SalvageRate = nest.SalvageRate,
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
Requests = requests,
}
);
}
return jobs;
}
/// <summary>Parses "WxL" with invariant-culture numbers, so "48.5x96" means the
/// same thing on every machine (Size.Parse follows the current culture).</summary>
public static bool TryParseSheetSize(string text, out Size size)
{
size = default;
var dims = text?.Split('x', 'X');
if (dims == null || dims.Length != 2)
return false;
var style = System.Globalization.NumberStyles.Float;
var culture = System.Globalization.CultureInfo.InvariantCulture;
if (
!double.TryParse(dims[0].Trim(), style, culture, out var width)
|| !double.TryParse(dims[1].Trim(), style, culture, out var length)
)
return false;
size = new Size(width, length);
return true;
}
private static List<string> ResolveFiles(string inputPath)
{
if (Directory.Exists(inputPath))
{
return Directory
.EnumerateFiles(inputPath, "*", SearchOption.AllDirectories)
.Where(f =>
f.EndsWith(".nest", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(
DxfManifestLoader.FolderSuffix,
StringComparison.OrdinalIgnoreCase
)
)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
}
if (File.Exists(inputPath))
return new List<string> { inputPath };
throw new FileNotFoundException($"Benchmark input not found: {inputPath}");
}
private static List<DrawingRequest> BuildRequests(Nest nest)
{
var requests = new List<DrawingRequest>();
foreach (var drawing in nest.Drawings)
{
var qty = drawing.Quantity.Required;
if (qty <= 0)
continue;
var constraints = drawing.Constraints;
requests.Add(
new DrawingRequest
{
Drawing = drawing,
Quantity = qty,
Priority = drawing.Priority,
StepAngle = constraints?.StepAngle ?? 0,
RotationStart = constraints?.StartAngle ?? 0,
RotationEnd = constraints?.EndAngle ?? 0,
}
);
}
return requests;
}
private static List<(Plate Plate, List<Part> Parts)> BuildBaselinePlateRuns(
Nest nest,
double? partSpacingOverride
)
{
var runs = new List<(Plate Plate, List<Part> Parts)>();
foreach (var plate in nest.Plates ?? Enumerable.Empty<Plate>())
{
var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
if (parts.Count == 0)
continue;
var validationPlate = new Plate(new Size(plate.Size.Width, plate.Size.Length))
{
Quantity = 1,
Quadrant = plate.Quadrant,
PartSpacing = partSpacingOverride ?? plate.PartSpacing,
EdgeSpacing = new Spacing(
plate.EdgeSpacing.Left,
plate.EdgeSpacing.Bottom,
plate.EdgeSpacing.Right,
plate.EdgeSpacing.Top
),
};
for (var copy = 0; copy < plate.Quantity; copy++)
runs.Add((validationPlate, parts));
}
return runs.Count > 0 ? runs : null;
}
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
Nest nest
)
{
var source = nest.Plates?.FirstOrDefault();
if (source != null)
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
var defaults = nest.PlateDefaults;
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
}
private static List<Size> ResolveSheetSizes(Nest nest)
{
var sizes = (nest.Plates ?? Enumerable.Empty<Plate>())
.Select(p => p.Size)
.Distinct()
.ToList();
if (sizes.Count == 0)
sizes.Add(nest.PlateDefaults.Size);
return sizes;
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using System;
using System.Diagnostics;
using System.IO;
using OpenNest.Engine.Jobs;
namespace OpenNest.Benchmark
{
/// <summary>
/// Writes one solve's NestJobProgress as log lines prefixed with "[job/engine]". Every
/// PlateCommitted is written; EvaluatingCandidate is throttled to one line per interval so a
/// chatty engine cannot flood the console. One instance per solve; Report is thread-safe.
/// </summary>
public sealed class JobProgressLog : IProgress<NestJobProgress>
{
public static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(2);
private readonly TextWriter writer;
private readonly string label;
private readonly TimeSpan interval;
private readonly Func<TimeSpan> clock;
private readonly object sync = new();
private TimeSpan? lastEvaluating;
public JobProgressLog(
TextWriter writer,
string label,
TimeSpan? interval = null,
Func<TimeSpan> clock = null
)
{
this.writer = writer ?? throw new ArgumentNullException(nameof(writer));
this.label = label;
this.interval = interval ?? DefaultInterval;
if (clock == null)
{
var stopwatch = Stopwatch.StartNew();
clock = () => stopwatch.Elapsed;
}
this.clock = clock;
}
public void Started() => Write("started");
public void Finished(NestJobResult result, long elapsedMs) =>
Write(
$"finished in {elapsedMs} ms: {result.Status} ({result.StopReason}), "
+ $"{result.Plates.Count} plate(s)"
);
public void Failed(string error, long elapsedMs) =>
Write($"failed after {elapsedMs} ms: {error}");
public void Report(NestJobProgress value)
{
if (value == null)
return;
if (value.Stage == NestJobStage.PlateCommitted)
{
Write(
$"committed plate {value.CommittedPlates} on stock {value.StockId} "
+ $"({value.CommittedParts} parts placed)"
);
return;
}
lock (sync)
{
var now = clock();
if (lastEvaluating.HasValue && now - lastEvaluating.Value < interval)
return;
lastEvaluating = now;
}
var plate = value.PlateIndex >= 0 ? value.PlateIndex + 1 : value.CommittedPlates + 1;
Write(
$"evaluating plate {plate} on stock {value.StockId} "
+ $"({value.CommittedPlates} plate(s), {value.CommittedParts} parts committed)"
);
}
private void Write(string message)
{
lock (sync)
writer.WriteLine($"[{label}] {message}");
}
}
}
+63
View File
@@ -0,0 +1,63 @@
using System.Collections.Generic;
namespace OpenNest.Benchmark
{
/// <summary>
/// Outcome of running one engine against one job. A job may span several
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
/// one sheet - possibly of different sizes - to place everything asked of
/// it. An invalid or crashed run places nothing as far as scoring is
/// concerned: it earns no area and pays the unplaced penalty on every
/// requested part.
/// </summary>
public class JobResult
{
public string EngineName { get; init; }
public string JobName { get; init; }
public bool Valid { get; init; }
public List<string> Violations { get; init; } = new();
public string Error { get; init; }
public int PartsPlaced { get; init; }
public int PartsRequested { get; init; }
public double PlacedArea { get; init; }
public double PlateArea { get; init; }
/// <summary>Sheet area consumed after crediting salvageable offcuts
/// (NestJobCost.NetSheetArea summed over every plate).
/// Equals PlateArea when salvage credit is disabled.</summary>
public double NetSheetArea { get; init; }
/// <summary>Sheet area charged for each requested part that was not
/// placed: the largest candidate sheet's area, so leaving a part out
/// always costs at least as much as the extra sheet it would need.</summary>
public double UnplacedPartPenalty { get; init; }
public int PlatesUsed { get; init; }
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
public long ElapsedMs { get; init; }
public bool Crashed => Error != null;
public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested;
/// <summary>Aggregate utilization across every plate the engine used:
/// total placed drawing area over total plate area, matching
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
/// <summary>Placed area over salvage-credited sheet area.</summary>
public double NetUtilization =>
Valid && NetSheetArea > 0 ? PlacedArea / NetSheetArea : 0;
public int PartsUnplaced =>
Valid ? System.Math.Max(0, PartsRequested - PartsPlaced) : PartsRequested;
/// <summary>
/// The ranking score, in sheet area (lower is better): net sheet area
/// consumed plus the unplaced penalty. An engine cannot improve it by
/// dropping awkward parts, and it sums honestly across jobs of
/// different sizes. Invalid runs consume no sheet but pay the penalty
/// on every requested part.
/// </summary>
public double Cost => (Valid ? NetSheetArea : 0) + PartsUnplaced * UnplacedPartPenalty;
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using OpenNest.Engine.Jobs;
namespace OpenNest.Benchmark;
/// <summary>Benchmark validation outcome.</summary>
public class ValidationResult
{
public bool Valid => Violations.Count == 0;
public List<string> Violations { get; } = new();
}
/// <summary>Compatibility wrapper over the shared layout validation contract.</summary>
public static class NestValidator
{
/// <summary>Checks materialized plates using drawing-reference requirement identity.</summary>
public static ValidationResult Validate(
List<(Plate Plate, List<Part> Parts)> plateRuns,
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
{
var result = new ValidationResult();
result.Violations.AddRange(NestLayoutCheck.Validate(plateRuns, requirements));
return result;
}
/// <summary>Appends offered-stock, finite-stock and rotation-policy violations.</summary>
public static void ValidateAgainstJob(NestJob job, NestJobResult jobResult,
IReadOnlyDictionary<string, string> displayNames, ValidationResult result) =>
NestLayoutCheck.ValidateAgainstJob(job, jobResult, displayNames, result.Violations);
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Benchmark</RootNamespace>
<AssemblyName>OpenNest.Benchmark</AssemblyName>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+349
View File
@@ -0,0 +1,349 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.Benchmark;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
return BenchmarkConsole.Run(args);
static class BenchmarkConsole
{
public static int Run(string[] args)
{
var options = ParseArgs(args);
if (options == null)
return 0; // --help was requested
if (options.InputPath == null)
{
PrintUsage();
return 1;
}
List<BenchmarkJob> jobs;
try
{
jobs = JobLoader.Load(options.InputPath, options.SheetSizes, options.PartSpacing);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
if (jobs.Count == 0)
{
Console.Error.WriteLine(
"No benchmark jobs found (no .nest files with any drawing quantity > 0, or *.manifest.json files)."
);
return 1;
}
var enginesDir = Path.Combine(AppContext.BaseDirectory, "Engines");
NestingEngineRegistry.LoadPlugins(enginesDir);
var engines = NestingEngineRegistry.AvailableEngines;
if (options.EngineNames.Count > 0)
{
engines = engines
.Where(e =>
options.EngineNames.Any(n =>
n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)
)
)
.ToList();
if (engines.Count == 0)
{
Console.Error.WriteLine(
"None of the requested engines are registered. Available: "
+ string.Join(
", ",
NestingEngineRegistry.AvailableEngines.Select(e => e.Name)
)
);
return 1;
}
}
Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'");
foreach (var job in jobs)
{
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
Console.WriteLine(
$" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}"
);
}
if (
options.SheetSizes.Count == 0
&& jobs.Any(j => j.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
)
{
Console.Error.WriteLine(
"Warning: no --sheet-sizes given, so each .nest job only offers the sheet sizes its "
+ "original layout used - a hint toward that answer. Pass --sheet-sizes with the "
+ "sizes you actually stock for an unbiased comparison."
);
}
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
var effectiveSalvageRates = jobs.Select(job => options.SalvageRate ?? job.SalvageRate);
if (
effectiveSalvageRates.Any(rate => rate > 0)
&& (options.MinimumSalvageDimension ?? 0) <= 0
)
{
Console.Error.WriteLine(
"Warning: salvage credit is disabled because --min-salvage-dimension was not set to a positive value."
);
}
var solves = jobs.Count * engines.Count;
if (options.Parallel > 1 && solves > 1)
{
Console.WriteLine(
$"Running up to {options.Parallel} solves at a time; Time(ms) is measured under that "
+ "concurrent load. Use --parallel 1 for strictly isolated timings."
);
}
var results = BenchmarkRunner.Run(
jobs,
engines,
options.SalvageRate,
options.MinimumSalvageDimension,
options.OutputDirectory,
options.Parallel,
options.Progress ? Console.Out : null
);
Report.PrintDetailed(results);
Report.PrintSummary(results);
if (options.CsvPath != null)
{
Report.WriteCsv(options.CsvPath, results);
Console.WriteLine();
Console.WriteLine($"Wrote CSV report to {options.CsvPath}");
}
return 0;
}
private static Options ParseArgs(string[] args)
{
var o = new Options();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--sheet-sizes" when i + 1 < args.Length:
o.SheetSizes = ParseSheetSizes(args[++i]);
break;
case "--spacing" when i + 1 < args.Length:
o.PartSpacing = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--engines" when i + 1 < args.Length:
o.EngineNames = args[++i]
.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
.ToList();
break;
case "--csv" when i + 1 < args.Length:
o.CsvPath = args[++i];
break;
case "--salvage-rate" when i + 1 < args.Length:
o.SalvageRate = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--min-salvage-dimension" when i + 1 < args.Length:
o.MinimumSalvageDimension = double.Parse(
args[++i],
System.Globalization.CultureInfo.InvariantCulture
);
break;
case "--output" when i + 1 < args.Length:
o.OutputDirectory = args[++i];
break;
case "--parallel" when i + 1 < args.Length:
if (int.TryParse(args[++i], out var parallel) && parallel >= 1)
o.Parallel = parallel;
else
Console.Error.WriteLine(
$"Warning: --parallel needs a whole number >= 1, using {o.Parallel}"
);
break;
case "--progress":
o.Progress = true;
break;
case "--help":
PrintUsage();
return null;
default:
if (!args[i].StartsWith("--"))
o.InputPath = args[i];
break;
}
}
return o;
}
private static List<Size> ParseSheetSizes(string arg)
{
var sizes = new List<Size>();
foreach (
var token in arg.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
)
)
{
if (JobLoader.TryParseSheetSize(token, out var size))
sizes.Add(size);
else
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
}
return sizes.Distinct().ToList();
}
private static void PrintUsage()
{
Console.Error.WriteLine(
"OpenNest.Benchmark - compare registered whole-job nesting engines on a set of .nest files"
);
Console.Error.WriteLine();
Console.Error.WriteLine(
"For each .nest file, every drawing with quantity > 0 is nested (mixed together),"
);
Console.Error.WriteLine(
"once per registered INestingEngine. Each engine is handed the full job - every"
);
Console.Error.WriteLine(
"requested part and the whole pool of candidate sheet sizes - and owns its own"
);
Console.Error.WriteLine(
"multi-plate/size strategy: how many plates it uses, of which sizes, and how"
);
Console.Error.WriteLine(
"demand splits across them. Ranking: a run that places every requested part beats"
);
Console.Error.WriteLine(
"one that does not; then lower cost = sheet area consumed (minus salvage credit for a"
);
Console.Error.WriteLine(
"usable offcut) + the largest candidate sheet's area per unplaced part; then fewer"
);
Console.Error.WriteLine(
"plates. An invalid layout (out of bounds, overlapping, over-quantity, off-stock, or"
);
Console.Error.WriteLine(
"breaking a rotation constraint), a thrown exception, or a timeout places nothing."
);
Console.Error.WriteLine();
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(
" OpenNest.Benchmark <file.nest | manifest.json | folder> [options]"
);
Console.Error.WriteLine();
Console.Error.WriteLine(
"A manifest.json builds a job straight from DXF files (paths relative to the manifest):"
);
Console.Error.WriteLine(
" { \"sheetSizes\": [\"48x96\"], \"spacing\": 0.25, \"edgeSpacing\": 0.25, \"quadrant\": 1,"
);
Console.Error.WriteLine(
" \"parts\": [ { \"dxf\": \"a.dxf\", \"quantity\": 12 }, { \"dxf\": \"b.dxf\", \"quantity\": 4, \"allowRotation\": false } ] }"
);
Console.Error.WriteLine(
"Sheet sizes must use the same units as the DXFs. A folder is scanned for *.nest and"
);
Console.Error.WriteLine(
"*.manifest.json files. --sheet-sizes and --spacing override the manifest."
);
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(
" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest"
);
Console.Error.WriteLine(
" (default: the distinct sizes already in each file,"
);
Console.Error.WriteLine(
" which hints engines toward the original layout)"
);
Console.Error.WriteLine(
" --spacing <value> Override part spacing for every job"
);
Console.Error.WriteLine(
" --engines Name1,Name2,... Only benchmark these registered engines (default: all)"
);
Console.Error.WriteLine(
" --csv <path> Write a flat CSV of all results"
);
Console.Error.WriteLine(
" --salvage-rate <0..1> Fraction of eligible offcut area credited (default: saved .nest rate;"
);
Console.Error.WriteLine(
" manifests 0; needs positive --min-salvage-dimension)"
);
Console.Error.WriteLine(
" --min-salvage-dimension <value> Both offcut dimensions must qualify; positive value enables credit (default 0)"
);
Console.Error.WriteLine(
" --output <directory> Save valid layouts as .nest plus detailed JSON reports"
);
Console.Error.WriteLine(
" --parallel <n> Solves to run at once (default 3; 1 = strictly sequential,"
);
Console.Error.WriteLine(
" which gives the cleanest per-engine timings)"
);
Console.Error.WriteLine(
" --progress Log each solve's start, engine progress and finish"
);
Console.Error.WriteLine(" --help Show this message");
}
private class Options
{
public string InputPath;
public List<Size> SheetSizes = new();
public double? PartSpacing;
public List<string> EngineNames = new();
public string CsvPath;
public string OutputDirectory;
public double? SalvageRate;
public double? MinimumSalvageDimension;
public int Parallel = 3;
public bool Progress;
}
}
+203
View File
@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenNest.Benchmark
{
/// <summary>
/// Console + CSV reporting for benchmark results. Ranking rule per job:
/// valid beats invalid; placing every requested part beats not; then lower
/// JobResult.Cost wins - salvage-credited sheet area consumed plus a
/// largest-sheet penalty per unplaced part, so dropping awkward parts can
/// never buy a better score; then fewer plates. Ties beyond that are a
/// shared win. Across jobs, costs and areas are summed (not averaged), so
/// a big job weighs more than a three-part one.
/// </summary>
public static class Report
{
private const double Epsilon = 1e-6;
public static void PrintDetailed(List<JobResult> results)
{
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
Console.WriteLine();
Console.WriteLine($"=== {jobGroup.Key} ===");
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
var best = ranked.Count > 0 ? ranked[0] : null;
Console.WriteLine(
$"{"Engine", -16} {"Result", -9} {"Parts", -10} {"Util%", -7} {"Net%", -7} {"Cost", -12} {"Plates", -18} {"Time(ms)", -9} Notes"
);
foreach (var r in ranked)
{
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
var marker = isWinner ? "*" : " ";
var status =
r.Crashed ? "CRASH"
: r.Valid ? "ok"
: "INVALID";
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
var netCol = r.Valid ? $"{r.NetUtilization * 100:F1}" : "-";
var platesCol =
r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
Console.WriteLine(
$"{marker}{r.EngineName, -15} {status, -9} {partsCol, -10} {utilCol, -7} {netCol, -7} {r.Cost, -12:F1} {platesCol, -18} {r.ElapsedMs, -9} {notes}"
);
}
}
}
public static void PrintSummary(List<JobResult> results)
{
Console.WriteLine();
Console.WriteLine("=== Summary ===");
var byEngine = results
.GroupBy(r => r.EngineName)
.Select(g => new
{
Engine = g.Key,
Jobs = g.Count(),
Valid = g.Count(r => r.Valid),
Crashed = g.Count(r => r.Crashed),
FullyPlaced = g.Count(r => r.FullyPlaced),
Unplaced = g.Sum(r => r.PartsUnplaced),
PlacedArea = g.Where(r => r.Valid).Sum(r => r.PlacedArea),
PlateArea = g.Where(r => r.Valid).Sum(r => r.PlateArea),
NetSheetArea = g.Where(r => r.Valid).Sum(r => r.NetSheetArea),
TotalCost = g.Sum(r => r.Cost),
TotalPlates = g.Sum(r => r.PlatesUsed),
TotalTimeMs = g.Sum(r => r.ElapsedMs),
})
.OrderBy(e => e.TotalCost)
.ToList();
var wins = CountWins(results);
// Util% and Net% are area-weighted over valid runs (sum placed / sum
// sheet), not a mean of per-job percentages. TotalCost sums across
// jobs, so it is only meaningful when every job uses the same units.
Console.WriteLine(
$"{"Engine", -16} {"Jobs", -6} {"Valid", -7} {"Complete", -9} {"Unplaced", -9} {"Wins", -6} {"Util%", -7} {"Net%", -7} {"TotalCost", -14} {"Plates", -8} {"TotalTime(ms)", -14}"
);
foreach (var e in byEngine)
{
var util = e.PlateArea > 0 ? e.PlacedArea / e.PlateArea * 100 : 0;
var netUtil = e.NetSheetArea > 0 ? e.PlacedArea / e.NetSheetArea * 100 : 0;
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
Console.WriteLine(
$"{e.Engine, -16} {e.Jobs, -6} {e.Valid, -7} {e.FullyPlaced, -9} {e.Unplaced, -9} {winCount, -6} {util, -7:F1} {netUtil, -7:F1} {e.TotalCost, -14:F1} {e.TotalPlates, -8} {e.TotalTimeMs, -14}"
);
}
}
public static void WriteCsv(string path, List<JobResult> results)
{
var sb = new StringBuilder();
sb.AppendLine(
"Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes"
);
foreach (var r in results)
{
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
sb.AppendLine(
string.Join(
",",
Csv(r.JobName),
Csv(r.EngineName),
r.Valid,
r.Crashed,
r.FullyPlaced,
r.PartsPlaced,
r.PartsRequested,
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
r.NetUtilization.ToString("F4", CultureInfo.InvariantCulture),
r.PlateArea.ToString("F2", CultureInfo.InvariantCulture),
r.NetSheetArea.ToString("F2", CultureInfo.InvariantCulture),
r.Cost.ToString("F2", CultureInfo.InvariantCulture),
r.PlatesUsed,
Csv(SizeSummary(r.SizeBreakdown)),
r.ElapsedMs,
Csv(notes)
)
);
}
File.WriteAllText(path, sb.ToString());
}
private static string SizeSummary(Dictionary<string, int> breakdown)
{
if (breakdown == null || breakdown.Count == 0)
return "-";
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
private static Dictionary<string, int> CountWins(List<JobResult> results)
{
var wins = new Dictionary<string, int>();
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
if (ranked.Count == 0 || !ranked[0].Valid)
continue;
foreach (var r in ranked.TakeWhile(r => Compare(r, ranked[0]) == 0))
wins[r.EngineName] = wins.GetValueOrDefault(r.EngineName) + 1;
}
return wins;
}
/// <summary>Lower sorts first (better). Valid beats invalid, complete beats
/// incomplete, then lower cost (relative tolerance, since costs are areas
/// in whatever units the job uses), then fewer plates.</summary>
public static int Compare(JobResult a, JobResult b)
{
if (a.Valid != b.Valid)
return a.Valid ? -1 : 1;
if (!a.Valid)
return 0;
if (a.FullyPlaced != b.FullyPlaced)
return a.FullyPlaced ? -1 : 1;
var costDiff = a.Cost - b.Cost;
var scale = System.Math.Max(1, System.Math.Max(a.Cost, b.Cost));
if (System.Math.Abs(costDiff) > Epsilon * scale)
return costDiff > 0 ? 1 : -1;
if (a.PlatesUsed != b.PlatesUsed)
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
return 0;
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Console</RootNamespace>
<AssemblyName>OpenNest.Console</AssemblyName>
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
File diff suppressed because it is too large. Load diff
+35 -12
View File
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest
{
@@ -7,7 +7,10 @@ namespace OpenNest
{
public static void Vertically(Entity fixedEntity, Entity movableEntity)
{
movableEntity.Offset(fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X, 0);
movableEntity.Offset(
fixedEntity.BoundingBox.Center.X - movableEntity.BoundingBox.Center.X,
0
);
}
public static void Vertically(Entity fixedEntity, List<Entity> entities)
@@ -17,7 +20,10 @@ namespace OpenNest
public static void Vertically(Part fixedPart, Part movablePart)
{
movablePart.Offset(fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X, 0);
movablePart.Offset(
fixedPart.BoundingBox.Center.X - movablePart.BoundingBox.Center.X,
0
);
}
public static void Vertically(Part fixedPart, List<Part> parts)
@@ -27,7 +33,10 @@ namespace OpenNest
public static void Horizontally(Entity fixedEntity, Entity movableEntity)
{
movableEntity.Offset(0, fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y);
movableEntity.Offset(
0,
fixedEntity.BoundingBox.Center.Y - movableEntity.BoundingBox.Center.Y
);
}
public static void Horizontally(Entity fixedEntity, List<Entity> entities)
@@ -37,7 +46,10 @@ namespace OpenNest
public static void Horizontally(Part fixedPart, Part movablePart)
{
movablePart.Offset(0, fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y);
movablePart.Offset(
0,
fixedPart.BoundingBox.Center.Y - movablePart.BoundingBox.Center.Y
);
}
public static void Horizontally(Part fixedPart, List<Part> parts)
@@ -67,7 +79,10 @@ namespace OpenNest
public static void Right(Entity fixedEntity, Entity movableEntity)
{
movableEntity.Offset(fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right, 0);
movableEntity.Offset(
fixedEntity.BoundingBox.Right - movableEntity.BoundingBox.Right,
0
);
}
public static void Right(Entity fixedEntity, List<Entity> entities)
@@ -107,7 +122,10 @@ namespace OpenNest
public static void Bottom(Entity fixedEntity, Entity movableEntity)
{
movableEntity.Offset(0, fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom);
movableEntity.Offset(
0,
fixedEntity.BoundingBox.Bottom - movableEntity.BoundingBox.Bottom
);
}
public static void Bottom(Entity fixedEntity, List<Entity> entities)
@@ -137,14 +155,19 @@ namespace OpenNest
return;
var list = new List<Part>(parts);
list.Sort((p1, p2) => horizontal
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
list.Sort(
(p1, p2) =>
horizontal
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y)
);
var lastIndex = list.Count - 1;
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y;
var end = horizontal
? list[lastIndex].BoundingBox.Center.X
: list[lastIndex].BoundingBox.Center.Y;
var spacing = (end - start) / lastIndex;
+2 -3
View File
@@ -1,5 +1,4 @@

namespace OpenNest
namespace OpenNest
{
public enum AlignType
{
@@ -10,6 +9,6 @@ namespace OpenNest
Horizontally,
Vertically,
EvenlySpaceHorizontally,
EvenlySpaceVertically
EvenlySpaceVertically,
}
}
+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest;
/// <summary>Existing cutting work on a set of plates: applied lead-ins and cut-offs.</summary>
/// <param name="LeadInParts">Real parts carrying applied lead-ins (locked ones included).</param>
/// <param name="LockedLeadInParts">Of those, parts whose lead-ins are locked.</param>
/// <param name="CutOffs">Cut-off definitions.</param>
/// <param name="PlateNumbers">Display numbers of the plates holding any of it, ascending.</param>
public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs,
IReadOnlyList<int> PlateNumbers)
{
public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0;
/// <summary>Operator text for the cutting planner's warning banner; empty when nothing exists.</summary>
public string Describe()
{
if (!HasExistingWork)
return string.Empty;
var found = new List<string>();
if (LeadInParts > 0)
found.Add($"lead-ins on {Count(LeadInParts, "part")}"
+ (LockedLeadInParts > 0 ? $" ({LockedLeadInParts} locked)" : string.Empty));
if (CutOffs > 0)
found.Add(Count(CutOffs, "cut-off"));
var plates = PlateNumbers.Count == 1 ? "plate " : "plates ";
var kept = new List<string>();
if (LockedLeadInParts > 0)
kept.Add("locked lead-ins");
if (CutOffs > 0)
kept.Add("existing cut-offs");
return $"ALREADY APPLIED: {string.Join(" and ", found)} on {plates}{string.Join(", ", PlateNumbers)}."
+ (LeadInParts > LockedLeadInParts ? " Planning recalculates unlocked lead-ins." : string.Empty)
+ (kept.Count > 0 ? $" Planning keeps {string.Join(" and ", kept)} unchanged." : string.Empty)
+ " Clear them to recalculate everything from scratch.";
}
private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}";
}
/// <summary>Finds and clears applied lead-ins and cut-offs so a plan can recalculate them.</summary>
public static class AppliedCuttingWork
{
/// <summary>
/// Counts applied lead-ins on real parts and cut-off definitions. Definitions in
/// <paramref name="ignoredCutOffs"/> (for example ones staged by the caller) are not counted.
/// </summary>
public static AppliedCuttingWorkSummary Summarize(IReadOnlyList<Plate> plates,
IReadOnlyList<int> plateNumbers = null, IEnumerable<CutOff> ignoredCutOffs = null)
{
ArgumentNullException.ThrowIfNull(plates);
if (plateNumbers != null && plateNumbers.Count != plates.Count)
throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
var ignored = new HashSet<CutOff>(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance);
int leads = 0, locked = 0, cutOffs = 0;
var numbers = new SortedSet<int>();
for (var index = 0; index < plates.Count; index++)
{
var plate = plates[index] ?? throw new ArgumentException("Plates must not be null.", nameof(plates));
var plateLeads = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray();
var plateCutOffs = plate.CutOffs.Count(c => !ignored.Contains(c));
leads += plateLeads.Length;
locked += plateLeads.Count(p => p.LeadInsLocked);
cutOffs += plateCutOffs;
if (plateLeads.Length > 0 || plateCutOffs > 0)
numbers.Add(plateNumbers?[index] ?? index + 1);
}
return new(leads, locked, cutOffs, numbers.ToArray());
}
/// <summary>
/// Removes every applied lead-in (locked ones too) from real parts and every cut-off with
/// its cut-off parts, and forgets each plate's recorded cutting parameters. Part poses,
/// order and quantities are unchanged. Returns what was cleared.
/// </summary>
public static AppliedCuttingWorkSummary Clear(IReadOnlyList<Plate> plates, IReadOnlyList<int> plateNumbers = null)
{
var cleared = Summarize(plates, plateNumbers);
foreach (var plate in plates)
{
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray())
part.RemoveLeadIns();
plate.CutOffs.Clear();
for (var index = plate.Parts.Count - 1; index >= 0; index--)
if (plate.Parts[index].BaseDrawing.IsCutOff)
plate.Parts.RemoveAt(index);
plate.CuttingParameters = null;
}
return cleared;
}
}
+182
View File
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest;
/// <summary>
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
/// Callers must prevent concurrent edits for the duration of either operation.
/// </summary>
public static class AutomaticCutOffBatch
{
/// <summary>Detached plans in plate order. Invalid input identifies the one-based plate number.</summary>
public static IReadOnlyList<AutomaticCutOffPlan> Create(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(settings);
if (plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
throw new ArgumentException("Each plate must occur only once.", nameof(plates));
var plans = new List<AutomaticCutOffPlan>(plates.Count);
for (var index = 0; index < plates.Count; index++)
{
try
{
plans.Add(AutomaticCutOffPlanner.Create(plates[index], options, settings));
}
catch (ArgumentException error)
{
throw new ArgumentException($"Plate {index + 1}: {error.Message}", nameof(plates), error);
}
}
return plans.AsReadOnly();
}
/// <summary>
/// Replans all plates before changing any. A blocking plan returns without applying any
/// definitions; empty/unchanged plates are untouched. On failure, restores cutoff state
/// on every touched plate, reporting explicitly if any restoration also fails.
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
/// </summary>
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings) => Stage(plates, options, settings).Plans;
/// <summary>
/// Applies like <see cref="Apply"/>, but keeps the recovery state so the caller can take the
/// new cut-offs back off again (<see cref="AutomaticCutOffStaging.Revert"/>) when the work they
/// were added for is abandoned. A blocked batch changes nothing and has nothing to revert.
/// </summary>
public static AutomaticCutOffStaging Stage(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings)
{
var plans = Create(plates, options, settings);
if (plans.Any(plan => plan.HasBlockingDiagnostics))
return new AutomaticCutOffStaging(plans, []);
var restoreActions = new List<(int PlateNumber, Action Restore)>();
try
{
for (var index = 0; index < plates.Count; index++)
{
var plan = plans[index];
if (plan.Definitions.Count == 0)
continue;
var plate = plates[index];
// Register recovery before the first observable mutation, including AddRange.
restoreActions.Add((index + 1, CaptureRestore(plate, plan)));
plate.CutOffs.AddRange(plan.Definitions);
plate.RegenerateCutOffs(settings);
}
}
catch (Exception applyError)
{
var rollbackErrors = RestoreAll(restoreActions);
if (rollbackErrors.Count > 0)
throw new InvalidOperationException(
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
+ string.Join("; ", rollbackErrors.Select(e => e.Message))
+ "\nThe nest may be incomplete; review it before saving or cutting.",
new AggregateException(new[] { applyError }.Concat(rollbackErrors)));
throw new InvalidOperationException(
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
}
return new AutomaticCutOffStaging(plans, restoreActions);
}
internal static List<Exception> RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
{
var errors = new List<Exception>();
for (var index = restoreActions.Count - 1; index >= 0; index--)
{
var saved = restoreActions[index];
try
{
saved.Restore();
}
catch (Exception rollbackError)
{
// A broken observer on one plate must not prevent recovery of the others.
errors.Add(new InvalidOperationException(
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
}
}
return errors;
}
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
{
// Regeneration replaces drawing programs and removes/reinserts cutoff parts.
// Save only that state; real parts, poses, programs and quantities stay untouched.
var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToArray();
var parts = plate.Parts.Select((part, index) => (Part: part, Index: index))
.Where(p => p.Part.BaseDrawing.IsCutOff).ToArray();
return () =>
{
foreach (var definition in plan.Definitions)
plate.CutOffs.Remove(definition);
for (var index = plate.Parts.Count - 1; index >= 0; index--)
{
if (plate.Parts[index].BaseDrawing.IsCutOff)
plate.Parts.RemoveAt(index);
}
foreach (var saved in programs)
saved.CutOff.Drawing.Program = saved.Program;
foreach (var saved in parts)
plate.Parts.Insert(saved.Index, saved.Part);
};
}
}
/// <summary>
/// Cut-offs added by <see cref="AutomaticCutOffBatch.Stage"/> that are still provisional: either
/// <see cref="Keep"/> them once the work they belong to is committed, or <see cref="Revert"/> them.
/// Owner-thread only, like the plates it changed.
/// </summary>
public sealed class AutomaticCutOffStaging
{
private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions;
internal AutomaticCutOffStaging(IReadOnlyList<AutomaticCutOffPlan> plans,
IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
{
Plans = plans;
this.restoreActions = restoreActions;
Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray();
}
/// <summary>The fresh plans, one per plate in the order given.</summary>
public IReadOnlyList<AutomaticCutOffPlan> Plans { get; }
/// <summary>True when any plate blocked the batch; then nothing was added.</summary>
public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics);
/// <summary>The definitions this staging added (empty when blocked or nothing was needed).</summary>
public IReadOnlyList<CutOff> Definitions { get; }
/// <summary>True while the added cut-offs can still be reverted.</summary>
public bool CanRevert => restoreActions.Count > 0;
/// <summary>Makes the added cut-offs permanent; a later <see cref="Revert"/> does nothing.</summary>
public void Keep() => restoreActions = [];
/// <summary>
/// Removes the added cut-offs and restores each touched plate's previous cut-off programs and
/// sequence. Real parts are not touched. Runs once; throws if any plate could not be restored.
/// </summary>
public void Revert()
{
var actions = restoreActions;
restoreActions = [];
var errors = AutomaticCutOffBatch.RestoreAll(actions);
if (errors.Count > 0)
throw new InvalidOperationException(
"Removing the added cut-offs failed: " + string.Join("; ", errors.Select(e => e.Message))
+ "\nThe nest may be incomplete; review it before saving or cutting.",
new AggregateException(errors));
}
}
+554
View File
@@ -0,0 +1,554 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest;
public sealed class AutomaticCutOffOptions
{
/// <summary>
/// Nominal distance between vertical cut lines in model units. Must be finite and
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
/// </summary>
public double Spacing { get; set; }
/// <summary>
/// Minimum retained-tail length along X in model units. Zero permits any positive tail.
/// The desktop default is 12 inches (304.8 mm).
/// </summary>
public double MinimumTailLength { get; set; }
/// <summary>
/// Minimum strip length in model units. Null or equal to Spacing preserves the fixed grid.
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
/// </summary>
public double? MinimumStripLength { get; set; }
}
public enum AutomaticCutOffDiagnosticCode
{
ExistingCutOff,
LimitedCutOffConflict,
EmptyCut,
SegmentedCut,
ClampBlockedCut,
NoSafeTailSeparator,
TailBelowMinimum,
ShiftedCut,
}
public sealed record AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode Code, string Message, bool IsBlocking = false, double? X = null);
/// <summary>
/// Detached proposal. Collections are read-only; contained definitions/preview parts belong
/// to the caller. Never accept a blocked plan or a preview made for an older layout/settings.
/// </summary>
public sealed class AutomaticCutOffPlan
{
/// <summary>Only new, usable definitions; existing equivalent definitions are not returned.</summary>
public IReadOnlyList<CutOff> Definitions { get; internal set; } = Array.Empty<CutOff>();
/// <summary>Detached display parts, in the same order as Definitions. Not for acceptance.</summary>
public IReadOnlyList<Part> PreviewParts { get; internal set; } = Array.Empty<Part>();
public IReadOnlyList<AutomaticCutOffDiagnostic> Diagnostics { get; internal set; } =
Array.Empty<AutomaticCutOffDiagnostic>();
/// <summary>Furthest real-part X distance from the origin, excluding cut-off parts.</summary>
public double OccupiedSpan { get; internal set; }
/// <summary>
/// Distance to the verified separator, or full sheet length when no separated tail can
/// be claimed. Zero on empty sheets. This is not a disconnected-scrap-size guarantee.
/// </summary>
public double UsedSpan { get; internal set; }
/// <summary>Length beyond a verified separator, otherwise zero (also on empty sheets).</summary>
public double TailLength { get; internal set; }
/// <summary>Signed X coordinate of a verified new or equivalent existing separator.</summary>
public double? TailSeparatorX { get; internal set; }
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue;
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
public double StripCutLength { get; internal set; }
/// <summary>Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path.</summary>
public double FixedGridCutLength { get; internal set; }
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
}
/// <summary>Pure proposals for vertical scrap cuts, measured in the plate's model units.</summary>
public static class AutomaticCutOffPlanner
{
public const double GeometryTolerance = Tolerance.Epsilon;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
public const int MaximumShiftCandidateCount = 20000;
/// <summary>
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
/// Invalid inputs throw ArgumentException. Blocking diagnostics return no definitions.
/// Accept by adding Definitions to Plate.CutOffs and calling RegenerateCutOffs with the
/// same settings, only while the layout is unchanged. Do not add PreviewParts to the plate.
/// Nominal spacing is not a guarantee of fully disconnected, hopper-sized scrap.
/// </summary>
public static AutomaticCutOffPlan Create(Plate plate, AutomaticCutOffOptions options,
CutOffSettings settings)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(settings);
ValidateInputs(plate, options, settings);
// Stored zones use the sheet's lower-left frame; cut positions are signed.
var clamps = ClampZoneFrame.ToPlacement(plate);
var bounds = plate.BoundingBox(false);
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
var occupied = 0.0;
var hasParts = false;
foreach (var part in plate.Parts)
{
Require(part?.BaseDrawing != null, "Every part must have a drawing.", nameof(plate));
if (part.BaseDrawing.IsCutOff)
continue;
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign));
hasParts = true;
}
if (!hasParts)
return new AutomaticCutOffPlan();
// Use distances from the coordinate origin, not the sheet's lower-left corner.
var length = plate.Size.Length;
occupied = System.Math.Min(occupied, length);
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
+ GeometryTolerance;
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
// A tail too short to keep stays attached to the skeleton, so the strip grid must
// continue to the sheet end; stopping at the skipped separator would leave a final
// strip longer than the spacing.
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength;
var usedSpan = keepsTail ? separator : length;
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
// Validate and bound the candidate count before preparing geometry or allocating lines.
ValidateExisting(plate, bounds, settings);
var cache = Plate.BuildPerimeterCache(plate);
var definitions = new List<CutOff>();
var diagnostics = new List<AutomaticCutOffDiagnostic>();
var separated = false;
var separatorX = (double?)null;
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
var stripCutLength = 0.0;
var fixedGridCutLength = 0.0;
var measurements = new Dictionary<double, (double Length, int SegmentCount)>();
var candidates = new HashSet<double>();
if (shifted)
{
// Full-span definitions fix the strip boundaries, even when their emitted
// programs are segmented. Partial cuts remain obstacles, not boundaries.
var boundaries = plate.CutOffs
.Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings))
.Select(c => sign * c.Position.X)
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
.Distinct().OrderBy(distance => distance).Append(usedSpan);
var gaps = new List<(double Start, double End, int Count)>();
var start = 0.0;
var nominalCount = 0;
foreach (var end in boundaries)
{
var count = 0;
for (var index = 1; index <= (int)candidateCount; index++)
{
if (start + index * options.Spacing >= end - GeometryTolerance)
break;
count++;
}
gaps.Add((start, end, count));
nominalCount += count;
start = end;
}
// The origin-based lattice alone fits within 20,000 positions, but its
// off-lattice nominal lines need reserved slots too. Budget both before
// constructing any row so a valid, dense grid is never refused halfway.
Require(nominalCount < MaximumShiftCandidateCount,
"Too many nominal strip lines for the shift search.", nameof(options));
var step = System.Math.Max(options.Spacing / 700,
usedSpan / (MaximumShiftCandidateCount - nominalCount));
foreach (var gap in gaps)
AddShiftedGap(gap.Start, gap.End, gap.Count, step);
}
else
{
// Keep the legacy loop and certification unchanged when shifting is disabled.
for (var index = 1; index <= (int)candidateCount; index++)
{
var distance = index * options.Spacing;
if (distance >= usedSpan - GeometryTolerance)
break;
AddCandidate(sign * distance, false);
}
}
if (keepsTail)
AddCandidate(sign * separator, true);
else if (hasTailCandidate)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
"The proposed tail is shorter than the minimum tail length; the final separator was skipped "
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
X: sign * separator));
if (diagnostics.Any(d => d.IsBlocking))
{
// A partial proposal must not accidentally be accepted around a manual conflict.
definitions.Clear();
stripCutLength = 0;
separated = false;
separatorX = null;
}
// The legacy placement loop stays untouched. Its detached programs are already
// regenerated, so reporting their actual new strip cutting needs no new search.
if (!shifted)
fixedGridCutLength = stripCutLength;
return new AutomaticCutOffPlan
{
Definitions = definitions.AsReadOnly(),
PreviewParts = definitions.Select(c => new Part(c.Drawing)).ToList().AsReadOnly(),
Diagnostics = diagnostics.AsReadOnly(),
OccupiedSpan = occupied,
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
TailSeparatorX = separatorX,
StripCutLength = stripCutLength,
FixedGridCutLength = fixedGridCutLength,
};
void AddShiftedGap(double start, double end, int count, double step)
{
var nominal = new List<double>(count);
for (var index = 1; index <= count; index++)
{
var distance = start + index * options.Spacing;
nominal.Add(distance);
fixedGridCutLength += Measure(distance).Length;
}
if (count == 0)
return;
var positions = AutomaticCutOffStripSearch.FindPositions(start, end, count,
options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray();
for (var index = 0; index < positions.Length; index++)
{
var distance = positions[index];
AddCandidate(sign * distance, false);
if (!Near(distance, nominal[index]))
{
var difference = Measure(nominal[index]).Length - Measure(distance).Length;
var comparison = difference > 0 ? $"{difference:G8} model units less" :
difference < 0 ? $"{-difference:G8} model units more" : "the same length";
diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut,
$"Moved from {nominal[index]:G8} to {distance:G8} model units; "
+ $"this line cuts {comparison}.", X: sign * distance));
}
}
}
bool Allowed(double distance)
{
RegisterCandidate(distance);
// Use the certification path's exact Near predicate, including its ULP
// allowance, so a selected line cannot rediscover a limited-cut conflict.
var x = sign * distance;
return !plate.CutOffs.Any(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)) &&
!clamps.Any(zone => CrossesClamp(zone, x));
}
void RegisterCandidate(double distance)
{
if (candidates.Add(distance))
Require(candidates.Count <= MaximumShiftCandidateCount,
$"Shift search would generate more than {MaximumShiftCandidateCount} distinct positions. "
+ "Increase spacing or the minimum strip length, or use the fixed grid.", nameof(options));
}
(double Length, int SegmentCount) Measure(double distance)
{
RegisterCandidate(distance);
if (!measurements.TryGetValue(distance, out var measured))
{
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
.MeasureSegments(plate, settings, cache);
measurements.Add(distance, measured);
}
return measured;
}
void AddCandidate(double x, bool isSeparator)
{
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
Near(c.Position.X, x)).ToList();
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
"A same-line manual cut has different limits. Manual review is required; no cuts may be applied.",
true, x));
if (isSeparator)
WarnNoSeparator(x);
return;
}
// A full-width cut crosses every active front-edge clamp at its X. Test the
// proposed X and every equivalent existing definition, because a tolerance-close
// existing line is regenerated and certified at its own X. Keep the whole
// footprint when it hangs off the sheet. A same-line manual limit conflict above
// still blocks the whole plan, clamp or not.
var blocked = clamps.FirstOrDefault(zone =>
matches.Select(c => c.Position.X).Prepend(x).Any(position => CrossesClamp(zone, position)));
if (blocked != null)
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
X: x));
if (isSeparator)
WarnNoSeparator(x);
return;
}
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
var existing = matches.FirstOrDefault();
var candidate = existing == null
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
: new CutOff(existing.Position, existing.Axis)
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit };
candidate.Regenerate(plate, settings, cache);
var program = candidate.Drawing.Program;
var usable = HasUsableSegments(program);
if (existing != null)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.ExistingCutOff,
"An equivalent full-span cut-off already exists; no duplicate was added.", X: x));
if (!usable)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.EmptyCut,
"The line has no usable cut segments after part clearance and minimum-length filtering.",
X: x));
if (isSeparator)
{
if (!usable || !IsFullSpanProgram(program, bounds))
{
WarnNoSeparator(x);
return;
}
separated = true;
separatorX = candidate.Position.X;
}
else if (usable && !IsFullSpanProgram(program, bounds))
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.SegmentedCut,
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
X: x));
if (usable && existing == null)
{
definitions.Add(candidate);
if (!isSeparator)
{
if (shifted)
stripCutLength += Measure(System.Math.Abs(x)).Length;
else
for (var index = 0; index < program.Codes.Count; index += 2)
stripCutLength += System.Math.Abs(
((LinearMove)program.Codes[index + 1]).EndPoint.Y -
((RapidMove)program.Codes[index]).EndPoint.Y);
}
}
}
// 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;
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
"No safe full-width tail separator survives the current settings. Review the tail manually.",
X: x));
}
private static void ValidateInputs(Plate plate, AutomaticCutOffOptions options, CutOffSettings settings)
{
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
Require(!options.MinimumStripLength.HasValue ||
(double.IsFinite(options.MinimumStripLength.Value) && options.MinimumStripLength.Value > MinimumSpacing &&
options.MinimumStripLength.Value <= options.Spacing),
$"Minimum strip length must be finite, greater than {MinimumSpacing} and no greater than spacing.", nameof(options));
Require(Nonnegative(options.MinimumTailLength),
"Minimum tail length must be finite and nonnegative.", nameof(options));
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
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));
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));
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
Require(plate.Parts != null && plate.CutOffs != null,
"Plate parts and cut-off collections are required.", nameof(plate));
}
private static double ValidatePart(Part part, Box sheet, int sign)
{
Require(Finite(part.Location) && double.IsFinite(part.Rotation),
"Part pose must be finite.", "plate");
ValidateProgram(part.Program, new HashSet<Program>());
var box = part.Program.BoundingBox();
box.Offset(part.Location);
Require(ValidBox(box) && box.Length > 0 && box.Width > 0,
"Real parts must have finite, nonempty geometry.", "plate");
var cached = part.BoundingBox;
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
"Part geometry has stale bounds; update it before planning.", "plate");
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate");
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
// Checking converted entities catches overflowing incremental moves and curve bounds.
var hasMaterial = false;
var occupied = sign > 0 ? box.Right : -box.Left;
var roundoff = CutOff.GetBoundsRoundoff(part);
foreach (var entity in ConvertProgram.ToGeometry(part.Program))
{
var entityBox = entity.BoundingBox;
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
if (!SpecialLayers.IsMaterial(entity.Layer))
continue;
entityBox = entityBox.Translate(part.Location);
// Permit only bounded floating-point roundoff, with the same conservative
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions
// (including refitted arc centers) are still rejected, even below epsilon.
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
"Converted material extends outside cached part bounds; repair it before planning.", "plate");
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate");
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
}
Require(hasMaterial, "Real parts must contain material geometry.", "plate");
return occupied;
}
private static void ValidateProgram(Program program, HashSet<Program> path)
{
Require(program?.Codes != null && path.Count < 64 && path.Add(program),
"Part program is missing, recursive, or nested too deeply.", "plate");
foreach (var code in program.Codes)
{
Require(code != null, "Part program contains a missing instruction.", "plate");
if (code is Motion motion)
Require(Finite(motion.EndPoint), "Part motion coordinates must be finite.", "plate");
if (code is ArcMove arc)
Require(Finite(arc.CenterPoint) && Enum.IsDefined(arc.Rotation),
"Part arc geometry must be finite with a valid direction.", "plate");
if (code is SubProgramCall call)
{
Require(Finite(call.Offset) && double.IsFinite(call.Rotation),
"Part sub-program pose must be finite.", "plate");
ValidateProgram(call.Program, path);
}
}
path.Remove(program);
}
private static void ValidateExisting(Plate plate, Box bounds, CutOffSettings settings)
{
foreach (var cut in plate.CutOffs)
{
Require(cut != null && Enum.IsDefined(cut.Axis) && Finite(cut.Position) &&
(!cut.StartLimit.HasValue || double.IsFinite(cut.StartLimit.Value)) &&
(!cut.EndLimit.HasValue || double.IsFinite(cut.EndLimit.Value)),
"Existing cut-off definitions must be finite with a valid axis.", nameof(plate));
var start = cut.StartLimit ?? (cut.Axis == CutOffAxis.Vertical ? bounds.Bottom : bounds.Left);
var end = cut.EndLimit ?? ((cut.Axis == CutOffAxis.Vertical ? bounds.Top : bounds.Right)
+ settings.Overtravel);
Require(double.IsFinite(end) && start < end,
"Existing cut-off limits must be finite and ordered.", nameof(plate));
}
}
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
private static bool HasUsableSegments(Program program)
{
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
return false;
for (var i = 0; i < program.Codes.Count; i += 2)
{
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
!Finite(from.EndPoint) || !Finite(to.EndPoint) ||
!Near(from.EndPoint.X, to.EndPoint.X) ||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance)
return false;
}
return true;
}
private static bool IsFullSpanProgram(Program program, Box bounds)
{
// No gaps, bridges, or filtered middle segments can separate the tail.
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
program.Codes[1] is not LinearMove to)
return false;
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance &&
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance;
}
private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
Nonnegative(box.Length) && Nonnegative(box.Width) &&
double.IsFinite(box.Right) && double.IsFinite(box.Top);
private static bool Inside(Box box, Box sheet) =>
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance &&
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance;
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
private static bool Near(double a, double b)
{
// An exact tolerance-sized offset can round just above epsilon after subtraction.
// Allow two representational steps, not a geometry-scale relative tolerance.
var magnitude = System.Math.Max(System.Math.Abs(a), System.Math.Abs(b));
var step = System.Math.BitIncrement(magnitude) - magnitude;
return System.Math.Abs(a - b) <= GeometryTolerance + (double.IsFinite(step) ? 2 * step : 0);
}
private static void Require(bool valid, string message, string parameter)
{
if (!valid)
throw new ArgumentException(message, parameter);
}
}
+157
View File
@@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
namespace OpenNest;
/// <summary>Exact layered search over sorted positions; each transition uses a sliding minimum.</summary>
internal static class AutomaticCutOffStripSearch
{
internal static double[] FindPositions(double start, double end, int count, double spacing,
double minimum, double step, Func<double, bool> allowed,
Func<double, (double Length, int SegmentCount)> measure)
{
var rows = new List<double[]>(count);
var nominal = new double[count];
for (var line = 1; line <= count; line++)
{
var today = start + line * spacing;
nominal[line - 1] = today;
var low = System.Math.Max(start + line * minimum, end - (count - line + 1) * spacing);
var high = System.Math.Min(today, end - AutomaticCutOffPlanner.GeometryTolerance);
var positions = new List<double>();
// Integer indices keep candidates on the origin-based lattice without cumulative drift.
var first = (long)System.Math.Ceiling(low / step);
var last = (long)System.Math.Floor(high / step);
for (var index = first; index <= last; index++)
{
var position = index * step;
if (position >= low && position <= high && allowed(position))
positions.Add(position);
}
if (allowed(today))
positions.Add(today);
positions.Sort();
for (var index = positions.Count - 1; index > 0; index--)
if (positions[index] == positions[index - 1])
positions.RemoveAt(index);
if (positions.Count == 0)
return null;
rows.Add(positions.ToArray());
}
return Solve(start, rows, nominal, spacing, minimum, measure);
}
// Kept independent of geometry so tests can compare against exhaustive, non-greedy oracles.
internal static double[] Solve(double start, IReadOnlyList<double[]> rows,
IReadOnlyList<double> nominal, double spacing, double minimum,
Func<double, (double Length, int SegmentCount)> measure)
{
var previous = new List<State> { new() { Position = start } };
for (var line = 0; line < rows.Count; line++)
{
var current = new List<State>(rows[line].Length);
var deque = new int[previous.Count];
var head = 0;
var tail = 0;
var added = 0;
foreach (var position in rows[line])
{
var upper = position - minimum + AutomaticCutOffPlanner.GeometryTolerance;
var lower = position - spacing - AutomaticCutOffPlanner.GeometryTolerance;
while (added < previous.Count && previous[added].Position <= upper)
{
while (tail > head && Compare(previous[added], previous[deque[tail - 1]]) < 0)
tail--;
deque[tail++] = added++;
}
while (head < tail && previous[deque[head]].Position < lower)
head++;
if (head == tail)
continue;
var parent = previous[deque[head]];
var cost = measure(position);
current.Add(new State
{
Position = position,
Length = parent.Length + RoundedMicroUnits(cost.Length),
Pierces = parent.Pierces + cost.SegmentCount,
Deviation = parent.Deviation + System.Math.Abs(nominal[line] - position),
Parent = parent,
});
}
if (current.Count == 0)
return null;
AssignLexicographicRanks(current, previous.Count);
previous = current;
}
State best = null;
foreach (var state in previous)
if (best == null || Compare(state, best) < 0)
best = state;
var result = new double[rows.Count];
for (var line = result.Length - 1; line >= 0; line--)
{
result[line] = best.Position;
best = best.Parent;
}
return result;
}
private static BigInteger RoundedMicroUnits(double length)
{
if (!double.IsFinite(length) || length < 0)
throw new ArgumentException("Cut-off segment length must be finite and nonnegative.", nameof(length));
var rounded = System.Math.Round(length, 6);
// A shortest round-trip decimal may spell a different integer near 1e16
// (for example 26833294515438512d prints as 26833294515438510).
// An already-integral double is exact; never round it through text.
if (rounded == System.Math.Truncate(rounded))
return new BigInteger(rounded) * 1_000_000;
// Compare sums of the specified decimal-rounded model units, not binary
// approximations such as 0.1 + 0.2 versus 0.3. The round-trip string
// retains micro-unit digits for fractional scores.
var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture),
NumberStyles.Float, CultureInfo.InvariantCulture);
return new BigInteger(decimal.Round(value * 1_000_000m, 0));
}
private static int Compare(State left, State right)
{
var comparison = left.Length.CompareTo(right.Length);
if (comparison == 0)
comparison = left.Pierces.CompareTo(right.Pierces);
if (comparison == 0)
comparison = left.Deviation.CompareTo(right.Deviation);
return comparison != 0 ? comparison : left.LexicographicRank.CompareTo(right.LexicographicRank);
}
private static void AssignLexicographicRanks(List<State> states, int parentCount)
{
// Stable counting sort by prefix rank. Positions are already sorted, so within a
// prefix their order is lexicographic. Ranking and transitions are linear per row.
var offsets = new int[parentCount];
foreach (var state in states)
offsets[state.Parent.LexicographicRank]++;
var total = 0;
for (var index = 0; index < offsets.Length; index++)
{
var count = offsets[index];
offsets[index] = total;
total += count;
}
foreach (var state in states)
state.LexicographicRank = offsets[state.Parent.LexicographicRank]++;
}
private sealed class State
{
public double Position;
public BigInteger Length;
public int Pierces;
public double Deviation;
public State Parent;
public int LexicographicRank;
}
}
+20 -9
View File
@@ -1,7 +1,7 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Drawing;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Bending
{
@@ -10,7 +10,7 @@ namespace OpenNest.Bending
public static readonly Layer EtchLayer = new Layer("ETCH")
{
Color = Color.Green,
IsVisible = true
IsVisible = true,
};
private const double DefaultEtchLength = 1.0;
@@ -26,11 +26,14 @@ namespace OpenNest.Bending
[System.Text.Json.Serialization.JsonIgnore]
public Entity SourceEntity { get; set; }
// Import-only identity of the original CAD annotation (not nest metadata).
[System.Text.Json.Serialization.JsonIgnore]
public ulong? SourceNoteHandle { get; set; }
public double Length => StartPoint.DistanceTo(EndPoint);
public double AngleRadians => Angle.HasValue
? OpenNest.Math.Angle.ToRadians(Angle.Value)
: 0;
public double AngleRadians =>
Angle.HasValue ? OpenNest.Math.Angle.ToRadians(Angle.Value) : 0;
public Line ToLine() => new Line(StartPoint, EndPoint);
@@ -62,7 +65,9 @@ namespace OpenNest.Bending
var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength;
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)));
result.Add(
CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy))
);
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
}
@@ -75,7 +80,8 @@ namespace OpenNest.Bending
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
{
entities.RemoveAll(e => e.Tag == BendEtchTag);
if (bends == null) return;
if (bends == null)
return;
foreach (var bend in bends)
entities.AddRange(bend.GetEtchEntities());
@@ -83,7 +89,12 @@ namespace OpenNest.Bending
private static Line CreateEtchLine(Vector start, Vector end)
{
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green, Tag = BendEtchTag };
return new Line(start, end)
{
Layer = EtchLayer,
Color = Color.Green,
Tag = BendEtchTag,
};
}
public override string ToString()
+1 -1
View File
@@ -4,6 +4,6 @@ namespace OpenNest.Bending
{
Unknown,
Up,
Down
Down,
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Collections.Generic;
namespace OpenNest.BestFit
{
/// <summary>One saved best-fit pair result, as persisted in a .nest archive.</summary>
public sealed record SavedPairResult(
double Part1Rotation,
double Part2Rotation,
double Part2OffsetX,
double Part2OffsetY,
int StrategyType,
int TestNumber,
double CandidateSpacing,
double RotatedArea,
double BoundingWidth,
double BoundingHeight,
double OptimalRotation,
bool Keep,
string Reason,
double TrueArea,
IReadOnlyList<double> HullAngles
);
/// <summary>The saved best-fit results for one drawing at one plate size and spacing.</summary>
public sealed record SavedPairSet(
double PlateWidth,
double PlateHeight,
double Spacing,
IReadOnlyList<SavedPairResult> Results
);
/// <summary>
/// Persists best-fit pair caches across .nest round trips. Implementations own their
/// cache; readers and writers never assume one exists, and a file without saved sets is
/// always valid because the data can be recomputed.
/// </summary>
public interface IBestFitStore
{
/// <summary>
/// Sets to save for <paramref name="drawing"/>, limited to the given
/// (plate width, plate length, part spacing) keys.
/// </summary>
IReadOnlyList<SavedPairSet> Export(
Drawing drawing,
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
);
/// <summary>Restores saved sets for <paramref name="drawing"/>.</summary>
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
}
}
+43
View File
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Breakoffs
{
/// <summary>
/// Scrap drawn on a part (for example a forming backgauge tab) that stays attached by tabs and
/// is snapped off later. It is identified by its joining line: a chain of source entities whose
/// ends touch the outline around part and scrap and which splits that region in two. By
/// default the smaller side is the scrap. Building the drawing cuts the joining line as open
/// break cuts between tabs, entered from the scrap side. All values are in the source frame.
/// </summary>
public sealed class Breakoff
{
public const double DefaultTabWidth = 0.0625;
public const int DefaultTabCount = 4;
public const int MinTabCount = 2;
/// <summary>Source entity IDs of the joining line between part and scrap.</summary>
public List<Guid> EdgeIds { get; set; } = new();
/// <summary>True when the larger side is the scrap instead of the smaller one.</summary>
public bool Flip { get; set; }
/// <summary>Uncut length of each tab along the joining line.</summary>
public double TabWidth { get; set; } = DefaultTabWidth;
/// <summary>
/// Tab centers as distances along the joining line from its start, ascending; the first
/// and last tab sit at the ends. Empty means <see cref="DefaultTabCount"/> evenly spaced.
/// </summary>
public List<double> TabCenters { get; set; } = new();
public Breakoff Clone() =>
new()
{
EdgeIds = new List<Guid>(EdgeIds),
Flip = Flip,
TabWidth = TabWidth,
TabCenters = new List<double>(TabCenters),
};
}
}
+400
View File
@@ -0,0 +1,400 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
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
/// strategies pierce and lead in from the scrap.
/// </summary>
public static class BreakoffBuilder
{
/// <summary>How close the joining line's ends must be to the outline.</summary>
public const double JoinTolerance = 0.001;
private const double ArcTolerance = 0.0005;
/// <summary>
/// <paramref name="entities"/> with every valid breakoff applied: its joining line replaced
/// by break cuts and its outline's joints made exact. Inputs are not modified; generated
/// entities are new. Breakoffs that cannot be resolved are skipped and described in
/// <paramref name="errors"/>.
/// </summary>
public static List<Entity> Apply(IReadOnlyList<Entity> entities, IReadOnlyList<Breakoff> breakoffs,
out List<string> errors)
{
errors = new List<string>();
var working = entities.ToList();
if (breakoffs == null || breakoffs.Count == 0)
return working;
var allEdges = breakoffs.SelectMany(b => b.EdgeIds).ToHashSet();
for (var i = 0; i < breakoffs.Count; i++)
{
var layout = Layout(working, breakoffs[i], allEdges);
if (!layout.IsValid)
{
errors.Add($"Breakoff {i + 1}: {layout.Error}");
continue;
}
var replaced = new HashSet<Entity>(layout.ReplacedEntities, ReferenceEqualityComparer.Instance);
working.RemoveAll(replaced.Contains);
working.AddRange(layout.Outline.Select(e => e.Clone()));
working.AddRange(layout.Cuts.SelectMany(c => c).Select(e => e.Clone()));
}
return working;
}
/// <summary>
/// Resolves <paramref name="breakoff"/> against <paramref name="entities"/> (the entities that
/// will be cut). <paramref name="otherEdgeIds"/> are joining lines of other breakoffs on the
/// same drawing, which are ignored while finding the outline.
/// </summary>
public static BreakoffLayout Layout(IReadOnlyList<Entity> entities, Breakoff breakoff,
IEnumerable<Guid> otherEdgeIds = null)
{
ArgumentNullException.ThrowIfNull(entities);
ArgumentNullException.ThrowIfNull(breakoff);
if (!(breakoff.TabWidth > 0) || !double.IsFinite(breakoff.TabWidth))
return Fail("The tab width must be greater than zero.");
var edgeIds = breakoff.EdgeIds.ToHashSet();
var edgeSources = entities.Where(e => edgeIds.Contains(e.Id)).ToList();
if (edgeIds.Count == 0 || edgeSources.Count != edgeIds.Count)
return Fail("Its joining line is hidden or missing.");
var line = BreakoffPath.ChainOpen(edgeSources, JoinTolerance);
if (line == null)
return Fail("Its joining line must be one open chain.");
// Measure tabs from a fixed end so positions survive re-chaining.
var a = BreakoffPath.Start(line[0]);
var b = BreakoffPath.End(line[^1]);
if (b.X < a.X - JoinTolerance || (System.Math.Abs(b.X - a.X) <= JoinTolerance && b.Y < a.Y))
{
line = BreakoffPath.Reversed(line);
(a, b) = (b, a);
}
var excluded = new HashSet<Guid>(otherEdgeIds ?? Enumerable.Empty<Guid>());
excluded.UnionWith(edgeIds);
var sources = new Dictionary<Entity, Entity>(ReferenceEqualityComparer.Instance);
var candidates = new List<Entity>();
foreach (var entity in entities)
{
if (excluded.Contains(entity.Id) || IsMark(entity) || entity is not (Line or Arc))
continue;
var copy = BreakoffPath.Copy(entity);
sources[copy] = entity;
candidates.Add(copy);
}
Shape outline = null;
var outlineArea = 0.0;
foreach (var shape in ShapeBuilder.GetShapes(WithoutDanglingEnds(candidates)))
{
if (shape.Entities.Count < 2 || !shape.IsClosed()
|| shape.ClosestPointTo(a).DistanceTo(a) > JoinTolerance
|| shape.ClosestPointTo(b).DistanceTo(b) > JoinTolerance)
continue;
var area = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(shape.Entities, ArcTolerance)));
if (outline == null || area > outlineArea)
{
outline = shape;
outlineArea = area;
}
}
if (outline == null)
return Fail("Both ends of the joining line must touch a closed outline.");
var loop = CloseExactly(outline.Entities);
var total = BreakoffPath.Length(loop);
var atA = BreakoffPath.DistanceOf(loop, a);
var rotated = BreakoffPath.Sub(loop, atA, total).Concat(BreakoffPath.Sub(loop, 0, atA)).ToList();
var atB = BreakoffPath.DistanceOf(rotated, b);
if (atB <= JoinTolerance || atB >= total - JoinTolerance)
return Fail("The joining line must touch the outline at two different points.");
var toB = BreakoffPath.Sub(rotated, 0, atB);
var backToA = BreakoffPath.Sub(rotated, atB, total);
// The two sides: the outline from A to B closed back along the line, and the line
// closed back along the rest of the outline.
var sideTo = toB.Concat(BreakoffPath.Reversed(line)).ToList();
var sideBack = line.Concat(backToA).ToList();
var sideToArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideTo, ArcTolerance)));
var sideBackArea = System.Math.Abs(BreakoffPath.SignedArea(BreakoffPath.ToPoints(sideBack, ArcTolerance)));
if (System.Math.Abs(sideToArea + sideBackArea - outlineArea) > 0.01 * outlineArea)
return Fail("The joining line must run inside the outline.");
var scrap = (sideToArea <= sideBackArea) != breakoff.Flip ? sideTo : sideBack;
var scrapPoints = BreakoffPath.ToPoints(scrap, ArcTolerance);
var length = BreakoffPath.Length(line);
var tabWidth = breakoff.TabWidth;
var centers = breakoff.TabCenters.Count == 0
? EvenTabCenters(length, Breakoff.DefaultTabCount, tabWidth)
: breakoff.TabCenters.OrderBy(c => c).ToList();
if (centers.Count < Breakoff.MinTabCount || length <= tabWidth * centers.Count)
return Fail("The joining line is too short for its tabs.");
if (centers[0] < tabWidth / 2 - JoinTolerance || centers[^1] > length - tabWidth / 2 + JoinTolerance)
return Fail("A tab runs off the joining line; space the tabs evenly again.");
for (var i = 1; i < centers.Count; i++)
if (centers[i] - centers[i - 1] <= tabWidth + JoinTolerance)
return Fail("Two tabs overlap; space the tabs evenly again.");
var side = SideOf(line, length, scrapPoints);
if (side == 0)
return Fail("Could not tell which side of the joining line is scrap.");
var cuts = new List<IReadOnlyList<Entity>>();
for (var i = 0; i + 1 < centers.Count; i++)
{
var cut = BreakoffPath.Sub(line, centers[i] + tabWidth / 2, centers[i + 1] - tabWidth / 2);
if (side < 0)
cut = BreakoffPath.Reversed(cut);
foreach (var e in cut)
{
e.Id = Guid.NewGuid();
e.Layer = SpecialLayers.Break;
}
cuts.Add(cut);
}
var replaced = outline.Entities.Select(e => sources[e]).ToList();
replaced.AddRange(edgeSources);
return new BreakoffLayout
{
BreakLine = line,
Length = length,
Outline = loop,
Scrap = scrap,
Cuts = cuts,
TabCenters = centers,
TabWidth = tabWidth,
ReplacedEntities = replaced,
};
}
/// <summary><paramref name="count"/> tab centers spaced evenly with the first and last at the ends.</summary>
public static List<double> EvenTabCenters(double length, int count, double tabWidth)
{
count = System.Math.Max(count, Breakoff.MinTabCount);
var half = tabWidth / 2;
var span = length - tabWidth;
return Enumerable.Range(0, count).Select(i => half + span * i / (count - 1)).ToList();
}
/// <summary>
/// The chain of visible lines and arcs through <paramref name="picked"/>, grown until it
/// reaches a point where it does not continue as a single path (such as where the joining
/// line meets the outline). Empty when <paramref name="picked"/> cannot be a joining line.
/// </summary>
public static List<Entity> FindEdgeChain(IReadOnlyList<Entity> entities, Entity picked)
{
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
.ToList();
if (!pool.Contains(picked))
return new List<Entity>();
var chain = new List<Entity> { picked };
Grow(BreakoffPath.End(picked));
Grow(BreakoffPath.Start(picked));
return chain;
void Grow(Vector tip)
{
while (true)
{
var touching = pool.Where(e => !chain.Contains(e)
&& (BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance)).ToList();
if (touching.Count != 1)
return;
var next = touching[0];
chain.Add(next);
tip = BreakoffPath.Start(next).DistanceTo(tip) <= JoinTolerance
? BreakoffPath.End(next)
: BreakoffPath.Start(next);
}
}
}
/// <summary>
/// Splits each visible line or arc of <paramref name="entities"/> where another one ends on
/// its interior (a T-junction), so a joining line can end exactly where it meets other
/// geometry. The first piece keeps the entity's ID. Returns the IDs of every split entity's
/// pieces, keyed by its original ID.
/// </summary>
public static Dictionary<Guid, List<Guid>> SplitAtJunctions(List<Entity> entities)
{
var pool = entities.Where(e => e is Line or Arc && e.IsVisible && e.Layer?.IsVisible != false && !IsMark(e))
.ToList();
var ends = pool.SelectMany(e => new[] { BreakoffPath.Start(e), BreakoffPath.End(e) }).ToList();
var split = new Dictionary<Guid, List<Guid>>();
foreach (var host in pool)
{
var box = host.BoundingBox;
var cuts = new List<double>();
foreach (var point in ends)
{
if (point.X < box.Left - JoinTolerance || point.X > box.Right + JoinTolerance
|| point.Y < box.Bottom - JoinTolerance || point.Y > box.Top + JoinTolerance)
continue;
if (point.DistanceTo(BreakoffPath.Start(host)) <= JoinTolerance
|| point.DistanceTo(BreakoffPath.End(host)) <= JoinTolerance
|| host.ClosestPointTo(point).DistanceTo(point) > JoinTolerance)
continue;
var at = BreakoffPath.DistanceOf(new[] { host }, point);
if (cuts.All(c => System.Math.Abs(c - at) > JoinTolerance))
cuts.Add(at);
}
if (cuts.Count == 0)
continue;
cuts.Sort();
var bounds = new List<double> { 0 };
bounds.AddRange(cuts);
bounds.Add(host.Length);
var pieces = new List<Entity>();
for (var i = 0; i + 1 < bounds.Count; i++)
pieces.AddRange(BreakoffPath.Sub(new[] { host }, bounds[i], bounds[i + 1]));
pieces[0].Id = host.Id;
foreach (var piece in pieces)
piece.IsVisible = host.IsVisible;
var index = entities.IndexOf(host);
entities.RemoveAt(index);
entities.InsertRange(index, pieces);
split[host.Id] = pieces.Select(p => p.Id).ToList();
}
return split;
}
private static BreakoffLayout Fail(string error) => new(error);
// A stray open line ending on the outline (an unmarked etch line, say) would otherwise be
// chained into it and keep the loop from closing. Nothing with a free end can be part of a
// closed outline, so such entities are dropped, repeatedly, before chaining.
private static List<Entity> WithoutDanglingEnds(List<Entity> entities)
{
// Cluster endpoints that meet within the chain tolerance, using a grid of that size.
const double cell = Tolerance.ChainTolerance;
var grid = new Dictionary<(long, long), List<(Vector Point, int Cluster)>>();
var clusterOf = new int[entities.Count, 2];
var sizes = new List<int>();
for (var i = 0; i < entities.Count; i++)
for (var side = 0; side < 2; side++)
{
var point = side == 0 ? BreakoffPath.Start(entities[i]) : BreakoffPath.End(entities[i]);
var gx = (long)System.Math.Floor(point.X / cell);
var gy = (long)System.Math.Floor(point.Y / cell);
var cluster = -1;
for (var dx = -1; dx <= 1 && cluster < 0; dx++)
for (var dy = -1; dy <= 1 && cluster < 0; dy++)
if (grid.TryGetValue((gx + dx, gy + dy), out var near))
foreach (var (other, id) in near)
if (other.DistanceTo(point) <= cell)
{
cluster = id;
break;
}
if (cluster < 0)
{
cluster = sizes.Count;
sizes.Add(0);
}
if (!grid.TryGetValue((gx, gy), out var bucket))
grid[(gx, gy)] = bucket = new List<(Vector, int)>();
bucket.Add((point, cluster));
sizes[cluster]++;
clusterOf[i, side] = cluster;
}
// Peel entities with a free end until none is left; closed loops never peel.
var members = new List<int>[sizes.Count];
for (var i = 0; i < entities.Count; i++)
for (var side = 0; side < 2; side++)
(members[clusterOf[i, side]] ??= new List<int>()).Add(i);
var removed = new bool[entities.Count];
var queue = new Queue<int>(Enumerable.Range(0, entities.Count)
.Where(i => sizes[clusterOf[i, 0]] < 2 || sizes[clusterOf[i, 1]] < 2));
while (queue.Count > 0)
{
var i = queue.Dequeue();
if (removed[i])
continue;
removed[i] = true;
for (var side = 0; side < 2; side++)
{
var cluster = clusterOf[i, side];
if (--sizes[cluster] == 1)
foreach (var other in members[cluster])
if (!removed[other])
queue.Enqueue(other);
}
}
return entities.Where((_, i) => !removed[i]).ToList();
}
internal static bool IsMark(Entity e) =>
string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Layer?.Name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase);
// The planner closes contours far tighter than the import chain tolerance, and the outline
// is re-chained from any entity when the program is built, so every joint must be exact.
internal static List<Entity> CloseExactly(IReadOnlyList<Entity> loop)
{
var result = new List<Entity>();
for (var i = 0; i < loop.Count; i++)
{
var current = loop[i];
var next = loop[(i + 1) % loop.Count];
result.Add(current);
var end = BreakoffPath.End(current);
var start = BreakoffPath.Start(next);
if (end.DistanceTo(start) == 0)
continue;
if (next is Line nextLine)
nextLine.StartPoint = end;
else if (current is Line currentLine)
currentLine.EndPoint = start;
else if (end.DistanceTo(start) > 1e-8)
result.Add(new Line(end, start) { Layer = current.Layer, Color = current.Color, LineTypeName = current.LineTypeName });
}
return result;
}
private static Vector Normal(IReadOnlyList<Entity> path, double length, double distance)
{
const double nudge = 1e-6;
var before = BreakoffPath.TangentAt(path, System.Math.Max(distance - nudge, 0));
var after = BreakoffPath.TangentAt(path, System.Math.Min(distance + nudge, length));
var t = new Vector(before.X + after.X, before.Y + after.Y);
var size = System.Math.Sqrt(t.X * t.X + t.Y * t.Y);
t = size <= Tolerance.Epsilon ? after : new Vector(t.X / size, t.Y / size);
return new Vector(-t.Y, t.X);
}
// +1 when the scrap lies left of the line's travel, -1 when right, 0 when unclear.
private static int SideOf(IReadOnlyList<Entity> line, double length, IReadOnlyList<Vector> scrap)
{
var probe = System.Math.Min(0.01, length * 0.01);
var votes = 0;
foreach (var fraction in new[] { 0.25, 0.5, 0.75 })
{
var d = length * fraction;
var p = BreakoffPath.PointAt(line, d);
var n = Normal(line, length, d);
var left = BreakoffPath.Contains(scrap, p + n * probe);
var right = BreakoffPath.Contains(scrap, p - n * probe);
if (left != right)
votes += left ? 1 : -1;
}
return System.Math.Sign(votes);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Breakoffs
{
/// <summary>
/// A <see cref="Breakoff"/> resolved against a drawing's entities. When <see cref="Error"/> is
/// set nothing else is meaningful and the breakoff is not applied.
/// </summary>
public sealed class BreakoffLayout
{
internal BreakoffLayout(string error) => Error = error;
internal BreakoffLayout() { }
public string Error { get; }
public bool IsValid => Error == null;
/// <summary>The joining line in travel order from its start.</summary>
public IReadOnlyList<Entity> BreakLine { get; internal set; }
public double Length { get; internal set; }
/// <summary>The closed outline around part and scrap, with its joints made exact.</summary>
public IReadOnlyList<Entity> Outline { get; internal set; }
/// <summary>The scrap side's closed boundary (part of the outline plus the joining line).</summary>
public IReadOnlyList<Entity> Scrap { get; internal set; }
/// <summary>
/// One open cut per section of the joining line between tabs, on the break layer and
/// travel-ordered with the scrap to its left, so each is entered from the scrap.
/// </summary>
public IReadOnlyList<IReadOnlyList<Entity>> Cuts { get; internal set; }
/// <summary>Tab centers used, as distances along <see cref="BreakLine"/>.</summary>
public IReadOnlyList<double> TabCenters { get; internal set; }
public double TabWidth { get; internal set; }
/// <summary>Entities the outline and cuts replace: the original outline and joining line.</summary>
internal IReadOnlyList<Entity> ReplacedEntities { get; set; }
public Vector PointAt(double distance) => BreakoffPath.PointAt(BreakLine, distance);
/// <summary>The distance along the joining line closest to <paramref name="point"/>.</summary>
public double DistanceOf(Vector point) => BreakoffPath.DistanceOf(BreakLine, point);
/// <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.
/// </summary>
public double ClampTabCenter(int index, double center)
{
var half = TabWidth / 2;
var min = index == 0 ? half : TabCenters[index - 1] + TabWidth + MinGap;
var max = index == TabCenters.Count - 1 ? Length - half : TabCenters[index + 1] - TabWidth - MinGap;
return max < min ? TabCenters[index] : System.Math.Clamp(center, min, max);
}
// Wider than the builder's overlap tolerance, so a clamped drag always resolves.
internal double MinGap => System.Math.Max(TabWidth / 2, 2 * BreakoffBuilder.JoinTolerance);
}
}
+253
View File
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Breakoffs
{
/// <summary>
/// Distance-based operations on a chain of lines and arcs, each oriented in travel order
/// (one's end is the next one's start). Splits and copies keep the source entity's style.
/// </summary>
internal static class BreakoffPath
{
public static Vector Start(Entity e) => e is Line l ? l.StartPoint : ((Arc)e).StartPoint();
public static Vector End(Entity e) => e is Line l ? l.EndPoint : ((Arc)e).EndPoint();
public static double Length(IReadOnlyList<Entity> path) => path.Sum(e => e.Length);
public static Vector PointAt(IReadOnlyList<Entity> path, double distance) =>
Locate(path, distance, out var entity, out var local) ? PointOn(entity, local) : End(path[^1]);
public static Vector TangentAt(IReadOnlyList<Entity> path, double distance)
{
Locate(path, distance, out var entity, out var local);
return TangentOn(entity, local);
}
/// <summary>The distance along the path of the path point closest to <paramref name="point"/>.</summary>
public static double DistanceOf(IReadOnlyList<Entity> path, Vector point)
{
var best = double.MaxValue;
var bestDistance = 0.0;
var walked = 0.0;
foreach (var e in path)
{
var closest = e.ClosestPointTo(point);
var gap = closest.DistanceTo(point);
if (gap < best)
{
best = gap;
bestDistance = walked + LocalDistance(e, closest);
}
walked += e.Length;
}
return bestDistance;
}
/// <summary>Copies of the part of <paramref name="path"/> from <paramref name="from"/> to <paramref name="to"/>.</summary>
public static List<Entity> Sub(IReadOnlyList<Entity> path, double from, double to)
{
var result = new List<Entity>();
var walked = 0.0;
foreach (var e in path)
{
var length = e.Length;
var a = System.Math.Max(from - walked, 0);
var b = System.Math.Min(to - walked, length);
walked += length;
if (b - a <= Tolerance.Epsilon)
continue;
result.Add(Piece(e, a, b));
}
return result;
}
/// <summary>Copies of <paramref name="path"/> in reverse travel order.</summary>
public static List<Entity> Reversed(IEnumerable<Entity> path)
{
var result = path.Select(Copy).ToList();
result.Reverse();
foreach (var e in result)
e.Reverse();
return result;
}
public static Entity Copy(Entity e)
{
var copy = e.Clone();
copy.Id = e.Id;
return copy;
}
/// <summary>Points along the path, arcs subdivided so no chord deviates more than <paramref name="tolerance"/>.</summary>
public static List<Vector> ToPoints(IEnumerable<Entity> path, double tolerance)
{
var points = new List<Vector>();
foreach (var e in path)
{
if (points.Count == 0)
points.Add(Start(e));
if (e is Arc arc)
points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance)).Skip(1));
else
points.Add(End(e));
}
return points;
}
/// <summary>Signed area (counterclockwise positive) of a closed point loop.</summary>
public static double SignedArea(IReadOnlyList<Vector> loop)
{
var sum = 0.0;
for (var i = 0; i < loop.Count; i++)
{
var a = loop[i];
var b = loop[(i + 1) % loop.Count];
sum += a.X * b.Y - b.X * a.Y;
}
return sum / 2;
}
public static bool Contains(IReadOnlyList<Vector> loop, Vector point)
{
var inside = false;
for (int i = 0, j = loop.Count - 1; i < loop.Count; j = i++)
{
var a = loop[i];
var b = loop[j];
if ((a.Y > point.Y) != (b.Y > point.Y)
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
inside = !inside;
}
return inside;
}
private static bool Locate(IReadOnlyList<Entity> path, double distance, out Entity entity, out double local)
{
var walked = 0.0;
foreach (var e in path)
{
var length = e.Length;
if (distance <= walked + length)
{
entity = e;
local = System.Math.Clamp(distance - walked, 0, length);
return true;
}
walked += length;
}
entity = path[^1];
local = entity.Length;
return false;
}
private static Vector PointOn(Entity e, double local)
{
if (e is Line line)
{
var t = line.Length <= 0 ? 0 : local / line.Length;
return line.StartPoint + (line.EndPoint - line.StartPoint) * t;
}
var arc = (Arc)e;
var angle = ArcAngleAt(arc, local);
return new Vector(arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static Vector TangentOn(Entity e, double local)
{
if (e is Line line)
{
var d = line.EndPoint - line.StartPoint;
var length = line.Length;
return length <= 0 ? new Vector(1, 0) : new Vector(d.X / length, d.Y / length);
}
var arc = (Arc)e;
var angle = ArcAngleAt(arc, local);
return arc.IsReversed
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
}
private static double ArcAngleAt(Arc arc, double local) =>
arc.StartAngle + (arc.IsReversed ? -1 : 1) * local / arc.Radius;
private static double LocalDistance(Entity e, Vector point)
{
if (e is Line line)
return line.StartPoint.DistanceTo(point);
var arc = (Arc)e;
var swept = Angle.NormalizeRad(arc.IsReversed
? arc.StartAngle - arc.Center.AngleTo(point)
: arc.Center.AngleTo(point) - arc.StartAngle);
var distance = swept * arc.Radius;
// A point a hair before the start normalizes to a full turn; it belongs at an end.
if (distance > arc.Length)
return point.DistanceTo(arc.StartPoint()) <= point.DistanceTo(arc.EndPoint()) ? 0 : arc.Length;
return distance;
}
private static Entity Piece(Entity e, double a, double b)
{
Entity piece;
if (e is Line line)
{
piece = new Line(PointOn(line, a), PointOn(line, b));
}
else
{
var arc = (Arc)e;
piece = new Arc(arc.Center, arc.Radius, Angle.NormalizeRad(ArcAngleAt(arc, a)),
Angle.NormalizeRad(ArcAngleAt(arc, b)), arc.IsReversed);
}
piece.Layer = e.Layer;
piece.Color = e.Color;
piece.LineTypeName = e.LineTypeName;
return piece;
}
/// <summary>
/// Orders <paramref name="edges"/> into one open chain, copied and oriented in travel
/// order, or returns null when they do not form exactly one open chain.
/// </summary>
public static List<Entity> ChainOpen(IReadOnlyList<Entity> edges, double tolerance)
{
if (edges.Count == 0 || edges.Any(e => e is not (Line or Arc)))
return null;
int Degree(Vector p) => edges.Count(e => Start(e).DistanceTo(p) <= tolerance)
+ edges.Count(e => End(e).DistanceTo(p) <= tolerance);
var first = edges.FirstOrDefault(e => Degree(Start(e)) == 1 || Degree(End(e)) == 1);
if (first == null)
return null;
var remaining = edges.ToList();
var chain = new List<Entity>();
var current = Copy(first);
if (Degree(Start(first)) != 1)
current.Reverse();
remaining.Remove(first);
chain.Add(current);
while (remaining.Count > 0)
{
var tip = End(chain[^1]);
var next = remaining.FirstOrDefault(e => Start(e).DistanceTo(tip) <= tolerance
|| End(e).DistanceTo(tip) <= tolerance);
if (next == null)
return null;
var copy = Copy(next);
if (Start(next).DistanceTo(tip) > tolerance)
copy.Reverse();
remaining.Remove(next);
chain.Add(copy);
}
return Start(chain[0]).DistanceTo(End(chain[^1])) <= tolerance ? null : chain;
}
}
}
+19 -12
View File
@@ -5,16 +5,22 @@ namespace OpenNest.CNC
{
public class ArcMove : Motion
{
public ArcMove()
{
}
public ArcMove() { }
public ArcMove(double x, double y, double i, double j, RotationType rotation = RotationType.CCW)
: this(new Vector(x, y), new Vector(i, j), rotation)
{
}
public ArcMove(
double x,
double y,
double i,
double j,
RotationType rotation = RotationType.CCW
)
: this(new Vector(x, y), new Vector(i, j), rotation) { }
public ArcMove(Vector endPoint, Vector centerPoint, RotationType rotation = RotationType.CCW)
public ArcMove(
Vector endPoint,
Vector centerPoint,
RotationType rotation = RotationType.CCW
)
{
EndPoint = endPoint;
CenterPoint = centerPoint;
@@ -68,7 +74,8 @@ namespace OpenNest.CNC
{
Layer = Layer,
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
VariableRefs =
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
};
}
@@ -85,9 +92,9 @@ namespace OpenNest.CNC
var i = CenterPoint.X.ToString(dp);
var j = CenterPoint.Y.ToString(dp);
return Rotation == RotationType.CW ?
string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j) :
string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j);
return Rotation == RotationType.CW
? string.Format("G02 X{0} Y{1} I{2} J{3}", x, y, i, j)
: string.Format("G03 X{0} Y{1} I{2} J{3}", x, y, i, j);
}
}
}
+2 -3
View File
@@ -1,5 +1,4 @@

namespace OpenNest.CNC
namespace OpenNest.CNC
{
public enum CodeType
{
@@ -9,6 +8,6 @@ namespace OpenNest.CNC
RapidMove,
SetFeedrate,
SetKerf,
SubProgramCall
SubProgramCall,
}
}
+1 -3
View File
@@ -2,9 +2,7 @@
{
public class Comment : ICode
{
public Comment()
{
}
public Comment() { }
public Comment(string value)
{
+193
View File
@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
/// <summary>
/// Turns a plate's recorded moves into a timed timeline: numbers the contours, drops
/// etch-only contours the post would skip, sorts contours into holes and outsides, and
/// times each move at its feed, with acceleration when a motion profile is set.
/// </summary>
internal static class ContourTiming
{
private const double ClosedTolerance = 1e-4;
public static List<CutSimulationStep> Finish(List<CutSimulationStep> steps, Plate plate, CutParameters parameters)
{
NumberContours(steps);
if (parameters.SkipScribe && RemoveScribeContours(ref steps))
NumberContours(steps);
ClassifyHoles(steps, plate);
var holeFeed = parameters.HoleFeedrate is > 0 ? parameters.HoleFeedrate.Value : parameters.Feedrate;
foreach (var step in steps)
{
step.NominalSpeed = step.Kind switch
{
CutSimulationStepKind.Rapid => Speed(parameters.RapidTravelRate, parameters.Units),
CutSimulationStepKind.Cut when step.IsHole => Speed(holeFeed, parameters.Units),
CutSimulationStepKind.Pierce => 0,
_ => Speed(parameters.Feedrate, parameters.Units),
};
step.Duration = step.Kind == CutSimulationStepKind.Pierce
? parameters.PierceTime
: step.NominalSpeed > 0 && step.Length > 0
? TimeSpan.FromSeconds(step.Length / step.NominalSpeed)
: TimeSpan.Zero;
}
if (parameters.Motion != null)
MotionPlanner.Plan(steps, parameters.Motion);
var clock = TimeSpan.Zero;
foreach (var step in steps)
{
step.StartTime = clock;
clock += step.Duration;
}
return steps;
}
// Inch rates are per minute, millimeter rates per second, as Timing.CalculateTime reads them.
private static double Speed(double rate, Units units) =>
rate <= 0 ? 0 : units == Units.Millimeters ? rate : rate / 60;
/// <summary>
/// A contour starts where the torch goes down after travel: at a pierce, at a scribe
/// after travel, on a new part, or where cutting switches to scribing or back.
/// </summary>
private static void NumberContours(List<CutSimulationStep> steps)
{
var contour = -1;
CutSimulationStep previous = null;
foreach (var step in steps)
{
if (step.Kind == CutSimulationStepKind.Rapid)
{
step.ContourIndex = -1;
}
else
{
var starts = previous == null
|| previous.Kind == CutSimulationStepKind.Rapid
|| step.Kind == CutSimulationStepKind.Pierce
|| previous.PartIndex != step.PartIndex
|| previous.Kind != CutSimulationStepKind.Pierce && previous.Kind != step.Kind;
if (starts)
contour++;
step.ContourIndex = contour;
}
previous = step;
}
}
/// <summary>
/// Leaves out contours that only scribe, as the CI Fiber post does by default, along with
/// the travel to them; travel is then rejoined around the gap. True when any were removed.
/// </summary>
private static bool RemoveScribeContours(ref List<CutSimulationStep> steps)
{
var scribeOnly = steps
.Where(s => s.ContourIndex >= 0)
.GroupBy(s => s.ContourIndex)
.Where(g => g.All(s => s.Kind == CutSimulationStepKind.Scribe))
.Select(g => g.Key)
.ToHashSet();
if (scribeOnly.Count == 0)
return false;
var kept = new List<CutSimulationStep>();
foreach (var step in steps)
{
if (!scribeOnly.Contains(step.ContourIndex))
{
kept.Add(step);
continue;
}
// Drop the travel that led to the skipped contour.
while (kept.Count > 0 && kept[^1].Kind == CutSimulationStepKind.Rapid)
kept.RemoveAt(kept.Count - 1);
}
var joined = new List<CutSimulationStep>(kept.Count);
var at = Vector.Zero;
foreach (var step in kept)
{
if (step.Kind == CutSimulationStepKind.Rapid)
{
if (at.DistanceTo(step.End) > Math.Tolerance.Epsilon)
joined.Add(Travel(at, step.End, step.PartIndex));
at = step.End;
continue;
}
if (at.DistanceTo(step.Start) > Math.Tolerance.Epsilon)
joined.Add(Travel(at, step.Start, step.PartIndex));
joined.Add(step);
at = step.End;
}
steps = joined;
return true;
}
private static CutSimulationStep Travel(Vector from, Vector to, int partIndex) =>
new()
{
Kind = CutSimulationStepKind.Rapid,
PartIndex = partIndex,
Start = from,
End = to,
Length = from.DistanceTo(to),
};
/// <summary>
/// Matches the CI Fiber post's interior/exterior choice (L2/L4): a cut-off, or a closed
/// contour wound clockwise, is exterior; everything else is a hole. Lead moves are
/// left out of the winding, as the post samples from the lead-in's end.
/// </summary>
private static void ClassifyHoles(List<CutSimulationStep> steps, Plate plate)
{
foreach (var contour in steps.Where(s => s.ContourIndex >= 0).GroupBy(s => s.ContourIndex))
{
var moves = contour.Where(s => s.Kind == CutSimulationStepKind.Cut).ToList();
if (moves.Count == 0)
continue;
var partIndex = moves[0].PartIndex;
var isCutOff = partIndex < plate.Parts.Count && plate.Parts[partIndex].BaseDrawing?.IsCutOff == true;
var isHole = !isCutOff && !IsClosedClockwise(moves);
foreach (var step in contour)
step.IsHole = isHole && step.Kind != CutSimulationStepKind.Scribe;
}
}
private static bool IsClosedClockwise(List<CutSimulationStep> moves)
{
var points = new List<Vector>();
foreach (var move in moves.Where(m => m.Layer is not (LayerType.Leadin or LayerType.Leadout)))
{
if (points.Count == 0)
points.Add(move.Start);
if (move.IsArc)
{
var samples = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Abs(move.Sweep) / (System.Math.PI / 2)));
for (var i = 1; i < samples; i++)
points.Add(move.PointAt((double)i / samples));
}
points.Add(move.End);
}
if (points.Count < 3 || points[0].DistanceTo(points[^1]) > ClosedTolerance)
return false;
var area = 0.0;
for (var i = 0; i < points.Count - 1; i++)
area += points[i].X * points[i + 1].Y - points[i + 1].X * points[i].Y;
return area < 0;
}
}
}
+484
View File
@@ -0,0 +1,484 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC
{
public enum CutSimulationStepKind
{
/// <summary>Head-up travel: a rapid, a gap between programs, or an uncut (display/tab) move.</summary>
Rapid,
/// <summary>The dwell before a cut that starts after travel.</summary>
Pierce,
/// <summary>Torch-on motion through material (cut, lead-in/out, break).</summary>
Cut,
/// <summary>Marking motion that does not cut through.</summary>
Scribe,
}
/// <summary>
/// One timed tool motion in plate coordinates. Arcs carry a signed sweep
/// (positive counterclockwise); lines and pierces leave it zero.
/// </summary>
public sealed class CutSimulationStep
{
public CutSimulationStepKind Kind { get; init; }
public int PartIndex { get; init; }
public Vector Start { get; init; }
public Vector End { get; init; }
public bool IsArc { get; init; }
public Vector Center { get; init; }
public double Radius { get; init; }
public double StartAngle { get; init; }
public double Sweep { get; init; }
public double Length { get; init; }
/// <summary>The program layer of a cutting or scribing move.</summary>
public LayerType Layer { get; init; }
/// <summary>
/// The contour this move or pierce belongs to, numbered in cutting order; -1 for travel.
/// </summary>
public int ContourIndex { get; internal set; } = -1;
/// <summary>
/// True when the move is part of an interior contour, timed at the hole feed. Follows the
/// CI Fiber post's rule: only a closed clockwise contour (or a cut-off) is exterior.
/// </summary>
public bool IsHole { get; internal set; }
/// <summary>Programmed speed in units per second, before acceleration.</summary>
public double NominalSpeed { get; internal set; }
public TimeSpan StartTime { get; internal set; }
public TimeSpan Duration { get; internal set; }
public TimeSpan EndTime => StartTime + Duration;
/// <summary>
/// Planned speed profile in units per second: accelerate from <see cref="EntrySpeed"/>
/// to <see cref="CruiseSpeed"/>, then decelerate to <see cref="ExitSpeed"/>. A zero
/// <see cref="Acceleration"/> moves at constant speed for the whole step.
/// </summary>
public double EntrySpeed { get; internal set; }
public double CruiseSpeed { get; internal set; }
public double ExitSpeed { get; internal set; }
public double Acceleration { get; internal set; }
/// <summary>Fraction (0..1) of this step's distance covered <paramref name="elapsed"/> into it.</summary>
public double ProgressAt(TimeSpan elapsed)
{
if (Duration <= TimeSpan.Zero)
return elapsed >= TimeSpan.Zero ? 1 : 0;
if (Acceleration <= 0 || Length <= 0)
return System.Math.Clamp(elapsed / Duration, 0, 1);
return System.Math.Clamp(DistanceAt(elapsed.TotalSeconds) / Length, 0, 1);
}
/// <summary>Head speed in units per second <paramref name="elapsed"/> into this step.</summary>
public double SpeedAt(TimeSpan elapsed)
{
if (Kind == CutSimulationStepKind.Pierce || Duration <= TimeSpan.Zero)
return 0;
if (Acceleration <= 0)
return Length / Duration.TotalSeconds;
var t = System.Math.Clamp(elapsed.TotalSeconds, 0, Duration.TotalSeconds);
var (accelEnd, cruiseEnd) = PhaseEnds();
if (t < accelEnd)
return EntrySpeed + Acceleration * t;
if (t < cruiseEnd)
return CruiseSpeed;
return System.Math.Max(0, CruiseSpeed - Acceleration * (t - cruiseEnd));
}
private (double AccelEnd, double CruiseEnd) PhaseEnds()
{
var accelTime = (CruiseSpeed - EntrySpeed) / Acceleration;
var accelDistance = (CruiseSpeed * CruiseSpeed - EntrySpeed * EntrySpeed) / (2 * Acceleration);
var decelDistance = (CruiseSpeed * CruiseSpeed - ExitSpeed * ExitSpeed) / (2 * Acceleration);
var cruise = CruiseSpeed > 0 ? System.Math.Max(0, Length - accelDistance - decelDistance) / CruiseSpeed : 0;
return (accelTime, accelTime + cruise);
}
private double DistanceAt(double t)
{
var (accelEnd, cruiseEnd) = PhaseEnds();
var accelDistance = EntrySpeed * accelEnd + Acceleration * accelEnd * accelEnd / 2;
if (t < accelEnd)
return EntrySpeed * t + Acceleration * t * t / 2;
if (t < cruiseEnd)
return accelDistance + CruiseSpeed * (t - accelEnd);
var decel = t - cruiseEnd;
return accelDistance + CruiseSpeed * (cruiseEnd - accelEnd) + CruiseSpeed * decel - Acceleration * decel * decel / 2;
}
/// <summary>Unit direction of travel at the start (0) or end (1) of the step.</summary>
internal Vector TangentAt(double fraction)
{
if (IsArc)
{
var angle = StartAngle + Sweep * fraction;
var sign = System.Math.Sign(Sweep);
return new Vector(-System.Math.Sin(angle) * sign, System.Math.Cos(angle) * sign);
}
return Length > 0 ? new Vector((End.X - Start.X) / Length, (End.Y - Start.Y) / Length) : Vector.Zero;
}
/// <summary>The tool position after <paramref name="fraction"/> (0..1) of this step.</summary>
public Vector PointAt(double fraction)
{
fraction = System.Math.Clamp(fraction, 0, 1);
if (IsArc)
{
var angle = StartAngle + Sweep * fraction;
return new Vector(
Center.X + Radius * System.Math.Cos(angle),
Center.Y + Radius * System.Math.Sin(angle));
}
return new Vector(
Start.X + (End.X - Start.X) * fraction,
Start.Y + (End.Y - Start.Y) * fraction);
}
}
/// <summary>
/// A replayable timeline of a plate's tool motion in cutting order. Cut and display-layer
/// moves cut (as the posts emit them), at the hole or outside feed. Suppressed moves and,
/// with <see cref="CutParameters.SkipScribe"/>, etch-only contours are left out. Adds
/// <see cref="CutParameters.Motion"/> acceleration when set (see <see cref="MotionPlanner"/>).
/// It does not model jerk, controller feed tables or clamp removal. See docs/cut-time-estimates.md.
/// </summary>
public sealed class CutSimulation
{
private readonly List<CutSimulationStep> steps;
private readonly List<TimeSpan> contourStarts = new();
private readonly List<TimeSpan> partStarts = new();
private CutSimulation(List<CutSimulationStep> steps, int partCount)
{
this.steps = steps;
PartCount = partCount;
TotalTime = steps.Count == 0 ? TimeSpan.Zero : steps[^1].EndTime;
// A contour (a cutout or a part's perimeter) begins where the torch goes down after travel.
var lastPart = -1;
var lastContour = -1;
foreach (var step in steps)
{
if (step.ContourIndex < 0 || step.ContourIndex == lastContour)
continue;
lastContour = step.ContourIndex;
contourStarts.Add(step.StartTime);
if (step.PartIndex != lastPart)
{
partStarts.Add(step.StartTime);
lastPart = step.PartIndex;
}
}
}
public IReadOnlyList<CutSimulationStep> Steps => steps;
public int PartCount { get; }
public TimeSpan TotalTime { get; }
/// <summary>When each contour's first cut (or its pierce) starts, in cutting order.</summary>
public IReadOnlyList<TimeSpan> ContourStarts => contourStarts;
/// <summary>When each part's first contour starts, in cutting order.</summary>
public IReadOnlyList<TimeSpan> PartStarts => partStarts;
public TimeSpan NextContour(TimeSpan time) => Next(contourStarts, time);
public TimeSpan PreviousContour(TimeSpan time) => Previous(contourStarts, time);
public TimeSpan NextPart(TimeSpan time) => Next(partStarts, time);
public TimeSpan PreviousPart(TimeSpan time) => Previous(partStarts, time);
// The first start after the playhead, or the end once none is left.
private TimeSpan Next(List<TimeSpan> starts, TimeSpan time)
{
foreach (var start in starts)
{
if (start > time)
return start;
}
return TotalTime;
}
// Like a track's previous button: back to the start of the current item, or the one
// before when the playhead is already there.
private static TimeSpan Previous(List<TimeSpan> starts, TimeSpan time)
{
for (var i = starts.Count - 1; i >= 0; i--)
{
if (starts[i] < time)
return starts[i];
}
return TimeSpan.Zero;
}
public static CutSimulation Build(Plate plate, CutParameters parameters)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(parameters);
var builder = new Builder();
for (var i = 0; i < plate.Parts.Count; i++)
{
var part = plate.Parts[i];
if (part.Program == null)
continue;
builder.PartIndex = i;
var mode = Mode.Absolute;
var pos = part.Location;
builder.AddProgram(part.Program, part.Location, ref mode, ref pos);
}
var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
return new CutSimulation(steps, plate.Parts.Count);
}
/// <summary>
/// Index of the step running at <paramref name="time"/>; <see cref="Steps"/>.Count
/// once the timeline has finished, -1 when it is empty.
/// </summary>
public int IndexAt(TimeSpan time)
{
if (steps.Count == 0)
return -1;
if (time >= TotalTime)
return steps.Count;
int lo = 0, hi = steps.Count - 1;
while (lo < hi)
{
var mid = (lo + hi + 1) / 2;
if (steps[mid].StartTime <= time)
lo = mid;
else
hi = mid - 1;
}
return lo;
}
/// <summary>
/// Fraction (0..1) of step <paramref name="index"/> completed at <paramref name="time"/>:
/// distance for motion, elapsed time for a pierce.
/// </summary>
public double FractionAt(int index, TimeSpan time)
{
var step = steps[index];
if (step.Kind == CutSimulationStepKind.Pierce)
return step.Duration <= TimeSpan.Zero
? time >= step.StartTime ? 1 : 0
: System.Math.Clamp((time - step.StartTime) / step.Duration, 0, 1);
return step.ProgressAt(time - step.StartTime);
}
/// <summary>
/// When step <paramref name="index"/> is <paramref name="fraction"/> complete: the inverse
/// of <see cref="FractionAt"/>, so a retimed timeline can find the same tool position.
/// </summary>
public TimeSpan TimeAt(int index, double fraction)
{
var step = steps[index];
if (step.Duration <= TimeSpan.Zero || fraction <= 0)
return step.StartTime;
if (fraction >= 1)
return step.EndTime;
// Progress never decreases through a step, so bisect its duration.
long lo = 0, hi = step.Duration.Ticks;
while (lo < hi)
{
var mid = lo + (hi - lo) / 2;
if (step.ProgressAt(TimeSpan.FromTicks(mid)) < fraction)
lo = mid + 1;
else
hi = mid;
}
return step.StartTime + TimeSpan.FromTicks(lo);
}
public Vector ToolPositionAt(TimeSpan time)
{
var index = IndexAt(time);
if (index < 0)
return Vector.Zero;
if (index >= steps.Count)
return steps[^1].End;
return steps[index].PointAt(FractionAt(index, time));
}
private sealed class Builder
{
private Vector tool = Vector.Zero;
private bool torchOn;
public List<CutSimulationStep> Steps { get; } = new();
public int PartIndex { get; set; }
// Sub-program offsets are relative to the calling frame's origin, as ConvertProgram reads them.
public void AddProgram(Program program, Vector frameOrigin, ref Mode mode, ref Vector pos)
{
mode = program.Mode;
for (var i = 0; i < program.Length; i++)
{
switch (program[i])
{
case SubProgramCall { Program: { } sub } call:
var savedMode = mode;
var subOrigin = frameOrigin + call.Offset;
pos = subOrigin;
AddProgram(sub, subOrigin, ref mode, ref pos);
mode = savedMode;
break;
case RapidMove rapid:
var rapidEnd = Resolve(rapid.EndPoint, mode, pos, frameOrigin);
AddTravel(tool, rapidEnd);
torchOn = false; // A rapid in place still ends the cut.
pos = rapidEnd;
break;
case Motion { Suppressed: true } suppressed:
// Posts leave suppressed moves out; the next move starts from here.
pos = Resolve(suppressed.EndPoint, mode, pos, frameOrigin);
break;
case LinearMove linear:
var lineEnd = Resolve(linear.EndPoint, mode, pos, frameOrigin);
AddMotion(linear.Layer, pos, lineEnd, isArc: false, default, default);
pos = lineEnd;
break;
case ArcMove arc:
var arcEnd = Resolve(arc.EndPoint, mode, pos, frameOrigin);
var center = Resolve(arc.CenterPoint, mode, pos, frameOrigin);
AddMotion(arc.Layer, pos, arcEnd, isArc: true, center, arc.Rotation);
pos = arcEnd;
break;
}
}
}
private static Vector Resolve(Vector point, Mode mode, Vector pos, Vector frameOrigin) =>
mode == Mode.Incremental ? point + pos : point + frameOrigin;
private static CutSimulationStepKind? KindOf(LayerType layer) =>
layer switch
{
// Cutting strategies emit contour moves on Display; the posts cut them.
LayerType.Cut or LayerType.Display or LayerType.Leadin or LayerType.Leadout or LayerType.Break
=> CutSimulationStepKind.Cut,
LayerType.Scribe => CutSimulationStepKind.Scribe,
_ => null, // Tab spans are never cut.
};
private void AddMotion(LayerType layer, Vector start, Vector end, bool isArc, Vector center, RotationType rotation)
{
if (KindOf(layer) is not { } kind)
{
AddTravel(tool, end);
return;
}
// A program that begins cutting away from the tool reaches its start head-up.
AddTravel(tool, start);
if (!torchOn && kind == CutSimulationStepKind.Cut)
{
Add(new CutSimulationStep
{
Kind = CutSimulationStepKind.Pierce,
PartIndex = PartIndex,
Start = start,
End = start,
});
}
torchOn = kind == CutSimulationStepKind.Cut;
if (isArc)
AddArc(kind, layer, start, end, center, rotation);
else
Add(new CutSimulationStep
{
Kind = kind,
Layer = layer,
PartIndex = PartIndex,
Start = start,
End = end,
Length = start.DistanceTo(end),
});
}
private void AddArc(CutSimulationStepKind kind, LayerType layer, Vector start, Vector end, Vector center, RotationType rotation)
{
var radius = center.DistanceTo(start);
var startAngle = center.AngleTo(start);
var endAngle = center.AngleTo(end);
var ccw = rotation == RotationType.CCW;
// Coincident ends are a full circle, as ConvertProgram reads them.
var sweep = ccw
? Angle.NormalizeRad(endAngle - startAngle)
: -Angle.NormalizeRad(startAngle - endAngle);
if (System.Math.Abs(sweep) < Tolerance.Epsilon)
sweep = ccw ? Angle.TwoPI : -Angle.TwoPI;
var length = System.Math.Abs(sweep) * radius;
Add(new CutSimulationStep
{
Kind = kind,
Layer = layer,
PartIndex = PartIndex,
Start = start,
End = end,
IsArc = true,
Center = center,
Radius = radius,
StartAngle = startAngle,
Sweep = sweep,
Length = length,
});
}
private void AddTravel(Vector start, Vector end)
{
var distance = start.DistanceTo(end);
if (distance <= Tolerance.Epsilon)
{
tool = end;
return;
}
torchOn = false;
Add(new CutSimulationStep
{
Kind = CutSimulationStepKind.Rapid,
PartIndex = PartIndex,
Start = start,
End = end,
Length = distance,
});
}
private void Add(CutSimulationStep step)
{
tool = step.End;
Steps.Add(step);
}
}
}
}
@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Which motions of an execution cut a break line: its lead-in and its break moves.</summary>
internal static class BreakCutMotions
{
/// <summary>True for every <see cref="LayerType.Break"/> motion and the lead-in run directly before one.</summary>
internal static bool[] Mask(IReadOnlyList<ExecutionMotion> motions)
{
var mask = new bool[motions.Count];
for (var i = 0; i < motions.Count; i++)
{
if (motions[i].Rapid || motions[i].Layer != LayerType.Break)
continue;
mask[i] = true;
for (var lead = i - 1; lead >= 0 && !motions[lead].Rapid && motions[lead].Layer == LayerType.Leadin; lead--)
mask[lead] = true;
}
return mask;
}
}
@@ -0,0 +1,56 @@
using System;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Automatic start preference, ordered: a lower value is more preferred.</summary>
internal enum AutomaticEntryKind
{
/// <summary>A convex turn of the contour's own travel, classified exactly as emission classifies it.</summary>
ConvexCorner = 0,
/// <summary>The midpoint of a straight edge.</summary>
StraightMidpoint = 1,
/// <summary>A tangent line/arc joint. A collinear line/line split is not a joint and never appears.</summary>
TangentJoint = 2,
/// <summary>
/// Tier 3 (fallback): a point on a straight edge meeting a convex corner, back from the
/// corner by about twice the applicable lead-in length, strictly inside the edge.
/// </summary>
NearCorner = 3,
/// <summary>
/// Tier 3 (fallback): the exact native closest point facing the caller's look-ahead
/// position (pass the arrival there when there is no next cut). Never a reflex/cusp vertex.
/// </summary>
TargetFacing = 4,
/// <summary>Tier 3 (fallback): the native midpoint of an arc entity.</summary>
ArcMidpoint = 5,
/// <summary>Tier 3 (fallback): one of the eight compass points of a whole circle.</summary>
CircleCompass = 6,
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
Pinned = 7,
}
/// <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.
/// </summary>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
{
/// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
/// equal points rank together regardless of the entity that produced them.
/// </summary>
internal (long X, long Y) GeometryKey => (Quantize(Choice.Point.X), Quantize(Choice.Point.Y));
private static long Quantize(double value) =>
(long)System.Math.Round(value / PostVerificationGeometry.Epsilon);
}
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Pure deterministic ordering of the automatic entry catalogue 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
/// filtering and the bounded selection belong to S07/S08, the wiring to S09.
/// </summary>
internal static class ContourEntryRanking
{
/// <summary>
/// Orders <paramref name="candidates"/> for one contour in local coordinates. With a
/// <paramref name="target"/> (the next cut's look-ahead point): the number of matched
/// facing sides of the candidate bounding rectangle descending (a corner on both facing
/// sides is ideal), then rank tier ascending, then arrival-&gt;entry + entry-&gt;target
/// ascending, then the stable geometric key. With no target (the last part): tier first,
/// then distance to <paramref name="arrival"/> — never toward the plate origin. The
/// input list is returned untouched as a new list; entity order is never meaningful.
/// </summary>
internal static IReadOnlyList<ContourEntryCandidate> RankTowardNextCut(
this IReadOnlyList<ContourEntryCandidate> candidates,
Vector? target = null,
Vector? arrival = null)
{
if (target.HasValue) PostVerificationGeometry.Validate(target.Value);
if (arrival.HasValue) PostVerificationGeometry.Validate(arrival.Value);
if (candidates.Count == 0)
return new List<ContourEntryCandidate>();
// Whole circles have no corners. A diagonal compass point is equally near two
// bounding-box sides, but must not gain the two-side bonus of a real corner.
// Rank outgoing travel first so arrival cannot pull the start away from the next
// cut. Retain every compass/polar alternative for feasibility and rapid checks;
// configured angle rounding remains the emitter's responsibility.
if (target is { } next && candidates.Any(c => c.Kind == AutomaticEntryKind.CircleCompass)
&& candidates.All(c => c.Kind is AutomaticEntryKind.CircleCompass or AutomaticEntryKind.TargetFacing))
return candidates.OrderBy(c => c.Choice.Point.DistanceTo(next))
.ThenBy(c => arrival is { } from ? c.Choice.Point.DistanceTo(from) : 0.0)
.ThenBy(c => c.GeometryKey.X)
.ThenBy(c => c.GeometryKey.Y)
.ToList();
// Candidate bounding rectangle in the contour's local coordinates; every candidate
// lies on the contour, so distances to the four side lines order side proximity.
var minX = double.PositiveInfinity;
var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity;
var maxY = double.NegativeInfinity;
foreach (var candidate in candidates)
{
var p = candidate.Choice.Point;
if (p.X < minX) minX = p.X;
if (p.X > maxX) maxX = p.X;
if (p.Y < minY) minY = p.Y;
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;
return candidates
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
.OrderByDescending(x => x.Score.Facing)
.ThenBy(x => x.Score.Tier)
.ThenBy(x => x.Score.Travel)
.ThenBy(x => x.Candidate.GeometryKey.X)
.ThenBy(x => x.Candidate.GeometryKey.Y)
.Select(x => x.Candidate)
.ToList();
}
/// <summary>
/// The ranking tier — coarser than the preference kind: outside corners first, then
/// straight midpoints and tangent joints as peers, then every fallback kind.
/// </summary>
internal static int RankTier(this AutomaticEntryKind kind) => kind switch
{
AutomaticEntryKind.ConvexCorner => 0,
AutomaticEntryKind.StraightMidpoint or AutomaticEntryKind.TangentJoint => 1,
_ => 2,
};
private static (int Facing, int Tier, double Travel) Score(
ContourEntryCandidate candidate,
double minX,
double minY,
double maxX,
double maxY,
bool facingRight,
bool facingTop,
Vector? target,
Vector? arrival)
{
var p = candidate.Choice.Point;
var facing = 0;
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.
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 travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
return (facing, candidate.Kind.RankTier(), travel);
}
}
@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using Curve = OpenNest.Diagnostics.PostVerificationGeometry.Curve;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Independently constructed expected geometry, frozen before final replay.
/// It is not a search verdict or the payload returned to the caller.</summary>
internal sealed class SelectedContourProgram
{
private readonly PreparedContours owner;
private readonly ContourChoice[] choices;
internal OwnedExecution Expected { get; }
internal double TabSize { get; }
internal SelectedContourProgram(PreparedContours owner, IReadOnlyList<ContourChoice> choices,
OwnedExecution expected, double tabSize)
{
this.owner = owner;
this.choices = choices.ToArray();
Expected = expected;
TabSize = tabSize;
}
internal bool Matches(PreparedContours prepared, IReadOnlyList<ContourChoice> selected)
{
if (!ReferenceEquals(owner, prepared) || !choices.SequenceEqual(selected)) return false;
prepared.ValidateCompleteChoices(selected);
return true;
}
}
/// <summary>Directed native boundary accounting and selected-emission correspondence.
/// No tessellation, topology inference, live source reads or final emission.</summary>
internal static class ContourProgramVerifier
{
private const double Epsilon = PostVerificationGeometry.Epsilon;
internal static bool Verify(OwnedExecution actual, LeadMaterialSnapshot material,
SelectedContourProgram selected, CancellationToken token)
{
var budget = 1000000;
if (material == null || !material.IsComplete) return false;
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
var visited = new HashSet<int>();
var piecesCut = new Dictionary<int, HashSet<int>>();
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
{
token.ThrowIfCancellationRequested();
if (run.Curves.Count == 0) return false;
var first = run.Curves[0];
var ringIndex = -1;
var entityIndex = -1;
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
{
var admitted = AdmittedClosureSlack(material.Rings[r]);
for (var e = 0; e < material.Rings[r].Length; e++)
{
Query(token, ref budget);
var curve = material.Rings[r][e];
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
ringIndex = r;
entityIndex = e;
break;
}
}
}
if (ringIndex < 0) return false;
var tabs = material.Tabs[ringIndex];
if (tabs.Any(tab => tab))
{
// A tabbed cutout: each run cuts exactly one span between tabs, whole and once.
if (!CutsTabbedSpan(run.Curves, material.Rings[ringIndex], tabs, entityIndex, token, ref budget)
|| !(piecesCut.TryGetValue(ringIndex, out var cut) ? cut : piecesCut[ringIndex] = new()).Add(entityIndex))
return false;
if (piecesCut[ringIndex].Count == SpanStarts(tabs).Count)
visited.Add(ringIndex);
continue;
}
if (!visited.Add(ringIndex)) return false;
var ring = material.Rings[ringIndex];
// Capture admits a nominal ring that closes within NominalContourClosure.Tolerance, and
// preparation agrees at that inclusive boundary. A rotated tool path bridges that one
// admitted joint, so this ring's positional and length agreement carries exactly that
// admitted slack, whether bridging it shortens or lengthens the cut.
var tolerance = AdmittedClosureSlack(ring);
var nominalLength = ring.Sum(c => c.Length);
var cutLength = run.Curves.Sum(c => c.Length);
var missing = nominalLength - cutLength;
// Bridging the admitted joint may add up to its gap; a second circuit/retrace never passes.
if (missing < -tolerance) return false;
if (missing > tolerance)
{
// Fixed legacy payloads have no certified selected tab/entry metadata.
// A merely open contour or a caller's current tab switch proves nothing.
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > tolerance)
return false;
// SameEmission has already bound this exact terminal gap to the owned
// settings' independently emitted, rounded/clamped selected geometry.
}
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
true, tolerance, token, ref budget)) return false;
}
return visited.Count == material.Rings.Count;
}
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
private static List<int> SpanStarts(bool[] tabs) => Enumerable.Range(0, tabs.Length)
.Where(i => !tabs[i] && tabs[(i + tabs.Length - 1) % tabs.Length]).ToList();
// A run that starts exactly where a span between tabs starts and covers that span exactly,
// stopping at the next tab.
private static bool CutsTabbedSpan(IReadOnlyList<Curve> run, IReadOnlyList<Curve> ring, bool[] tabs,
int entityIndex, CancellationToken token, ref int budget)
{
var tolerance = AdmittedClosureSlack(ring);
if (!SpanStarts(tabs).Contains(entityIndex) || ring[entityIndex].DistanceAlong(run[0].Start) > tolerance)
return false;
var span = new List<Curve>();
for (var e = entityIndex; !tabs[e % tabs.Length]; e++)
span.Add(ring[e % tabs.Length]);
return System.Math.Abs(run.Sum(c => c.Length) - span.Sum(c => c.Length)) <= tolerance
&& Follows(run, span, 0, 0, false, tolerance, token, ref budget);
}
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
// tolerance, and only that admitted endpoint gap relaxes replay's positional agreement.
private static double AdmittedClosureSlack(IReadOnlyList<Curve> ring) => ring.Count == 0 ? Epsilon
: Epsilon + System.Math.Max(0.0, ring[^1].End.DistanceTo(ring[0].Start));
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
CancellationToken token, ref int budget)
{
var left = Runs(actual);
var right = Runs(expected);
if (left.Count != right.Count) return false;
for (var i = 0; i < left.Count; i++)
{
Query(token, ref budget);
var a = left[i];
var b = right[i];
if (a.Kind != b.Kind || a.Rapid != b.Rapid) return false;
if (a.Rapid)
{
// Arrival is supplied anew by replay, not captured expected geometry.
if (a.End.DistanceTo(b.End) > Epsilon) return false;
}
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|| !Follows(a.Curves, b.Curves, 0, 0, false, Epsilon, token, ref budget)) return false;
}
return true;
}
// Consume directed native arc-length from both streams. Subdivision, merged
// collinear moves and a cyclic ring's reindexing do not change coverage.
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
// unless the nominal ring closes only within the nominal closure tolerance).
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
{
foreach (var curve in actual)
{
var consumed = 0.0;
// Like the nominal side below, a remainder within the slack is consumed: a path that
// bridges the admitted joint leaves exactly that much on the edge it extends.
while (consumed < curve.Length - tolerance)
{
Query(token, ref budget);
if (index >= expected.Count)
{
if (!cyclic) return false;
index = 0;
}
var nominal = expected[index];
var remaining = nominal.Length - offset;
if (remaining <= 0)
{
index++;
offset = 0;
continue;
}
var length = System.Math.Min(remaining, curve.Length - consumed);
if (!nominal.SameDirection(curve, tolerance)
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > tolerance
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > tolerance)
return false;
consumed += length;
offset += length;
if (offset >= nominal.Length - tolerance)
{
index++;
offset = 0;
}
}
}
return cyclic || index == expected.Count || index == expected.Count - 1
&& expected[index].Length - offset <= tolerance;
}
private static List<Run> Runs(OwnedExecution execution)
{
var runs = new List<Run>();
foreach (var motion in execution.Motions)
{
var kind = motion.Layer is LayerType.Cut or LayerType.Display ? LayerType.Cut : motion.Layer;
if (motion.Rapid)
{
runs.Add(new Run(true, kind, motion.End));
continue;
}
if (motion.Length <= Epsilon) continue; // Zero travel contributes no coverage.
if (runs.Count == 0 || runs[^1].Rapid || runs[^1].Kind != kind)
runs.Add(new Run(false, kind, motion.End));
runs[^1].Curves.Add(motion.Curve);
}
return runs;
}
private static void Query(CancellationToken token, ref int budget)
{
token.ThrowIfCancellationRequested();
if (--budget < 0) throw new NotSupportedException("Replay contour accounting exceeds the native query limit.");
}
private sealed record Run(bool Rapid, LayerType Kind, Vector End)
{
internal List<Curve> Curves { get; } = new();
}
}
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
public enum CutPinStatus
{
/// <summary>The pin lies on exactly one contour, which no other pin claims.</summary>
Resolved,
/// <summary>The pin no longer lies on any contour of the drawing.</summary>
NotOnContour,
/// <summary>The pin lies on more than one contour, so which one it pins is unknown.</summary>
OnSeveralContours,
/// <summary>Another pin lies on the same contour; neither is used.</summary>
ContourPinnedTwice,
/// <summary>The drawing's program cannot be prepared, or is not in the frame its source offset describes.</summary>
UnsupportedGeometry,
}
/// <summary>
/// Where one drawing pin lands on a placed contour. <see cref="Point"/> is in the placed
/// part's local frame (add the part's location for plate coordinates): the exact native point
/// when resolved, otherwise where the pin maps to (<see cref="Vector.Invalid"/> when the drawing
/// frame is unknown). Ordinals match <see cref="PreparedContours"/> captured from the same
/// rotated clean program.
/// </summary>
public sealed record CutPinResolution(int PinIndex, ContourCutPin Pin, CutPinStatus Status, int ContourOrdinal,
int EntityOrdinal, Vector Point)
{
public bool IsResolved => Status == CutPinStatus.Resolved;
}
/// <summary>
/// Maps a drawing's source-frame pins onto its program contours at a placement rotation. A
/// pin never resolves by guessing: it must lie within the contour closure tolerance of exactly
/// one contour that no other pin claims, or it is reported unresolved with the reason.
/// </summary>
public static class CutPinResolver
{
/// <summary>
/// Resolves the drawing's pins for a part rotated by <paramref name="rotation"/> radians
/// about the drawing program's origin, as <see cref="Part.Rotate(double)"/> does.
/// </summary>
public static IReadOnlyList<CutPinResolution> Resolve(Drawing drawing, double rotation, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(drawing);
if (!double.IsFinite(rotation))
throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite.");
var pins = drawing.CutPins ?? new List<ContourCutPin>();
if (pins.Count == 0)
return Array.Empty<CutPinResolution>();
if (drawing.Program == null || !IsSourceFrame(drawing))
return Unsupported(pins);
var program = (Program)drawing.Program.Clone();
if (!Math.Tolerance.IsEqualTo(rotation, 0))
program.Rotate(rotation);
PreparedContours contours;
try
{
contours = PreparedContours.Capture(program, new CuttingParameters(), token);
}
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException)
{
return Unsupported(pins);
}
return Resolve(drawing, rotation, contours, token);
}
/// <summary>Resolves the pins of a placed part at its current rotation.</summary>
public static IReadOnlyList<CutPinResolution> Resolve(Part part, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(part);
return Resolve(part.BaseDrawing, part.Rotation, token);
}
/// <summary>
/// Resolves against contours the caller already captured. <paramref name="contours"/> must
/// come from the drawing's clean program rotated by <paramref name="rotation"/>.
/// </summary>
public static IReadOnlyList<CutPinResolution> Resolve(Drawing drawing, double rotation, PreparedContours contours,
CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(drawing);
ArgumentNullException.ThrowIfNull(contours);
if (!double.IsFinite(rotation))
throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite.");
var pins = drawing.CutPins ?? new List<ContourCutPin>();
if (pins.Count == 0)
return Array.Empty<CutPinResolution>();
if (!IsSourceFrame(drawing))
return Unsupported(pins);
var offset = drawing.Source?.Offset ?? Vector.Zero;
var results = new CutPinResolution[pins.Count];
for (var i = 0; i < pins.Count; i++)
{
token.ThrowIfCancellationRequested();
var pin = pins[i] ?? throw new ArgumentException($"Drawing pin {i + 1} is missing.", nameof(drawing));
var local = (pin.Point - offset).Rotate(rotation);
var near = contours.ContoursNear(local, NominalContourClosure.Tolerance);
results[i] = near.Count switch
{
0 => new(i, pin, CutPinStatus.NotOnContour, -1, -1, local),
1 => new(i, pin, CutPinStatus.Resolved, near[0].ContourOrdinal, near[0].EntityOrdinal, near[0].Point),
_ => new(i, pin, CutPinStatus.OnSeveralContours, -1, -1, local),
};
}
// At most one pin per contour. Choosing between two would be a guess, so neither is used.
foreach (var claimed in results.Where(r => r.IsResolved).GroupBy(r => r.ContourOrdinal).Where(g => g.Count() > 1))
foreach (var result in claimed)
results[result.PinIndex] = result with { Status = CutPinStatus.ContourPinnedTwice };
return results;
}
// Pins are stored relative to the source entities; the program is that frame moved by
// -Source.Offset and never rotated in place. A rotated program (a canonical copy) is not.
private static bool IsSourceFrame(Drawing drawing) =>
drawing.Program != null && Math.Tolerance.IsEqualTo(drawing.Program.Rotation, 0);
private static CutPinResolution[] Unsupported(List<ContourCutPin> pins) =>
pins.Select((pin, i) => new CutPinResolution(i, pin, CutPinStatus.UnsupportedGeometry, -1, -1, Vector.Invalid))
.ToArray();
}
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>An owned planned program for one unlocked part; ownership passes to the part on Apply.</summary>
internal sealed class PlannedPartProgram
{
public PlannedPartProgram(Part part, Program program, CuttingParameters parameters)
{
Part = part;
Program = program;
Parameters = parameters;
}
public Part Part { get; }
public Program Program { get; }
public CuttingParameters Parameters { get; }
}
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
internal sealed class PlateCuttingPlan
{
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null)
{
Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray());
}
public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; }
}
public enum CuttingCommitStatus
{
Applied,
Stale,
InvalidInput,
Cancelled,
Failed
}
/// <summary>
/// Applied means every plate in scope holds its new state. RefreshErrors are observer failures
/// raised after that consistent state was published; they are not a rollback. Every other
/// status leaves every plate exactly as it was.
/// </summary>
public sealed class CuttingCommitResult
{
internal CuttingCommitResult(CuttingCommitStatus status, string message = null, Plate plate = null,
Exception error = null, IEnumerable<Exception> refreshErrors = null)
{
Status = status;
Message = message;
Plate = plate;
Error = error;
RefreshErrors = Array.AsReadOnly((refreshErrors ?? []).ToArray());
}
public CuttingCommitStatus Status { get; }
public string Message { get; }
public Plate Plate { get; }
public Exception Error { get; }
public IReadOnlyList<Exception> RefreshErrors { get; }
}
/// <summary>
/// Installs verified cutting plans for a whole scope at once. Nothing is searched, emitted,
/// rotated or regenerated here: inputs are validated and checked for freshness, bounds are
/// staged, then order and programs are installed synchronously and published once per plate.
/// Internal: it checks root program references only, so payloads must be owned copies of
/// independently replayed proposals. CuttingPlanService.Apply is the public entry point.
/// </summary>
internal static class CuttingPlanCommit
{
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token = default) =>
Apply(plans, token, null);
// beforeInstall is a test seam that runs inside the install boundary, before each program.
internal static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> plans, CancellationToken token,
Action<Plate, Part> beforeInstall)
{
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var scope = plans?.ToArray();
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
return Invalid(null, "A nonempty set of captured plate plans is required.");
var plates = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
foreach (var plan in scope)
if (!plates.Add(plan.Expected.Plate))
return Invalid(plan.Expected.Plate, "A plate appears more than once in the commit scope.");
// Freshness for the whole scope before any validation that reads live geometry.
foreach (var plan in scope)
{
string difference;
try
{
difference = plan.Expected.Difference(token);
}
catch (OperationCanceledException)
{
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
}
if (difference != null)
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
}
var staged = new List<Staged>(scope.Length);
var targets = new HashSet<Part>(ReferenceEqualityComparer.Instance);
var members = new HashSet<Part>(ReferenceEqualityComparer.Instance);
var installed = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var live = new HashSet<Program>(scope.SelectMany(p => p.Expected.Order).Select(p => p.Program),
ReferenceEqualityComparer.Instance);
foreach (var plan in scope)
{
var plate = plan.Expected.Plate;
Part[] order;
try
{
order = plate.Parts.ValidateReorder(plan.Order);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
return Invalid(plate, "The planned order is not exactly the plate's current parts.");
}
// A part on two plates would let one plate's install change the other's verified program.
if (order.Any(part => !members.Add(part)))
return Invalid(plate, "A part appears on more than one plate in the commit scope.");
var onPlate = new HashSet<Part>(order, ReferenceEqualityComparer.Instance);
var programs = new List<(Part, Program, Box, CuttingParameters)>();
foreach (var planned in plan.Programs)
{
if (planned?.Part == null || !onPlate.Contains(planned.Part) || !targets.Add(planned.Part))
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
if (planned.Part.LeadInsLocked)
return Invalid(plate, "A locked part's program is retained exactly and cannot be replaced.");
if (planned.Program == null || planned.Parameters == null || live.Contains(planned.Program)
|| !installed.Add(planned.Program))
return Invalid(plate, "Planned programs and settings must be present, owned and unshared.");
if (!planned.Program.Codes.Any(code => code is Motion
|| code is SubProgramCall call && call.Program?.Codes.Any(sub => sub is Motion) == true))
return Invalid(plate, "A planned program has no motion.");
Box bounds;
try
{
bounds = planned.Program.BoundingBox();
bounds.Offset(planned.Part.Location);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Invalid(plate, "A planned program's bounds cannot be computed.");
}
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
}
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
}
// Last cancellation point. The boundary below has no await, search or geometry work.
if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
try
{
foreach (var item in staged)
{
undo.Add((item.Plate, item.Plate.Parts.ToArray(),
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
item.Plate.Parts.SetOrder(item.Order);
foreach (var (part, program, bounds, parameters) in item.Programs)
{
beforeInstall?.Invoke(item.Plate, part);
part.InstallPlannedProgram(program, bounds, parameters);
}
}
}
catch (Exception ex)
{
for (var i = undo.Count - 1; i >= 0; i--)
{
foreach (var (part, state) in undo[i].States)
part.RestoreCuttingState(state);
undo[i].Plate.Parts.SetOrder(undo[i].Order);
}
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
undo.Count == 0 ? null : undo[^1].Plate, ex);
}
// Publish only after the whole scope is consistent.
var errors = new List<Exception>();
foreach (var item in staged.Where(s => s.Changed))
item.Plate.Parts.RaiseItemsReordered(errors);
return new(CuttingCommitStatus.Applied, errors.Count == 0 ? null
: "Applied; one or more views failed to refresh.", refreshErrors: errors);
}
private static CuttingCommitResult Invalid(Plate plate, string message) =>
new(CuttingCommitStatus.InvalidInput, message, plate);
private sealed record Staged(Plate Plate, Part[] Order,
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
}
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Guarded read of stable placed programs; never clones or mutates their graphs.</summary>
public static class ExecutionMotionReader
{
public static OwnedExecution Read(Program program, Vector origin, Vector? previous,
CancellationToken token)
=> ReadCore(program, origin, previous, token, false);
/// <summary>Read only exact built-in runtime types and defined program modes.
/// Validate the original graph before cloning: Clone can erase unsupported subclasses.
/// Inputs must remain stable during this bounded, cancellation-aware read.</summary>
public static OwnedExecution ReadSupported(Program program, Vector origin, Vector? previous,
CancellationToken token = default)
=> ReadCore(program, origin, previous, token, true);
private static OwnedExecution ReadCore(Program program, Vector origin, Vector? previous,
CancellationToken token, bool requireSupportedTypes)
{
var moves = new List<ExecutionMotion>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000;
Walk(program, origin, previous);
return new OwnedExecution(moves);
Vector Walk(Program current, Vector frame, Vector? arrival)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame);
if (current?.Codes == null || !visiting.Add(current) || visiting.Count > 64)
throw new ArgumentException("Missing, recursive or excessively nested program.");
if (requireSupportedTypes && (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode)))
throw new NotSupportedException("Unsupported program runtime type or mode.");
var pos = frame;
var first = true;
var countBefore = moves.Count;
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null)
throw new ArgumentException("Program contains a missing instruction.");
if (requireSupportedTypes)
{
var type = code.GetType();
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
throw new NotSupportedException("Unsupported instruction runtime type.");
}
if (code is SubProgramCall call)
{
if (!double.IsFinite(call.Rotation))
throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos);
first = false;
continue;
}
if (code is not Motion motion)
{
if (code is not (Comment or Feedrate or Kerf))
throw new NotSupportedException("Unsupported program instruction.");
continue;
}
// Posts disagree about incremental position after suppressed instructions.
// Never silently certify a trajectory whose semantics are ambiguous.
if (motion.Suppressed)
throw new NotSupportedException("Suppressed motion requires post-specific verification.");
if (motion is not (RapidMove or LinearMove or ArcMove))
throw new NotSupportedException("Unsupported motion.");
var reference = current.Mode == Mode.Incremental ? pos : frame;
var end = reference + motion.EndPoint;
PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove;
if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null));
var start = first && rapid ? arrival : pos;
var layer = motion switch
{
LinearMove line => line.Layer,
ArcMove arc => arc.Layer,
_ => LayerType.Display
};
if (!Enum.IsDefined(layer))
throw new NotSupportedException("Unsupported motion layer.");
if (motion is ArcMove direction && !Enum.IsDefined(direction.Rotation))
throw new NotSupportedException("Unsupported arc direction.");
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve));
pos = end;
first = false;
}
visiting.Remove(current);
if (moves.Count == countBefore)
throw new ArgumentException("Program has no motions.");
return pos;
}
}
}
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Pre-post review of fixed emissions. Plan Cutting accepts a locked program's leads and a
/// drawing-pinned contour's leads as given and warns (D1a), so posting checks them again against
/// every part's clean material on the plate and the active clamps. It also reports a part with
/// active drawing pins whose program does not cut its pinned contours as pinned. Reports only;
/// nothing is repaired and an incomplete check is never a clear result.
/// </summary>
internal static class FixedLeadReview
{
/// <param name="fixedMotions">Receives, per reviewed part index, which of its placed program's
/// motions this review owns, so other lead checks do not report them twice.</param>
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
IReadOnlyList<ClampZone> clamps, CancellationToken token,
IDictionary<int, Func<int, bool>> fixedMotions = null)
{
var parts = plate.Parts;
var reviewed = Enumerable.Range(0, parts.Count)
.Where(i => parts[i] is { BaseDrawing.IsCutOff: false } part
&& (part.LeadInsLocked || part.HasActiveDrawingPins)).ToArray();
if (reviewed.Length == 0)
return;
var materials = new LeadMaterialSnapshot[parts.Count];
var uncaptured = new List<int>();
for (var index = 0; index < parts.Count; index++)
{
token.ThrowIfCancellationRequested();
var part = parts[index];
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
continue;
try
{
// Scribe-only drawings are marks, not material.
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display))
continue;
materials[index] = LeadMaterialSnapshot.Capture(Clean(part), part.Location, token);
}
catch (Exception exception) when (IsInvalid(exception))
{
// The overlap and rapid checks already report unreadable programs.
}
if (materials[index] is not { IsComplete: true })
uncaptured.Add(index);
}
var others = materials.Select((material, index) => (Material: material, Index: index))
.Where(m => m.Material is { IsComplete: true }).ToArray();
// Leads near material that could not be captured are not checked: say so once per plate.
foreach (var index in uncaptured)
findings.Add(Incomplete(index, (materials[index]?.Reason ?? "the clean material could not be captured.")
+ " Fixed leads near this part are not checked."));
foreach (var index in reviewed)
{
token.ThrowIfCancellationRequested();
var part = parts[index];
OwnedExecution execution;
try
{
execution = ExecutionMotionReader.Read(part.Program, part.Location, null, token);
}
catch (Exception exception) when (IsInvalid(exception))
{
continue; // The rapid check reports the unreadable program.
}
// Which motions are fixed: a locked program's every motion; otherwise the motions
// that cut each active pin's contour exactly as pinned. Anything else was planned.
int?[] pins = null;
if (part.LeadInsLocked)
{
if (part.HasActiveDrawingPins)
NotApplied(index, "the part's locked lead-ins override them. Use Plate > Remove Lead-ins, "
+ "then Plan Cutting, to apply the pins.");
}
else
{
pins = PinnedMotions(plate, part, execution, reason => NotApplied(index, reason), token);
if (pins == null || pins.All(p => p == null))
continue;
}
bool IsFixed(int motion) => pins == null || pins[motion] != null;
if (fixedMotions != null)
fixedMotions[index] = IsFixed;
if (materials[index] is { IsComplete: true } own)
{
var failures = new List<FixedLeadFailure>();
var result = LeadPathValidator.Check(execution, own, others.Select(o => o.Material).ToArray(),
IsFixed, failures, token);
if (!result.IsComplete)
findings.Add(Incomplete(index, result.Reason));
foreach (var failure in failures)
{
var obstacle = failure.Obstacle is int other ? others[other].Index + 1 : (int?)null;
var what = !failure.Result.IsComplete ? $"could not be checked ({failure.Result.Reason})"
: obstacle != null ? $"contacts or enters the material of part {obstacle}"
: "contacts or enters its own part's material";
Lead(index, execution, failure.Motion, pins?[failure.Motion], what, obstacle, null, null);
}
}
for (var motion = 0; motion < execution.Motions.Count && clamps is { Count: > 0 }; motion++)
{
var move = execution.Motions[motion];
if (!IsFixed(motion) || move.Rapid || move.Start is not { } from
|| move.Layer is not (LayerType.Leadin or LayerType.Leadout))
continue;
foreach (var zone in clamps)
if (PostVerificationAnalyzer.CutTouches(move, from, zone.Bounds, out var contact))
Lead(index, execution, motion, pins?[motion], $"touches active clamp '{zone.Id}'", null,
zone.Id, contact);
}
}
void Lead(int index, OwnedExecution execution, int motion, int? pin, string what, int? obstacle,
string clampId, Vector? contact)
{
var lead = pin is int number ? $"Drawing pin {number} lead" : "Locked lead";
var move = execution.Motions[motion];
findings.Add(new(PostVerificationKind.FixedLead, plateNumber, index + 1, obstacle,
$"{lead} on cutting contour {ContourAt(execution, motion)} {what}. "
+ "Planning kept it as given; review it before posting.")
{ Location = contact ?? move.Curve?.Midpoint ?? move.End, ClampId = clampId });
}
void NotApplied(int index, string reason) => findings.Add(new(PostVerificationKind.FixedLead,
plateNumber, index + 1, null, "Drawing pins not applied: " + reason));
PostVerificationFinding Incomplete(int index, string reason) =>
new(PostVerificationKind.Incomplete, plateNumber, index + 1, null, "Fixed lead check: " + reason);
}
/// <summary>
/// For each motion of an unlocked part's placed program, the one-based pin whose contour it
/// cuts exactly as pinned, found by re-emitting each pinned contour with the part's settings
/// and matching that emission motion for motion; null where none. Each pin that cannot be
/// found, or does not resolve, is reported through <paramref name="notApplied"/>.
/// </summary>
private static int?[] PinnedMotions(Plate plate, Part part, OwnedExecution execution,
Action<string> notApplied, CancellationToken token)
{
var parameters = part.CuttingParameters ?? plate.CuttingParameters ?? new CuttingParameters();
PreparedContours pinned;
IReadOnlyList<CutPinResolution> resolutions;
try
{
var clean = Clean(part);
var plain = PreparedContours.Capture(clean, parameters, token);
resolutions = CutPinResolver.Resolve(part.BaseDrawing, part.Rotation, plain, token);
pinned = PreparedContours.Capture(clean, parameters, resolutions, token);
}
catch (Exception exception) when (IsInvalid(exception))
{
notApplied($"the drawing pins could not be checked ({exception.Message}).");
return null;
}
foreach (var resolution in resolutions.Where(r => !r.IsResolved))
notApplied($"drawing pin {resolution.PinIndex + 1} no longer matches the geometry.");
foreach (var rejected in pinned.RejectedPins)
notApplied($"drawing pin {rejected.PinIndex + 1} puts a tab on an internal contour.");
var owned = new int?[execution.Motions.Count];
for (var contour = 0; contour < pinned.Count; contour++)
{
token.ThrowIfCancellationRequested();
if (pinned.PinnedEntry(contour) is not { } entry)
continue;
var number = pinned.PinNumberOf(contour);
var expected = ExecutionMotionReader.Read(pinned.EmitCandidateForValidation(entry), part.Location, null, token)
.Motions.Where(m => !m.Rapid && m.Layer != LayerType.Scribe).ToArray();
var at = Find(execution.Motions, expected);
if (at < 0)
{
notApplied($"drawing pin {number} is not cut as pinned; plan cutting to apply it.");
continue;
}
for (var offset = 0; offset < expected.Length; offset++)
owned[at + offset] = number;
}
return owned;
}
// The first place the expected emission occurs motion for motion in the placed program.
private static int Find(IReadOnlyList<ExecutionMotion> actual, ExecutionMotion[] expected)
{
const double epsilon = PostVerificationGeometry.Epsilon;
for (var start = 0; expected.Length != 0 && start + expected.Length <= actual.Count; start++)
{
var same = true;
for (var offset = 0; offset < expected.Length && same; offset++)
{
var a = actual[start + offset];
var e = expected[offset];
same = !a.Rapid && Kind(a.Layer) == Kind(e.Layer) && a.End.DistanceTo(e.End) <= epsilon
&& System.Math.Abs(a.Length - e.Length) <= epsilon
&& (a.Start is not { } from || e.Start is not { } to || from.DistanceTo(to) <= epsilon);
}
if (same)
return start;
}
return -1;
static LayerType Kind(LayerType layer) => layer == LayerType.Display ? LayerType.Cut : layer;
}
// The same clean, rotation-baked, drift-closed, seam-trimmed program that planning captures.
internal static Program Clean(Part part)
{
var clean = (Program)part.BaseDrawing.Program.Clone();
clean.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation);
IncrementalClosureDrift.Close(clean);
SeamOvercut.Trim(clean);
return clean;
}
/// <summary>
/// The one-based cutting contour a lead motion belongs to: a lead-in leads into the next cut
/// run, a lead-out leaves the last one. Scribes are not cutting contours.
/// </summary>
internal static int ContourAt(OwnedExecution execution, int motion)
{
var count = 0;
var cutting = false;
for (var index = 0; index < motion; index++)
{
var move = execution.Motions[index];
var cut = !move.Rapid && move.Layer is LayerType.Cut or LayerType.Display
&& move.Length > PostVerificationGeometry.Epsilon;
if (cut && !cutting)
count++;
cutting = cut;
}
return execution.Motions[motion].Layer == LayerType.Leadout ? System.Math.Max(count, 1) : count + 1;
}
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
}
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Rounding drift at an incremental contour's seam. Each incremental move is written at the source
/// CAM's precision, so a contour's end is a sum of rounded moves and can miss its start by more than
/// <see cref="NominalContourClosure.Tolerance"/> (converted PEP perimeters of a hundred polyline
/// moves written at 1e-6 miss by 1.4e-6). An absolute program has no such sum. A contour that misses
/// by no more than its moves could accumulate at that precision, and never more than
/// <see cref="Tolerance.Epsilon"/>, ends its last cut on its start; the next motion absorbs the
/// correction, so nothing after the contour moves. Gaps inside the closure tolerance are closed too:
/// material capture and preparation sum the moves differently, so a gap at that boundary can pass
/// one and fail the other. Contours closed within the native epsilon are left alone.
/// </summary>
internal static class IncrementalClosureDrift
{
/// <summary>Closes drifted contours of an owned program in place; returns how many were closed.</summary>
internal static int Close(Program program)
{
if (program?.Codes == null || program.Mode != Mode.Incremental)
return 0;
var closed = 0;
var run = new List<Motion>();
var start = new Vector();
var position = new Vector();
for (var index = 0; index <= program.Codes.Count; index++)
{
var code = index < program.Codes.Count ? program.Codes[index] : null;
if (code is Motion motion && IsContour(motion))
{
if (run.Count == 0)
start = position;
run.Add(motion);
position += motion.EndPoint;
continue;
}
if (run.Count > 0 && TryClose(program, run, start, position, index))
closed++;
run.Clear();
if (code is Motion other)
position += other.EndPoint;
}
return closed;
}
// The chains material capture closes: cut, display and tab moves between rapids and other layers.
private static bool IsContour(Motion motion) => !motion.Suppressed
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab };
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
{
var gap = start.DistanceTo(end);
// Each rounded move is off by at most half a quantum per axis.
var drift = System.Math.Min(Tolerance.Epsilon,
run.Count * NominalContourClosure.Tolerance * System.Math.Sqrt(0.5));
if (run.Count < 2 || !(gap > PostVerificationGeometry.Epsilon) || !(gap <= drift))
return false;
var last = run[^1];
if (last.VariableRefs is { Count: > 0 })
return false;
// The first motion after the contour starts where the contour used to end. A subprogram call
// first would run from the corrected end instead, so that contour is left alone.
Motion absorber = null;
for (; next < program.Codes.Count && absorber == null; next++)
{
if (program.Codes[next] is SubProgramCall)
return false;
absorber = program.Codes[next] as Motion;
}
if (absorber != null && (absorber is ArcMove || absorber.VariableRefs is { Count: > 0 }))
return false;
var correction = start - end;
var endPoint = last.EndPoint + correction;
if (last is ArcMove arc)
{
// Keep the arc's centre (relative to its start) equidistant from its moved end.
var chord = endPoint.DistanceTo(new Vector());
if (chord <= Tolerance.Epsilon)
return false;
var normal = new Vector(-endPoint.Y, endPoint.X) * (1 / chord);
var middle = endPoint * 0.5;
var offset = arc.CenterPoint - middle;
arc.CenterPoint = middle + normal * (offset.X * normal.X + offset.Y * normal.Y);
}
last.EndPoint = endPoint;
if (absorber != null)
absorber.EndPoint -= correction;
return true;
}
}
@@ -0,0 +1,35 @@
using System.Collections.Generic;
using OpenNest.Diagnostics;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Remembers whether a lead curve stays clear of another part's material, so a search that
/// re-checks the same emitted leads (every prefix of a part repeats its earlier contours' leads,
/// and backtracking revisits them) runs each native query set once. A hit returns exactly what
/// <see cref="LeadPathValidator.MaterialContact"/> returned for that curve and material and charges
/// the same native query budget. Curves match by exact bits and materials by reference; the
/// materials must not change while the memo is in use. Scans that threw are not remembered and
/// run again. One instance belongs to one single-threaded search; replay must not use one.
/// </summary>
internal sealed class LeadContactMemo
{
private readonly Dictionary<(PostVerificationGeometry.CurveKey Curve, LeadMaterialSnapshot Material), Scan> scans = new();
/// <summary>Remembered native queries answered without running (Debug cost tests).</summary>
internal long Hits { get; private set; }
internal bool TryGet(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, out Scan scan)
{
if (!scans.TryGetValue((curve.Key, material), out scan))
return false;
Hits++;
return true;
}
internal void Add(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, Scan scan) =>
scans[(curve.Key, material)] = scan;
/// <summary>A completed scan: whether it found contact and how many boundary queries it charged.</summary>
internal readonly record struct Scan(bool Contact, int Queries);
}
@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Owned nominal material: one simple perimeter minus disjoint simple holes.</summary>
public sealed class LeadMaterialSnapshot
{
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
string reason)
{
Rings = rings;
Tabs = tabs;
Reason = reason;
var extent = PostVerificationGeometry.Extent.None;
foreach (var ring in rings)
foreach (var curve in ring)
extent = extent.Union(curve.Extent);
Extent = extent;
BoundaryCount = rings.Sum(ring => ring.Length);
}
public bool IsComplete => Reason == null;
public string Reason { get; }
internal IReadOnlyList<PostVerificationGeometry.Curve[]> Rings { get; }
/// <summary>A conservative extent of every boundary's native contact reach; material lies within it.</summary>
internal PostVerificationGeometry.Extent Extent { get; }
/// <summary>The boundary curves of all rings: the native queries one full scan charges.</summary>
internal int BoundaryCount { get; }
/// <summary>
/// Per ring curve, whether it is a tabbed cutout's tab: material boundary that the
/// program never cuts. Aligned with <see cref="Rings"/>.
/// </summary>
internal IReadOnlyList<bool[]> Tabs { get; }
/// <summary>Capture a stable, clean, rotation-baked program, applying location once.
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
CancellationToken token = default)
{
try
{
var execution = ExecutionMotionReader.ReadSupported(cleanProgram, location, null, token);
var rings = new List<PostVerificationGeometry.Curve[]>();
var ringTabs = new List<bool[]>();
var chain = new List<PostVerificationGeometry.Curve>();
var chainTabs = new List<bool>();
foreach (var motion in execution.Motions)
{
token.ThrowIfCancellationRequested();
// Break cuts run through material that stays attached at tabs; they bound nothing.
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Break)
{
Finish();
continue;
}
// A tabbed cutout's tabs are never cut but still close its hole.
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab))
throw new ArgumentException("Material capture contains an unsupported layer.");
var curve = motion.Curve;
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contains a degenerate motion.");
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve);
chainTabs.Add(motion.Layer == LayerType.Tab);
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish();
}
Finish();
if (rings.Count == 0)
throw new ArgumentException("Material has no closed contour.");
var budget = 1000000;
// Certify simple rings and mutually disjoint boundaries before containment.
for (var r = 0; r < rings.Count; r++)
for (var s = r; s < rings.Count; s++)
for (var i = 0; i < rings[r].Length; i++)
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Material validation exceeds the native query limit.");
var a = rings[r][i];
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
// Native arc queries pad angles by 1e-5 radians, admitting
// intersections on an extension (distance grows with radius).
// At a shared arc joint require both actual finite spans;
// overlap and genuine second intersections still refuse.
if (arcJoint)
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// Native contacts at rounded arc/line joints can drift from the
// authored endpoint. Use the shared geometry tolerance only to
// recognize that joint; overlaps and other contacts still refuse.
if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
}
var outer = -1;
for (var i = 0; i < rings.Count; i++)
{
token.ThrowIfCancellationRequested();
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
{
if (outer >= 0)
throw new ArgumentException("Material perimeter is ambiguous.");
outer = i;
}
}
if (outer < 0)
throw new ArgumentException("Material must have exactly one enclosing perimeter.");
if (ringTabs[outer].Any(tab => tab))
throw new ArgumentException("A perimeter cannot have tabs.");
var holes = rings.Where((_, i) => i != outer).ToArray();
for (var i = 0; i < holes.Length; i++)
for (var j = i + 1; j < holes.Length; j++)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null);
void Finish()
{
if (chain.Count == 0)
return;
if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
{
var gap = chain[0].Start.DistanceTo(chain[^1].End);
throw new ArgumentException(FormattableString.Invariant(
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
}
rings.Add(chain.ToArray());
ringTabs.Add(chainTabs.ToArray());
chain.Clear();
chainTabs.Clear();
}
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), Array.Empty<bool[]>(), ex.Message);
}
}
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
{
var inside = false;
foreach (var curve in ring)
{
token.ThrowIfCancellationRequested();
if (curve.CrossesRay(point))
inside = !inside;
}
return inside;
}
}
@@ -0,0 +1,307 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>A complete unsafe result is distinct from an incomplete/unsupported check.</summary>
public sealed record LeadPathValidationResult(bool IsComplete, bool IsClear, string Reason);
/// <summary>
/// One lead motion of a fixed emission that failed validation. <see cref="Obstacle"/> indexes the
/// other materials passed to the check, or is null when the lead meets its own part's material
/// or could not be checked.
/// </summary>
internal sealed record FixedLeadFailure(int Motion, LeadPathValidationResult Result, int? Obstacle);
/// <summary>Certifies actual emitted native lead paths against owned nominal material.</summary>
public static class LeadPathValidator
{
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default) =>
Check(execution, target, otherMaterials, null, null, token);
/// <summary>
/// As the public check, and every lead-in pierce must also keep <paramref name="pierceRadius"/>
/// from all of the materials (see <see cref="CuttingStrategy.CuttingParameters.PierceDiameter"/>).
/// Zero checks paths only.
/// </summary>
public static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, double pierceRadius, CancellationToken token = default) =>
Check(execution, target, otherMaterials, null, null, token, pierceRadius: pierceRadius);
/// <summary>As the public check, remembering other-material results in <paramref name="memo"/>.</summary>
internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, LeadContactMemo memo, CancellationToken token,
double pierceRadius = 0) =>
Check(execution, target, otherMaterials, null, null, token, memo, pierceRadius);
/// <summary>
/// As the public check, but a lead motion that <paramref name="isFixed"/> owns (by motion
/// index) is part of a fixed emission: its failure is added to <paramref name="fixedFailures"/>
/// and checking continues. Any other failure is returned exactly as the public check returns it.
/// A positive <paramref name="pierceRadius"/> also requires each lead-in pierce to keep that
/// distance from every material; a contour pierced directly, without a lead-in, is not checked.
/// </summary>
internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, Func<int, bool> isFixed,
List<FixedLeadFailure> fixedFailures, CancellationToken token, LeadContactMemo memo = null,
double pierceRadius = 0)
{
token.ThrowIfCancellationRequested();
if (execution == null || target == null || otherMaterials == null)
return new(false, false, "Missing execution or material snapshots.");
if (!double.IsFinite(pierceRadius) || pierceRadius < 0)
return new(false, false, "Pierce zone radius must be finite and nonnegative.");
if (!target.IsComplete || otherMaterials.Any(m => m == null || !m.IsComplete))
return new(false, false, "Material snapshot is incomplete.");
try
{
var budget = 1000000;
for (var i = 0; i < execution.Motions.Count; i++)
{
token.ThrowIfCancellationRequested();
var move = execution.Motions[i];
if (move.Layer is not (LayerType.Leadin or LayerType.Leadout))
continue;
var (failure, obstacle) = CheckMotion(i);
if (failure == null)
continue;
if (isFixed?.Invoke(i) != true || fixedFailures == null)
return failure;
fixedFailures.Add(new(i, failure, obstacle));
}
// No lead motion is valid for direct contour piercing. The caller still
// checks rapid travel and contour accounting for the complete program.
return new(true, true, null);
(LeadPathValidationResult Failure, int? Obstacle) CheckMotion(int i)
{
var move = execution.Motions[i];
if (move.Rapid || move.Start is not { } start || move.Curve == null
|| !double.IsFinite(move.Length) || move.Length <= PostVerificationGeometry.Epsilon
|| start.DistanceTo(move.Curve.Start) > PostVerificationGeometry.Epsilon
|| move.End.DistanceTo(move.Curve.End) > PostVerificationGeometry.Epsilon)
return (new(false, false, "Lead motion is missing, degenerate or inconsistent."), null);
Vector? allowed = null;
var groupEdge = i;
var step = move.Layer == LayerType.Leadin ? 1 : -1;
while (groupEdge + step >= 0 && groupEdge + step < execution.Motions.Count
&& execution.Motions[groupEdge + step].Layer == move.Layer)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
groupEdge += step;
}
var adjacentIndex = groupEdge + step;
var genuineJoint = false;
var breakLead = false;
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
{
var adjacent = execution.Motions[adjacentIndex];
// A break cut lies inside its part's material, so its lead joins no boundary:
// the joint is the cut's own start (or end for a lead-out).
if (!adjacent.Rapid && adjacent.Layer == LayerType.Break
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
{
var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var cutJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
if (joint.DistanceTo(cutJoint) <= PostVerificationGeometry.Epsilon)
{
genuineJoint = true;
breakLead = true;
if (groupEdge == i)
allowed = joint;
}
}
else if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
&& adjacent.Curve != null && adjacent.Length > PostVerificationGeometry.Epsilon)
{
var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
foreach (var boundary in target.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
if (joint.DistanceTo(contourJoint) <= PostVerificationGeometry.Epsilon
&& boundary.SameSupport(adjacent.Curve)
&& boundary.Contains(adjacent.Curve.Start) && boundary.Contains(adjacent.End)
&& boundary.Contains(adjacent.Curve.Midpoint)
&& adjacent.Length <= boundary.Length + PostVerificationGeometry.Epsilon)
{
genuineJoint = true;
if (groupEdge == i)
allowed = joint;
break;
}
}
}
}
if (!genuineJoint)
return (new(true, false, "Lead chain has no genuine adjacent target contour entry or exit."), null);
if (allowed is { } jointPoint
&& (move.Layer == LayerType.Leadin ? start : move.End).DistanceTo(jointPoint) <= PostVerificationGeometry.Epsilon)
return (new(true, false, "A positive-length lead returns to its contour joint; contact is not endpoint-only."), null);
// A break cut's lead runs through its own breakoff scrap: entering the target's
// material is expected there, but touching any of its boundaries is not.
var failure = breakLead
? MaterialContact(move.Curve, target, null, ref budget, token, allowInterior: true)
: CheckMaterial(target, allowed);
if (failure != null)
return (new(true, false, breakLead
? $"Break cut lead motion {i} contacts its part's boundary ({failure}). Try a shorter lead-in or move the tabs."
: $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure})."), null);
var reach = move.Curve.Extent;
for (var other = 0; other < otherMaterials.Count; other++)
{
token.ThrowIfCancellationRequested();
var material = otherMaterials[other];
if (ReferenceEquals(material, target))
continue;
if (reach.IsClearOfQueriesFrom(material.Extent) ? FarInside(material) : OtherContact(material))
return (new(true, false, "Lead contacts or enters another placed material. "
+ "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. "
+ "For locked parts, edit the leads or unlock the part before replanning."), other);
}
return PierceZone();
// A lead-in reached by a rapid (or starting the program) starts at its pierce. A break
// cut's pierce lies in its own breakoff scrap, inside the part's material, so only
// others are checked.
(LeadPathValidationResult, int?) PierceZone()
{
if (pierceRadius <= 0 || move.Layer != LayerType.Leadin || i > 0 && !execution.Motions[i - 1].Rapid)
return (null, null);
if (!breakLead && Clearance(target) is { } own)
return (Short(own, "its own part's material"), null);
for (var other = 0; other < otherMaterials.Count; other++)
{
token.ThrowIfCancellationRequested();
var material = otherMaterials[other];
if (ReferenceEquals(material, target))
continue;
// Every boundary of material this far away is farther than the zone.
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
{
Charge(material.BoundaryCount);
continue;
}
if (Clearance(material) is { } distance)
return (Short(distance, "another placed part"), other);
}
return (null, null);
}
// The pierce's distance to the material when it is inside the zone, else null.
double? Clearance(LeadMaterialSnapshot material)
{
var distance = MaterialDistance(start, material, ref budget, token);
return IsInsideZone(distance, pierceRadius) ? distance : null;
}
LeadPathValidationResult Short(double distance, string what) =>
new(true, false, PierceZoneMessage(start, distance, what, pierceRadius)
+ " Try a longer lead-in, more part spacing or a smaller pierce diameter, then replan.");
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) =>
MaterialContact(move.Curve, material, permittedJoint, ref budget, token);
// Exactly the unmemoized check: a remembered scan charges the queries it ran
// and throws where running it again would exhaust the budget.
bool OtherContact(LeadMaterialSnapshot material)
{
if (memo == null)
return CheckMaterial(material, null) != null;
if (memo.TryGet(move.Curve, material, out var scan))
{
Charge(scan.Queries);
return scan.Contact;
}
var before = budget;
var contact = CheckMaterial(material, null) != null;
memo.Add(move.Curve, material, new(contact, before - budget));
return contact;
}
// Material too far from the lead for any native boundary query to report a contact
// (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran.
// Ray parity is still asked natively unless the lead lies beside the material's rows.
bool FarInside(LeadMaterialSnapshot material)
{
Charge(material.BoundaryCount);
var midpoint = move.Curve.Midpoint;
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
}
void Charge(int queries)
{
if (budget - queries < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
budget -= queries;
}
}
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
{
return new(false, false, ex.Message);
}
}
/// <summary>
/// The exact native distance from <paramref name="point"/> to the nearest boundary of complete
/// material. Exceeding the budget throws.
/// </summary>
internal static double MaterialDistance(Vector point, LeadMaterialSnapshot material, ref int budget,
CancellationToken token)
{
var nearest = double.PositiveInfinity;
foreach (var boundary in material.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
nearest = System.Math.Min(nearest, boundary.DistanceTo(point));
}
return nearest;
}
/// <summary>True when material <paramref name="distance"/> away lies inside a pierce zone.</summary>
internal static bool IsInsideZone(double distance, double pierceRadius) =>
pierceRadius > 0 && distance < pierceRadius - PostVerificationGeometry.Epsilon;
internal static string PierceZoneMessage(Vector pierce, double distance, string what, double pierceRadius) =>
FormattableString.Invariant(
$"The pierce at ({pierce.X:0.####}, {pierce.Y:0.####}) is {distance:0.####} from {what}; ")
+ FormattableString.Invariant($"the {pierceRadius * 2:0.####} pierce diameter needs {pierceRadius:0.####}.");
/// <summary>
/// How a native lead curve contacts or enters complete material, or null when it stays clear.
/// A contact at <paramref name="permittedJoint"/> is allowed. Exceeding the budget throws.
/// </summary>
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
{
foreach (var boundary in material.Rings.SelectMany(r => r))
{
token.ThrowIfCancellationRequested();
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))
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;
}
}
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Whether one part's clean material lies strictly inside a cutout of another's. The cutting
/// planner's prerequisites and the pre-post review share this proof, so a pair the planner orders
/// inner-before-host is the pair posting checks. Bounds only select candidates; containment is
/// proven on native material, and any boundary contact or incomplete material is ambiguous.
/// </summary>
internal static class NestedPartContainment
{
private const int ContactBudget = 1000000;
/// <summary>
/// True when <paramref name="inner"/>'s perimeter lies strictly inside one cutout of
/// <paramref name="host"/>, false when it lies in none; otherwise null, with
/// <paramref name="ambiguity"/> saying why containment cannot be established.
/// </summary>
internal static bool? InsideCutout(LeadMaterialSnapshot inner, LeadMaterialSnapshot host,
CancellationToken token, out string ambiguity)
{
ambiguity = null;
if (!inner.IsComplete || !host.IsComplete)
{
ambiguity = inner.Reason ?? host.Reason;
return null;
}
var budget = ContactBudget;
foreach (var curve in inner.Rings[0])
foreach (var ring in host.Rings)
foreach (var boundary in ring)
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
{
ambiguity = "the containment check exceeds the native query limit";
return null;
}
if (curve.Contacts(boundary, out var overlap).Count != 0 || overlap)
{
ambiguity = "their boundaries touch or cross";
return null;
}
}
var probe = inner.Rings[0][0].Start;
return host.Rings.Skip(1).Any(hole => LeadMaterialSnapshot.Inside(probe, hole, token));
}
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
internal static bool Within(Box inner, Box outer) =>
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
/// <summary>The union of two boxes.</summary>
internal static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
}
}
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Pre-post review of parts nested in other parts' cutouts. Cutting a host's enclosing hole
/// releases the slug the nested part is cut from, so the nested part must be cut first; and the
/// host's hole leads must not cross the nested part, nor the nested part's leads enter the host.
/// Containment is proven exactly as the cutting planner proves it; an ambiguous pair is incomplete,
/// never clear. Motions a fixed-lead review already owns are not reported twice. Reports only.
/// </summary>
internal static class NestedPartReview
{
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
IReadOnlyDictionary<int, Func<int, bool>> fixedMotions, CancellationToken token)
{
var parts = plate.Parts;
var clean = new Program[parts.Count];
var cleanBounds = new Box[parts.Count];
var hostBounds = new Box[parts.Count];
for (var index = 0; index < parts.Count; index++)
{
token.ThrowIfCancellationRequested();
var part = parts[index];
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
continue;
try
{
// Scribe-only drawings are marks, not material.
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null))
continue;
var program = FixedLeadReview.Clean(part);
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
hostBounds[index] = NestedPartContainment.Union(part.BoundingBox, cleanBounds[index]);
clean[index] = program;
}
catch (Exception exception) when (IsInvalid(exception))
{
// The overlap and rapid checks already report unreadable programs.
}
}
var materials = new LeadMaterialSnapshot[parts.Count];
var executions = new OwnedExecution[parts.Count];
for (var inner = 0; inner < parts.Count; inner++)
for (var host = 0; host < parts.Count; host++)
{
token.ThrowIfCancellationRequested();
if (inner == host || clean[inner] == null || clean[host] == null
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host]))
continue;
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
out var ambiguity);
if (inside == null)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, inner + 1, host + 1,
$"Containment of part {inner + 1} relative to part {host + 1} cannot be established: "
+ $"{ambiguity}. Their cut order and leads are not checked."));
continue;
}
if (inside == false)
continue;
if (inner > host)
{
var bounds = cleanBounds[inner];
findings.Add(new(PostVerificationKind.NestedCutOrder, plateNumber, inner + 1, host + 1,
$"Part {inner + 1} lies inside a cutout of part {host + 1} but is cut after it, so cutting "
+ "the cutout releases its slug first. Plan Cutting cuts nested parts first; replan or "
+ "reorder before posting.")
{ Location = new Vector((bounds.Left + bounds.Right) / 2, (bounds.Bottom + bounds.Top) / 2) });
}
Leads(host, inner, $"part {inner + 1}, which is nested in this part's cutout");
Leads(inner, host, $"part {host + 1}, whose cutout this part is nested in");
}
LeadMaterialSnapshot Material(int index) =>
materials[index] ??= LeadMaterialSnapshot.Capture(clean[index], parts[index].Location, token);
void Leads(int from, int against, string what)
{
OwnedExecution execution;
try
{
execution = executions[from] ??= ExecutionMotionReader.Read(parts[from].Program,
parts[from].Location, null, token);
}
catch (Exception exception) when (IsInvalid(exception))
{
return; // The rapid check reports the unreadable program.
}
var owned = fixedMotions.TryGetValue(from, out var isFixed) ? isFixed : null;
var budget = 1000000;
for (var motion = 0; motion < execution.Motions.Count; motion++)
{
token.ThrowIfCancellationRequested();
var move = execution.Motions[motion];
if (move.Rapid || move.Layer is not (LayerType.Leadin or LayerType.Leadout)
|| owned?.Invoke(motion) == true
|| move.Curve == null || !(move.Length > PostVerificationGeometry.Epsilon))
continue;
string contact;
try
{
contact = LeadPathValidator.MaterialContact(move.Curve, materials[against], null, ref budget, token);
}
catch (Exception exception) when (IsInvalid(exception))
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, from + 1, against + 1,
"Nested part lead check: " + exception.Message));
return;
}
if (contact != null)
findings.Add(new(PostVerificationKind.NestedLead, plateNumber, from + 1, against + 1,
$"Lead on cutting contour {FixedLeadReview.ContourAt(execution, motion)} contacts or enters "
+ $"the material of {what}. Replan cutting or edit the lead before posting.")
{ Location = move.Curve.Midpoint });
}
}
}
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
}
@@ -0,0 +1,20 @@
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Closure of source contours during planner material capture and preparation.</summary>
internal static class NominalContourClosure
{
internal const double Tolerance = 0.000001;
internal static bool IsClosed(Vector start, Vector end)
{
// Subtracting translated/rotated coordinates can put an exact boundary gap
// a few floating-point ulps above the limit. Bound that allowance to 1e-12.
var scale = System.Math.Max(1, System.Math.Max(
System.Math.Max(System.Math.Abs(start.X), System.Math.Abs(start.Y)),
System.Math.Max(System.Math.Abs(end.X), System.Math.Abs(end.Y))));
var roundoff = System.Math.Min(1e-12, 4 * 2.2204460492503131e-16 * scale);
return start.DistanceTo(end) <= Tolerance + roundoff;
}
}
@@ -0,0 +1,140 @@
using System;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Copies only supported, exact setting types; never slices custom subclasses.</summary>
internal static class OwnedCuttingParameters
{
internal static CuttingParameters Copy(CuttingParameters source)
{
Exact<CuttingParameters>(source);
Exact<SequenceParameters>(source.Sequencing);
Exact<AssignmentParameters>(source.Assignment);
var sequence = source.Sequencing;
var assignment = source.Assignment;
if (!Enum.IsDefined(sequence.Method) || !Enum.IsDefined(assignment.Method))
throw new ArgumentException("Unsupported sequence method.");
if (assignment.Preference == null)
throw new ArgumentException("Missing assignment preference.");
if (!double.IsFinite(source.LeadInAngleIncrement) || source.LeadInAngleIncrement <= 0
|| source.LeadInAngleIncrement > 360)
throw new ArgumentException("Angle increment must be in (0, 360] degrees.");
return new CuttingParameters
{
Id = source.Id,
MachineName = source.MachineName,
MaterialName = source.MaterialName,
Grade = source.Grade,
Thickness = Dimension(source.Thickness),
Kerf = Dimension(source.Kerf),
PartSpacing = Dimension(source.PartSpacing),
PierceClearance = Dimension(source.PierceClearance),
PierceDiameter = Dimension(source.PierceDiameter),
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
RoundLeadInAngles = source.RoundLeadInAngles,
LeadInAngleIncrement = source.LeadInAngleIncrement,
AutoTabMinSize = Dimension(source.AutoTabMinSize),
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
TabConfig = source.TabConfig == null
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
: CopyTab(source.TabConfig),
TabsEnabled = source.TabsEnabled,
Sequencing = new SequenceParameters
{
Method = sequence.Method,
SmallCutoutWidth = Dimension(sequence.SmallCutoutWidth),
SmallCutoutHeight = Dimension(sequence.SmallCutoutHeight),
MediumCutoutWidth = Dimension(sequence.MediumCutoutWidth),
MediumCutoutHeight = Dimension(sequence.MediumCutoutHeight),
DistanceMediumSmall = Dimension(sequence.DistanceMediumSmall),
AlternateRowsColumns = sequence.AlternateRowsColumns,
AlternateCutoutsWithinRowColumn = sequence.AlternateCutoutsWithinRowColumn,
MinDistanceBetweenRowsColumns = Dimension(sequence.MinDistanceBetweenRowsColumns)
},
Assignment = new AssignmentParameters
{
Method = assignment.Method,
Preference = assignment.Preference,
MinGeometryLength = Dimension(assignment.MinGeometryLength)
}
};
}
internal static LeadIn CopyLeadIn(LeadIn source) => source switch
{
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) },
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
{
Length1 = Dimension(n.Length1),
Length2 = Dimension(n.Length2),
ApproachAngle1 = Angle(n.ApproachAngle1),
ApproachAngle2 = Angle(n.ApproachAngle2)
},
CleanHoleLeadIn n when n.GetType() == typeof(CleanHoleLeadIn) => new CleanHoleLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), Kerf = Dimension(n.Kerf) },
null => throw new ArgumentException("Missing lead-in settings."),
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
};
internal static LeadOut CopyLeadOut(LeadOut source) => source switch
{
NoLeadOut n when n.GetType() == typeof(NoLeadOut) => new NoLeadOut(),
LineLeadOut n when n.GetType() == typeof(LineLeadOut) => new LineLeadOut
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadOut n when n.GetType() == typeof(ArcLeadOut) => new ArcLeadOut { Radius = Dimension(n.Radius) },
null => throw new ArgumentException("Missing lead-out settings."),
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
};
internal static Tab CopyTab(Tab source)
{
Tab copy = source switch
{
NormalTab n when n.GetType() == typeof(NormalTab) => new NormalTab
{
CutoutMinWidth = Dimension(n.CutoutMinWidth),
CutoutMinHeight = Dimension(n.CutoutMinHeight),
CutoutMaxWidth = Dimension(n.CutoutMaxWidth),
CutoutMaxHeight = Dimension(n.CutoutMaxHeight)
},
BreakerTab n when n.GetType() == typeof(BreakerTab) => new BreakerTab
{
BreakerDepth = Dimension(n.BreakerDepth),
BreakerLeadInLength = Dimension(n.BreakerLeadInLength),
BreakerAngle = Angle(n.BreakerAngle)
},
MachineTab n when n.GetType() == typeof(MachineTab) => new MachineTab { MachineTabId = n.MachineTabId },
_ => throw new NotSupportedException("Unsupported tab runtime type.")
};
copy.Size = Dimension(source.Size);
copy.TabLeadIn = source.TabLeadIn == null ? null : CopyLeadIn(source.TabLeadIn);
copy.TabLeadOut = source.TabLeadOut == null ? null : CopyLeadOut(source.TabLeadOut);
return copy;
}
private static void Exact<T>(T value) where T : class
{
if (value == null)
throw new ArgumentException("Missing cutting settings.");
if (value.GetType() != typeof(T))
throw new NotSupportedException("Unsupported settings runtime type.");
}
private static double Dimension(double value) => double.IsFinite(value) && value >= 0
? value : throw new ArgumentException("Dimensions must be finite and nonnegative.");
// Approach angles are degrees, not lengths. Signed and periodic angles are valid.
private static double Angle(double value) => double.IsFinite(value)
? value : throw new ArgumentException("Angles must be finite.");
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Owned value motions; no references to live programs, instructions or settings.</summary>
public sealed class OwnedExecution
{
internal OwnedExecution(IEnumerable<ExecutionMotion> motions)
{
Motions = Array.AsReadOnly(motions.ToArray());
}
public IReadOnlyList<ExecutionMotion> Motions { get; }
public Vector DeparturePoint => Motions[^1].End;
public bool HasCuttingContour => Motions.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Length > PostVerificationGeometry.Epsilon);
public double RapidDistanceFrom(Vector arrival)
{
PostVerificationGeometry.Validate(arrival);
var total = 0.0;
for (var index = 0; index < Motions.Count; index++)
{
var move = Motions[index];
if (move.Rapid && (index == 0 ? arrival : move.Start) is { } start)
total += start.DistanceTo(move.End);
}
return total;
}
}
/// <summary>An immutable motion value; native line/arc geometry stays private to Core.</summary>
public sealed class ExecutionMotion
{
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve)
{
Start = start;
End = end;
Rapid = rapid;
Layer = layer;
Curve = curve;
}
public Vector? Start { get; }
public Vector End { get; }
public bool Rapid { get; }
public LayerType Layer { get; }
public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve);
}
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Lossless owned copies at the cutting-plan boundary, not a general Clone change.</summary>
internal static class OwnedProgramCopy
{
private const int Limit = 1000000;
private const int MaxDepth = 64;
internal static Program Copy(Program original, CancellationToken token = default)
{
Validate(original, token);
// Built-in Clone preserves mode/rotation and binds calls without rotating setters.
// Its per-parent maps can duplicate diamonds; restore one global owned graph below.
var cloned = (Program)original.Clone();
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
var bindings = new Dictionary<Dictionary<string, string>, Dictionary<string, string>>(ReferenceEqualityComparer.Instance);
return Restore(original, cloned);
Program Restore(Program source, Program copy)
{
token.ThrowIfCancellationRequested();
if (programs.TryGetValue(source, out var existing)) return existing;
programs.Add(source, copy);
for (var i = 0; i < source.Codes.Count; i++)
{
token.ThrowIfCancellationRequested();
var authored = source.Codes[i];
if (codes.TryGetValue(authored, out var owned))
{
copy.Codes[i] = owned;
continue;
}
owned = copy.Codes[i];
codes.Add(authored, owned);
if (authored is Motion motion)
{
var target = (Motion)owned;
// All other built-in fields are retained by their Clone implementations.
target.UseExactStop = motion.UseExactStop;
target.Feedrate = motion.Feedrate;
if (motion.VariableRefs != null)
{
if (!bindings.TryGetValue(motion.VariableRefs, out var refs))
{
refs = new Dictionary<string, string>(motion.VariableRefs, motion.VariableRefs.Comparer);
bindings.Add(motion.VariableRefs, refs);
}
target.VariableRefs = refs;
}
}
if (authored is SubProgramCall call)
{
var target = (SubProgramCall)owned;
target.BindProgram(Restore(call.Program, target.Program));
}
}
foreach (var (id, child) in source.SubPrograms)
{
token.ThrowIfCancellationRequested();
copy.SubPrograms[id] = Restore(child, copy.SubPrograms[id]);
}
return copy;
}
}
// Unlike the execution reader, inactive registered graphs can be motionless or
// contain suppressed motions. They still must be supported, acyclic and bounded.
internal static void Validate(Program program, CancellationToken token)
{
var active = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var done = new Dictionary<Program, (int Cost, int Depth)>(ReferenceEqualityComparer.Instance);
var budget = Limit;
Visit(program);
(int Cost, int Depth) Visit(Program current)
{
token.ThrowIfCancellationRequested();
if (current == null || active.Contains(current) || active.Count >= MaxDepth)
throw new ArgumentException("Missing, recursive or excessively nested clone graph.");
if (done.TryGetValue(current, out var previous))
{
if (active.Count + previous.Depth > MaxDepth)
throw new ArgumentException("Excessively nested clone graph.");
return previous;
}
if (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode))
throw new NotSupportedException("Unsupported program runtime type or mode.");
if (current.Codes == null)
throw new ArgumentException("Missing clone graph instructions.");
active.Add(current);
var children = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var cost = 1;
var depth = 1;
foreach (var code in current.Codes)
{
Step();
cost++;
if (code == null)
throw new ArgumentException("Missing clone graph instruction.");
var type = code.GetType();
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
throw new NotSupportedException("Unsupported instruction runtime type.");
if (code is SubProgramCall call) Child(call.Program);
}
foreach (var child in current.SubPrograms.Values)
{
Step();
Child(child);
}
if (cost > Limit)
throw new ArgumentException("Clone graph exceeds the verification limit.");
active.Remove(current);
var result = (cost, depth);
done.Add(current, result);
return result;
void Child(Program child)
{
var summary = Visit(child);
// Program.Clone shares children only within one parent; bound its real
// expanded work as well as the distinct original graph before calling it.
if (!children.Add(child)) return;
if (summary.Cost > Limit - cost)
throw new ArgumentException("Clone expansion exceeds the verification limit.");
cost += summary.Cost;
depth = System.Math.Max(depth, summary.Depth + 1);
}
}
void Step()
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
throw new ArgumentException("Clone graph exceeds the verification limit.");
}
}
}
@@ -0,0 +1,9 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Every Part field a cutting commit can change, held by reference for exact rollback.</summary>
internal sealed record PartCuttingState(Program Program, bool OwnsProgram, double PreLeadInRotation,
bool HasManualLeadIns, bool LeadInsLocked, CuttingParameters CuttingParameters, Vector Location,
Box BoundingBox);
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Pre-post review of pierce zones. When a part's cutting parameters set a
/// <see cref="CuttingParameters.PierceDiameter"/>, every lead-in pierce of its placed program must
/// keep half of it from every part's clean material on the plate, however the leads were made
/// (planned, assigned, placed by hand or locked). A contour pierced directly, without a lead-in,
/// is not checked. Reports only; an incomplete check is never a clear result.
/// </summary>
internal static class PierceZoneReview
{
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
CancellationToken token)
{
var parts = plate.Parts;
var radii = parts.Select(part => part is { BaseDrawing.IsCutOff: false }
? (part.CuttingParameters ?? plate.CuttingParameters)?.GetPierceZoneRadius() ?? 0 : 0).ToArray();
if (radii.All(radius => radius <= 0))
return;
var materials = new LeadMaterialSnapshot[parts.Count];
var uncaptured = new List<int>();
for (var index = 0; index < parts.Count; index++)
{
token.ThrowIfCancellationRequested();
var part = parts[index];
if (part?.BaseDrawing?.Program == null || part.BaseDrawing.IsCutOff)
continue;
try
{
// Scribe-only drawings are marks, not material.
if (!ExecutionMotionReader.Read(part.BaseDrawing.Program, Vector.Zero, null, token).Motions
.Any(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display))
continue;
materials[index] = LeadMaterialSnapshot.Capture(FixedLeadReview.Clean(part), part.Location, token);
}
catch (Exception exception) when (IsInvalid(exception))
{
// The overlap and rapid checks already report unreadable programs.
}
if (materials[index] is not { IsComplete: true })
uncaptured.Add(index);
}
// Pierces near material that could not be captured are not checked: say so once per part.
foreach (var index in uncaptured)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, index + 1, null,
"Pierce zone check: " + (materials[index]?.Reason ?? "the clean material could not be captured.")
+ " Pierces near this part are not checked."));
for (var index = 0; index < parts.Count; index++)
{
token.ThrowIfCancellationRequested();
if (radii[index] <= 0)
continue;
OwnedExecution execution;
try
{
execution = ExecutionMotionReader.Read(parts[index].Program, parts[index].Location, null, token);
}
catch (Exception exception) when (IsInvalid(exception))
{
continue; // The rapid check reports the unreadable program.
}
try
{
Review(index, execution, radii[index]);
}
catch (Exception exception) when (IsInvalid(exception))
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, index + 1, null,
"Pierce zone check: " + exception.Message));
}
}
void Review(int index, OwnedExecution execution, double radius)
{
var budget = 1000000;
var motions = execution.Motions;
for (var motion = 0; motion < motions.Count; motion++)
{
token.ThrowIfCancellationRequested();
var move = motions[motion];
// A lead-in reached by a rapid (or starting the program) starts at its pierce.
if (move.Rapid || move.Layer != LayerType.Leadin || move.Start is not { } pierce
|| motion > 0 && !motions[motion - 1].Rapid)
continue;
var end = motion;
while (end + 1 < motions.Count && !motions[end + 1].Rapid && motions[end + 1].Layer == LayerType.Leadin)
end++;
// A break cut's pierce sits in its breakoff scrap, inside its own part's material.
var breakLead = end + 1 < motions.Count && motions[end + 1].Layer == LayerType.Break;
for (var other = 0; other < parts.Count; other++)
{
if (materials[other] is not { IsComplete: true } material || other == index && breakLead)
continue;
var distance = LeadPathValidator.MaterialDistance(pierce, material, ref budget, token);
if (!LeadPathValidator.IsInsideZone(distance, radius))
continue;
var what = other == index ? "its own part's material" : $"the material of part {other + 1}";
findings.Add(new(PostVerificationKind.PierceZone, plateNumber, index + 1,
other == index ? null : other + 1,
$"Lead-in on cutting contour {FixedLeadReview.ContourAt(execution, motion)}: "
+ LeadPathValidator.PierceZoneMessage(pierce, distance, what, radius)
+ " Replan cutting or edit the lead before posting.")
{ Location = pierce });
}
}
}
}
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
}
@@ -0,0 +1,208 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact caller-thread record of everything a cutting proposal for one plate depends on:
/// part list instance and order, plate quantity/size/quadrant/settings, cutoff definitions, and
/// each part's complete cutting state with owned copies of its placed and drawing programs,
/// drawing cutoff classification, drawing pins and pins-off, and exact settings content.
/// A commit compares it with the live plate and refuses when anything differs.
/// </summary>
public sealed class PlateCuttingState
{
private readonly ObservableList<Part> partList;
private readonly PartRecord[] parts;
private readonly int quantity;
private readonly Size size;
private readonly int quadrant;
private readonly ObservableList<CutOff> cutOffList;
private readonly CutOffRecord[] cutOffs;
private readonly Dictionary<Drawing, DrawingRecord> drawings;
private readonly Dictionary<CuttingParameters, string> settings;
private readonly CuttingParameters plateSettings;
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
{
this.settings = settings;
plateSettings = plate.CuttingParameters;
Plate = plate;
partList = plate.Parts;
this.parts = parts;
quantity = plate.Quantity;
size = plate.Size;
quadrant = plate.Quadrant;
cutOffList = plate.CutOffs;
this.cutOffs = cutOffs;
this.drawings = drawings;
Order = Array.AsReadOnly(parts.Select(p => p.Part).ToArray());
}
public Plate Plate { get; }
/// <summary>Captured part order (reference identity).</summary>
public IReadOnlyList<Part> Order { get; }
/// <summary>
/// Captures on the caller thread. Unsupported or malformed programs throw
/// <see cref="ArgumentException"/> or <see cref="NotSupportedException"/>; so do part or
/// plate settings that are not exact built-in settings types, whose state cannot be
/// captured exactly.
/// </summary>
public static PlateCuttingState Capture(Plate plate, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(plate);
if (plate.Parts == null || plate.CutOffs == null)
throw new ArgumentException("Plate part and cutoff lists are required.");
var drawings = new Dictionary<Drawing, DrawingRecord>(ReferenceEqualityComparer.Instance);
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
Fingerprint(plate.CuttingParameters);
var records = new List<PartRecord>(plate.Parts.Count);
foreach (var part in plate.Parts)
{
token.ThrowIfCancellationRequested();
if (part?.BaseDrawing == null || part.Program == null)
throw new ArgumentException("Plate contains a missing part, drawing or program.");
var drawing = part.BaseDrawing;
if (!drawings.ContainsKey(drawing))
drawings.Add(drawing, new(drawing.Program, drawing.Program == null ? null
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff,
drawing.CutPins, StateFingerprint.Of(drawing.CutPins)));
var state = part.CaptureCuttingState();
Fingerprint(state.CuttingParameters);
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox),
part.DrawingPinsOff));
}
var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters)
{
if (parameters != null && !settings.ContainsKey(parameters))
settings.Add(parameters, StateFingerprint.Of(parameters));
}
}
/// <summary>True when the live plate still has exactly the captured state.</summary>
public bool IsCurrent(CancellationToken token = default) => Difference(token) == null;
/// <summary>Null when current; otherwise the first observed difference.</summary>
public string Difference(CancellationToken token = default)
{
var checkedSettings = new Dictionary<CuttingParameters, bool>(ReferenceEqualityComparer.Instance);
var plate = Plate;
if (!ReferenceEquals(plate.Parts, partList) || !ReferenceEquals(plate.CutOffs, cutOffList))
return "The plate's part or cutoff list was replaced.";
if (plate.Quantity != quantity || !Bits(plate.Size.Width, size.Width)
|| !Bits(plate.Size.Length, size.Length) || plate.Quadrant != quadrant)
return "Plate quantity, size or quadrant changed.";
if (!ReferenceEquals(plate.CuttingParameters, plateSettings) || !SameSettings(plateSettings))
return "Plate cutting settings changed.";
if (plate.Parts.Count != parts.Length)
return "Parts were added or removed.";
for (var i = 0; i < parts.Length; i++)
{
token.ThrowIfCancellationRequested();
var record = parts[i];
var part = plate.Parts[i];
if (!ReferenceEquals(part, record.Part))
return $"Part order changed at position {i + 1}.";
var live = part.CaptureCuttingState();
var was = record.State;
if (!ReferenceEquals(live.Program, was.Program) || live.OwnsProgram != was.OwnsProgram
|| !ReferenceEquals(live.CuttingParameters, was.CuttingParameters)
|| !ReferenceEquals(live.BoundingBox, was.BoundingBox))
return $"Part {i + 1} program, settings or bounds were replaced.";
if (!Bits(live.Location.X, was.Location.X) || !Bits(live.Location.Y, was.Location.Y)
|| !Bits(live.PreLeadInRotation, was.PreLeadInRotation)
|| live.HasManualLeadIns != was.HasManualLeadIns || live.LeadInsLocked != was.LeadInsLocked)
return $"Part {i + 1} pose, lead-in or lock state changed.";
if (!BoxValues(live.BoundingBox).SequenceEqual(record.Bounds))
return $"Part {i + 1} bounds changed.";
if (!ProgramContent.Equal(part.Program, record.Program, token))
return $"Part {i + 1} program was edited in place.";
if (!SameSettings(live.CuttingParameters))
return $"Part {i + 1} cutting settings were edited in place.";
var drawing = drawings[part.BaseDrawing];
if (!ReferenceEquals(part.BaseDrawing.Program, drawing.Program)
|| !ProgramContent.Equal(drawing.Program, drawing.Copy, token))
return $"Part {i + 1} drawing program changed.";
if (part.BaseDrawing.IsCutOff != drawing.IsCutOff)
return $"Part {i + 1} cutoff classification changed.";
// The drawing's pins decide how its pinned contours are cut, and pins-off whether
// they apply to this placement: either change makes the proposal stale.
if (part.DrawingPinsOff != record.DrawingPinsOff)
return $"Part {i + 1} drawing pins were switched on or off.";
if (!ReferenceEquals(part.BaseDrawing.CutPins, drawing.Pins)
|| StateFingerprint.Of(drawing.Pins) != drawing.PinsFingerprint)
return $"Part {i + 1} drawing pins changed.";
}
if (plate.CutOffs.Count != cutOffs.Length)
return "Cutoffs were added or removed.";
for (var i = 0; i < cutOffs.Length; i++)
{
var record = cutOffs[i];
var cutOff = plate.CutOffs[i];
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
return $"Cutoff {i + 1} definition changed.";
}
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
// all compared exactly above.
return null;
// References were compared already; this catches in-place edits of the same object.
// Each distinct settings object is fingerprinted at most once per check. A nested
// settings object replaced since capture by an unsupported type cannot equal the
// captured built-in state: Stale, not an exception.
bool SameSettings(CuttingParameters parameters)
{
if (parameters == null)
return true;
if (checkedSettings.TryGetValue(parameters, out var known))
return known;
bool current;
try
{
current = settings.TryGetValue(parameters, out var captured)
&& captured == StateFingerprint.Of(parameters);
}
catch (NotSupportedException)
{
current = false;
}
checkedSettings[parameters] = current;
return current;
}
}
private static long[] BoxValues(Box box) => box == null ? [] :
[BitConverter.DoubleToInt64Bits(box.X), BitConverter.DoubleToInt64Bits(box.Y),
BitConverter.DoubleToInt64Bits(box.Width), BitConverter.DoubleToInt64Bits(box.Length)];
private static bool Bits(double a, double b) =>
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
private static bool Bits(double? a, double? b) => a.HasValue == b.HasValue
&& (!a.HasValue || Bits(a.Value, b.Value));
private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds,
bool DrawingPinsOff);
private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff,
List<ContourCutPin> Pins, string PinsFingerprint);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit);
}
@@ -0,0 +1,764 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>A nominal native entry owned by one preparation, not an actual emitted pierce.</summary>
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
{
internal PreparedContours Owner { get; init; }
/// <summary>
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice.
/// </summary>
public Vector? Pierce { get; internal init; }
}
/// <summary>
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
/// program and keep it stable during Capture. This is not a topology or lead-path verdict.
/// </summary>
public sealed class PreparedContours
{
private readonly Shape[] shapes;
private readonly CuttingParameters parameters;
private readonly List<Entity> scribes;
// Open break cuts in program order, each travel-ordered with its scrap on the left. They are
// not contours: no choices, no material; every full or prefix emission cuts them first.
private readonly List<Shape> breakCuts;
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
// every full or prefix emission cuts their pieces after the break cuts.
private readonly List<Shape> tabbedCutouts;
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and
// its own owned settings (the pin's lead-in, lead-out and tab).
private readonly ContourChoice[] pinned;
private readonly CuttingParameters[] contourParameters;
private readonly int[] pinNumbers;
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters)
{
this.shapes = shapes;
this.scribes = scribes;
this.breakCuts = breakCuts;
this.tabbedCutouts = tabbedCutouts;
this.parameters = parameters;
pinned = new ContourChoice[shapes.Length];
pinNumbers = new int[shapes.Length];
}
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters)
{
contourParameters = new CuttingParameters[shapes.Length];
var rejected = new List<CutPinResolution>();
foreach (var pin in pins)
{
token.ThrowIfCancellationRequested();
if (pin == null)
throw new ArgumentException("A pin resolution is missing.", nameof(pins));
if (!pin.IsResolved)
continue; // The caller reports why; the contour stays automatic.
var contour = pin.ContourOrdinal;
if (contour < 0 || contour >= Count || pin.EntityOrdinal < 0
|| pin.EntityOrdinal >= shapes[contour].Entities.Count)
throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
if (pinned[contour] != null)
throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
// The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned.
if (pin.Pin.HasTab && contour != PerimeterOrdinal)
{
rejected.Add(pin);
continue;
}
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
ValidateChoice(choice);
pinned[contour] = choice;
pinNumbers[contour] = pin.PinIndex + 1;
contourParameters[contour] = PinParameters(parameters, pin.Pin);
}
RejectedPins = rejected.AsReadOnly();
}
public int Count => shapes.Length;
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
public int PerimeterOrdinal => Count - 1;
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
/// <summary>The pinned contour's only entry, or null for an automatic contour.</summary>
public ContourChoice PinnedEntry(int contourOrdinal) => IsPinned(contourOrdinal) ? pinned[contourOrdinal] : null;
/// <summary>
/// As <see cref="Capture(Program, CuttingParameters, CancellationToken)"/>, then fixes every
/// resolved pin's contour: that contour's only entry is the pin's point and it is emitted with
/// the pin's own lead-in, lead-out and tab through the same emitter, so its tab gap is genuine
/// and certifiable. <paramref name="pins"/> must come from <see cref="CutPinResolver"/> against
/// contours of this same program; unresolved pins are skipped (the caller reports them), a pin
/// with a tab on an internal contour is listed in <see cref="RejectedPins"/>, and every other
/// contour keeps the automatic selection with <paramref name="parameters"/>.
/// </summary>
public static PreparedContours Capture(Program program, CuttingParameters parameters,
IReadOnlyList<CutPinResolution> pins, CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(pins);
var unpinned = Capture(program, parameters, token);
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
}
// The pin's leads replace every contour type's leads, since the type the emitter detects is
// not the caller's concern; angle rounding is off because a pinned entry never moves.
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
{
var owned = OwnedCuttingParameters.Copy(shared);
owned.ExternalLeadIn = pin.LeadIn;
owned.InternalLeadIn = pin.LeadIn;
owned.ArcCircleLeadIn = pin.LeadIn;
owned.ExternalLeadOut = pin.LeadOut;
owned.InternalLeadOut = pin.LeadOut;
owned.ArcCircleLeadOut = pin.LeadOut;
owned.RoundLeadInAngles = false;
owned.TabsEnabled = pin.HasTab;
if (pin.HasTab)
owned.TabConfig = pin.Tab;
return owned;
}
private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var ownedParameters = OwnedCuttingParameters.Copy(parameters);
ExecutionMotionReader.ReadSupported(program, Vector.Zero, null, token);
// ToGeometry constructs fresh native entities. Normalize an owned motion graph
// to incremental mode so absolute subprograms retain their frame offsets too.
// Retain execution boundaries: ShapeBuilder can join duplicate closed contours.
var geometryProgram = CopyForGeometry(program, token);
var contours = new List<Shape>();
var scribes = new List<Entity>();
var breakCuts = new List<Shape>();
var tabbedCutouts = new List<Shape>();
Shape breakCut = null;
var current = new Shape();
foreach (var entity in geometryProgram.ToGeometry())
{
token.ThrowIfCancellationRequested();
if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle))
throw new NotSupportedException("Unsupported native geometry.");
if (entity.Layer == SpecialLayers.Break)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Preparation requires closed execution contours.");
if (entity is Circle || entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate break cut entity.");
if (breakCut == null || End(breakCut.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
{
breakCut = new Shape();
breakCuts.Add(breakCut);
}
breakCut.Entities.Add(entity);
continue;
}
breakCut = null;
if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Preparation requires closed execution contours.");
if (entity.Layer == SpecialLayers.Scribe)
scribes.Add(entity);
continue;
}
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab)
throw new ArgumentException("Preparation requires clean cut/display contours.");
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate contour entity.");
if (entity is Circle)
{
if (current.Entities.Count != 0 || entity.Layer == SpecialLayers.Tab)
throw new ArgumentException("Circle interrupts an open contour.");
current.Entities.Add(entity);
FinishContour();
continue;
}
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Discontinuous native contour.");
current.Entities.Add(entity);
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour();
}
if (current.Entities.Count != 0 || contours.Count == 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
var perimeter = 0;
for (var i = 1; i < contours.Count; i++)
{
var box = contours[i].BoundingBox;
var best = contours[perimeter].BoundingBox;
if (box.Width * box.Length > best.Width * best.Length)
perimeter = i;
}
var outer = contours[perimeter];
contours.RemoveAt(perimeter);
contours.Add(outer);
foreach (var cut in breakCuts)
cut.UpdateBounds();
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters);
void FinishContour()
{
current.UpdateBounds();
if (ContourCuttingStrategy.IsTabbedCutout(current))
{
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
throw new ArgumentException("A tabbed cutout has nothing to cut.");
tabbedCutouts.Add(current);
}
else
contours.Add(current);
current = new Shape();
}
}
/// <summary>The closest native entry to <paramref name="approach"/>; a pinned contour's pinned entry.</summary>
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
{
PostVerificationGeometry.Validate(approach);
var shape = GetShape(contourOrdinal);
if (pinned[contourOrdinal] is { } pin)
return pin;
var point = shape.ClosestPointTo(approach, out var entity);
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
}
/// <summary>
/// Closest native point first, then source-order vertices/midpoints or eight circle
/// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
/// Emit before validating actual paths: circle rounding/clamping may move this point.
/// A pinned contour has its pinned entry only.
/// </summary>
public IReadOnlyList<ContourChoice> Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (maxEntries <= 0)
throw new ArgumentOutOfRangeException(nameof(maxEntries));
var shape = GetShape(contourOrdinal);
if (pinned[contourOrdinal] is { } pin)
return new[] { pin };
var entries = new List<ContourChoice>();
entries.Add(ClosestEntry(contourOrdinal, approach));
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
{
token.ThrowIfCancellationRequested();
var entity = shape.Entities[i];
if (entity is Circle circle)
{
for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++)
Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius);
}
else
{
Add(i, Start(entity));
Add(i, entity is Line line ? line.MidPoint : ((Arc)entity).MidPoint());
Add(i, End(entity));
}
}
token.ThrowIfCancellationRequested();
return entries.AsReadOnly();
void Add(int entityOrdinal, Vector point)
{
token.ThrowIfCancellationRequested();
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
}
}
/// <summary>
/// Every contour with a native entity within <paramref name="tolerance"/> of
/// <paramref name="point"/>, in ordinal order, each with its closest entity and the exact
/// closest native point on it. A locating aid for drawing pins, not an entry choice.
/// </summary>
internal IReadOnlyList<(int ContourOrdinal, int EntityOrdinal, Vector Point)> ContoursNear(Vector point, double tolerance)
{
PostVerificationGeometry.Validate(point);
var found = new List<(int, int, Vector)>();
for (var ordinal = 0; ordinal < Count; ordinal++)
{
var shape = shapes[ordinal];
var closest = shape.ClosestPointTo(point, out var entity);
if (closest.DistanceTo(point) <= tolerance)
found.Add((ordinal, shape.Entities.IndexOf(entity), closest));
}
return found;
}
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
ValidateChoice(choice);
return choice;
}
/// <summary>
/// An automatic perimeter entry pierced at <paramref name="pierce"/> (local coordinates),
/// entering at the closest native point of the perimeter along a straight lead-in. The
/// planner's pierce-zone fallback chooses the pierce from the free scrap around the part;
/// emission reads only this choice and the perimeter, and the lead validator checks the
/// lead as usual. A pinned perimeter keeps its pin's entry.
/// </summary>
internal ContourChoice ScrapPierceEntry(Vector pierce)
{
PostVerificationGeometry.Validate(pierce);
var shape = GetShape(PerimeterOrdinal);
var point = shape.ClosestPointTo(pierce, out var entity);
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
{
Owner = this,
Pierce = pierce,
};
ValidateChoice(choice);
return choice;
}
/// <summary>
/// The uncapped preferred automatic start catalogue 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
/// reported once, keeping the most preferred kind. A pure-arc contour can have no
/// preferred point at all — <see cref="AutomaticEntryCandidatesWithFallbacks"/> supplies
/// those. Manual entry through <see cref="Entry"/> / <see cref="ClosestEntry"/> is
/// unaffected.
/// </summary>
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
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.");
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
}
/// <summary>
/// The complete uncapped automatic start catalogue: 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
/// <paramref name="lookAhead"/> (pass the arrival point there when there is no next cut).
/// 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.
/// </summary>
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
int contourOrdinal,
Vector? lookAhead = null,
CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
if (pinned[contourOrdinal] is { } pin)
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
var all = IsSingleCircle(shape)
? new List<ContourEntryCandidate>()
: PreferredCandidates(shape, contourOrdinal, token);
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
return MergeByGeometry(all);
}
private static bool IsSingleCircle(Shape shape) =>
shape.Entities.Count == 1 && shape.Entities[0] is Circle;
/// <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))
.ToList();
/// <summary>
/// Tier-3 fallbacks for one contour, each already run through the reflex/cusp exclusion:
/// native arc midpoints, the eight compass points of whole circles, near-convex-corner
/// insets on straight edges, and the exact target-facing closest point toward
/// <paramref name="lookAhead"/>. No ranking and no lead-safety verdict here.
/// </summary>
private List<ContourEntryCandidate> FallbackCandidates(
Shape shape,
int contourOrdinal,
Vector? lookAhead,
CancellationToken token)
{
var fallbacks = new List<ContourEntryCandidate>();
var lead = ApplicableLeadInLength(contourOrdinal);
var count = shape.Entities.Count;
for (var i = 0; i < count; i++)
{
token.ThrowIfCancellationRequested();
switch (shape.Entities[i])
{
case Arc arc:
// The native midpoint of an arc (exact native API, never tessellation).
fallbacks.Add(Fallback(i, arc.MidPoint(), AutomaticEntryKind.ArcMidpoint));
break;
case Circle circle:
// The legacy eight compass points, same native construction.
for (var angle = 0; angle < 8; angle++)
fallbacks.Add(Fallback(i, circle.Center + new Vector(
System.Math.Cos(angle * System.Math.PI / 4),
System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius,
AutomaticEntryKind.CircleCompass));
break;
}
}
// Near-convex-corner fallbacks: about twice the applicable lead-in length back from
// each convex corner along each incident STRAIGHT edge, only when strictly inside
// that edge. Short edges simply omit the point; it never extrapolates past an edge
// 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)
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
{
token.ThrowIfCancellationRequested();
// Back INTO each incident edge from the corner: the edge ending at the
// corner retreats against its own travel, the edge starting at the corner
// advances along its own travel.
AddInset(corner.EntityOrdinal, inward: false);
AddInset((corner.EntityOrdinal + 1) % count, inward: true);
}
}
if (lookAhead != null)
{
token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(lookAhead.Value);
var facing = ClosestEntry(contourOrdinal, lookAhead.Value);
// A raw closest point may land exactly on a reflex/cusp vertex; automatic
// selection must never sneak a forbidden inside corner back in, so drop it.
if (!IsForbiddenVertex(shape, facing.Point))
fallbacks.Add(Fallback(facing.EntityOrdinal, facing.Point, AutomaticEntryKind.TargetFacing));
}
token.ThrowIfCancellationRequested();
return fallbacks;
void AddInset(int entityOrdinal, bool inward)
{
// `inward` selects along the edge's own travel from its start; without it the
// point retreats against travel. Both ways move BACK INTO the edge from the
// corner, which sits at the edge's end (inward=false) or start (inward=true).
if (shape.Entities[entityOrdinal] is not Line line)
return;
var direction = (line.EndPoint - line.StartPoint).Normalize();
var offset = direction * (2 * lead);
// Adjacent native endpoints may differ within contour-continuity tolerance.
// Anchor to the selected edge itself, never the preceding edge's vertex.
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
// Strictly inside the edge by projection parameter (distance alone loses the
// sign when a short edge is overshoot): never the corner, never the far
// endpoint (where a reflex vertex might sit). Short edges omit the point.
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
return;
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
}
ContourEntryCandidate Fallback(int entityOrdinal, Vector point, AutomaticEntryKind kind)
=> new(Entry(contourOrdinal, entityOrdinal, point), kind);
}
/// <summary>
/// At equal geometric points the most preferred kind wins, independent of which entity
/// supplied it; the winner keeps its own ordinal and point. Result order: preference,
/// then entity ordinal.
/// </summary>
private static IReadOnlyList<ContourEntryCandidate> MergeByGeometry(List<ContourEntryCandidate> candidates)
{
var byPoint = new Dictionary<(long, long), ContourEntryCandidate>();
var order = new List<(long, long)>();
foreach (var candidate in candidates)
{
var key = candidate.GeometryKey;
if (!byPoint.TryGetValue(key, out var existing))
{
byPoint[key] = candidate;
order.Add(key);
}
else if (candidate.Kind < existing.Kind)
byPoint[key] = candidate;
}
return order
.OrderBy(key => byPoint[key].Kind)
.ThenBy(key => byPoint[key].Choice.EntityOrdinal)
.Select(key => byPoint[key])
.ToList();
}
/// <summary>
/// True when <paramref name="point"/> sits on a vertex of the contour that is reflex or
/// cusp from either travel direction — a point automatic selection must never emit.
/// </summary>
private bool IsForbiddenVertex(Shape shape, Vector point)
{
for (var i = 0; i < shape.Entities.Count; i++)
{
if (shape.Entities[i] is Circle)
continue; // A whole circle has no vertex.
var vertex = End(shape.Entities[i]);
if (vertex.DistanceTo(point) > PostVerificationGeometry.Epsilon)
continue;
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, shape.Entities[i], out var corner)
&& corner.Kind is ContourCuttingStrategy.CornerKind.Reflex or ContourCuttingStrategy.CornerKind.Cusp)
return true;
}
return false;
}
/// <summary>
/// The applicable lead-in length for a contour following the emitter's own selection:
/// the perimeter (last shape) is external, anything else internal; non-length lead-in
/// styles contribute 0, which omits the near-corner fallback instead of approximating.
/// </summary>
private double ApplicableLeadInLength(int contourOrdinal)
=> (contourOrdinal == PerimeterOrdinal
? parameters.ExternalLeadIn
: parameters.InternalLeadIn) switch
{
LineLeadIn line => line.Length,
LineLineLeadIn lineLine => lineLine.Length1,
_ => 0,
};
/// <summary>
/// The preferred catalogue points for one contour: for each entity, its convex corner
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
/// nothing; a single whole circle yields no points at all.
/// </summary>
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
{
var found = new List<(int, Vector, AutomaticEntryKind)>();
var count = shape.Entities.Count;
if (count == 1 && shape.Entities[0] is Circle)
{
// A whole circle has no corners or joints; only the fallback tier applies.
return found;
}
if (count < 2)
throw new ArgumentException("Contour has no vertex to classify.");
for (var i = 0; i < count; i++)
{
token.ThrowIfCancellationRequested();
var entity = shape.Entities[i];
// Vertex reached by travelling 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);
if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner))
{
switch (corner.Kind)
{
case ContourCuttingStrategy.CornerKind.Convex:
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
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));
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));
break;
}
}
if (entity is Line line)
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
}
return found;
Entity Next(int index) => shape.Entities[(index + 1) % count];
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
public Program Emit(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, true);
return EmitPrefix(choices);
}
/// <summary>
/// Diagnostic seam: emit exactly ONE owned contour — with its normal lead-in and
/// lead-out and its ORIGINAL contour type (the perimeter keeps External even when no
/// holes precede it) — so a candidate's emitted leads can be validated before the hole
/// choices exist. The result is a throwaway probe, not a plan: it must never be
/// installed on a Part or accepted as complete output. Normal <see cref="Emit"/> and
/// <see cref="EmitPrefix"/> keep the perimeter-last rule untouched.
/// </summary>
internal Program EmitCandidateForValidation(ContourChoice choice)
{
if (choice == null || !ReferenceEquals(choice.Owner, this))
throw new ArgumentException("Foreign contour choice.");
ValidateChoice(choice);
// Same owned clones as a real emission; the source shapes/settings are never used
// directly, so the probe cannot drift the preparation or mutate it.
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
contourParameters);
}
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, false);
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList());
}
/// <summary>Open break cuts this preparation cuts before its contours.</summary>
public int BreakCutCount => breakCuts.Count;
/// <summary>Tabbed cutouts this preparation cuts, as pieces, after its break cuts and before its contours.</summary>
public int TabbedCutoutCount => tabbedCutouts.Count;
/// <summary>Open pieces the tabbed cutouts are cut in, each a lead-in and cut group of its own.</summary>
public int TabbedPieceCount => tabbedCutouts.Sum(c => ContourCuttingStrategy.TabbedCutoutPieces(c).Count);
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
// Build expected-emission metadata independently of the selected payload BEFORE
// replay. Final verification only reads this immutable execution and clean geometry.
internal SelectedContourProgram CaptureSelectedProgram(IReadOnlyList<ContourChoice> choices,
Vector location, CancellationToken token)
{
token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
// Only the outside is tabbed, with its own pin's tab when it is pinned.
var perimeter = ParametersFor(PerimeterOrdinal);
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
}
private void Validate(IReadOnlyList<ContourChoice> choices, bool complete)
{
if (choices == null || (complete && choices.Count != Count) || choices.Count > Count)
throw new ArgumentException("Every contour must be represented exactly once.");
var visited = new HashSet<int>();
foreach (var choice in choices)
{
ValidateChoice(choice);
if (!visited.Add(choice.ContourOrdinal) || choice.ContourOrdinal == PerimeterOrdinal && visited.Count != Count)
throw new ArgumentException("Duplicate contour or perimeter before its internal contours.");
}
}
private void ValidateChoice(ContourChoice choice)
{
if (choice == null || !ReferenceEquals(choice.Owner, this))
throw new ArgumentException("Foreign contour choice.");
PostVerificationGeometry.Validate(choice.Point);
var shape = GetShape(choice.ContourOrdinal);
if (choice.EntityOrdinal < 0 || choice.EntityOrdinal >= shape.Entities.Count)
throw new ArgumentException("Foreign entity ordinal.");
var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Entry is not on the selected native entity.");
// A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
if (choice.Pierce is { } pierce)
{
PostVerificationGeometry.Validate(pierce);
if (choice.ContourOrdinal != PerimeterOrdinal)
throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
throw new ArgumentException("A scrap pierce must lie off its contour.");
}
}
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
/// <summary>
/// Representative POINTS per contour ordinal (bounding-box centre) 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)
{
token.ThrowIfCancellationRequested();
var centres = new Vector?[Count];
for (var contour = 0; contour < PerimeterOrdinal; contour++)
{
token.ThrowIfCancellationRequested();
centres[contour] = GetShape(contour).BoundingBox.Center;
}
return centres;
}
/// <summary>
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
/// </summary>
internal bool HoleContains(int contourOrdinal, Vector point)
{
var shape = GetShape(contourOrdinal);
return contourOrdinal != PerimeterOrdinal && shape.BoundingBox.Contains(point)
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
}
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
private static Program CopyForGeometry(Program source, CancellationToken token)
{
var copies = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
return Copy(source);
Program Copy(Program current)
{
token.ThrowIfCancellationRequested();
if (copies.TryGetValue(current, out var existing))
return existing;
var copy = new Program(current.Mode);
copies.Add(current, copy);
foreach (var code in current.Codes)
{
token.ThrowIfCancellationRequested();
var ownedCode = code.Clone();
if (ownedCode is SubProgramCall call)
call.BindProgram(Copy(((SubProgramCall)code).Program));
copy.Codes.Add(ownedCode);
}
copy.Mode = Mode.Incremental;
return copy;
}
}
}
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact structural equality of two program graphs: instruction runtime types, scalar bits,
/// motion metadata, variables and the same instruction/sub-program sharing shape. This is
/// a freshness check, not geometric equivalence; unsupported instruction types never match.
/// </summary>
internal static class ProgramContent
{
internal static bool Equal(Program left, Program right, CancellationToken token = default)
{
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
var programOwners = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
var codeOwners = new HashSet<ICode>(ReferenceEqualityComparer.Instance);
return SameProgram(left, right);
bool SameProgram(Program a, Program b)
{
token.ThrowIfCancellationRequested();
if (a == null || b == null)
return a == null && b == null;
// Pair graphs one-to-one so shared and distinct sub-programs cannot be confused.
if (programs.TryGetValue(a, out var paired))
return ReferenceEquals(paired, b);
if (!programOwners.Add(b))
return false;
programs.Add(a, b);
// Codes is a writable field: a missing list is a difference, never an exception.
if (a.Codes == null || b.Codes == null)
return a.Codes == null && b.Codes == null && a.GetType() == b.GetType();
if (a.GetType() != b.GetType() || a.Mode != b.Mode || !Bits(a.Rotation, b.Rotation)
|| a.Codes.Count != b.Codes.Count || a.SubPrograms.Count != b.SubPrograms.Count
|| !SameVariables(a.Variables, b.Variables))
return false;
for (var i = 0; i < a.Codes.Count; i++)
if (!SameCode(a.Codes[i], b.Codes[i]))
return false;
foreach (var (id, child) in a.SubPrograms)
if (!b.SubPrograms.TryGetValue(id, out var other) || !SameProgram(child, other))
return false;
return true;
}
bool SameCode(ICode a, ICode b)
{
token.ThrowIfCancellationRequested();
if (a == null || b == null)
return a == null && b == null;
if (codes.TryGetValue(a, out var paired))
return ReferenceEquals(paired, b);
if (!codeOwners.Add(b))
return false;
codes.Add(a, b);
if (a.GetType() != b.GetType())
return false;
return (a, b) switch
{
(RapidMove x, RapidMove y) => SameMotion(x, y),
(LinearMove x, LinearMove y) => SameMotion(x, y) && x.Layer == y.Layer,
(ArcMove x, ArcMove y) => SameMotion(x, y) && x.Layer == y.Layer
&& x.Rotation == y.Rotation && Bits(x.CenterPoint, y.CenterPoint),
(SubProgramCall x, SubProgramCall y) => x.Id == y.Id && Bits(x.Offset, y.Offset)
&& Bits(x.Rotation, y.Rotation) && SameProgram(x.Program, y.Program),
(Comment x, Comment y) => string.Equals(x.Value, y.Value, StringComparison.Ordinal),
(Feedrate x, Feedrate y) => Bits(x.Value, y.Value)
&& string.Equals(x.VariableRef, y.VariableRef, StringComparison.Ordinal),
(Kerf x, Kerf y) => x.Value == y.Value,
_ => false
};
}
}
private static bool SameMotion(Motion a, Motion b) => Bits(a.EndPoint, b.EndPoint)
&& a.UseExactStop == b.UseExactStop && a.Feedrate == b.Feedrate && a.Suppressed == b.Suppressed
&& SameRefs(a.VariableRefs, b.VariableRefs);
private static bool SameRefs(Dictionary<string, string> a, Dictionary<string, string> b)
{
if (a == null || b == null)
return a == null && b == null;
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
return false;
foreach (var (key, value) in a)
if (!b.TryGetValue(key, out var other) || !string.Equals(value, other, StringComparison.Ordinal))
return false;
return true;
}
// A case-insensitive dictionary finds a respelled key; authored spelling must still match.
private static bool SameSpelling(IEnumerable<string> a, IEnumerable<string> b) =>
new HashSet<string>(a, StringComparer.Ordinal).SetEquals(b);
private static bool SameVariables(Dictionary<string, VariableDefinition> a, Dictionary<string, VariableDefinition> b)
{
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
return false;
foreach (var (key, value) in a)
{
if (!b.TryGetValue(key, out var other))
return false;
if (ReferenceEquals(value, other))
continue;
if (value == null || other == null
|| !string.Equals(value.Name, other.Name, StringComparison.Ordinal)
|| !string.Equals(value.Expression, other.Expression, StringComparison.Ordinal)
|| !Bits(value.Value, other.Value) || value.Inline != other.Inline || value.Global != other.Global)
return false;
}
return true;
}
private static bool Bits(Vector a, Vector b) => Bits(a.X, b.X) && Bits(a.Y, b.Y);
private static bool Bits(double a, double b) =>
BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
}
@@ -0,0 +1,420 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Direct XY completed-contour checker. This is not physical machine safety.</summary>
public sealed class ReleasedContourState
{
private readonly List<Obstacle> obstacles = new();
private readonly bool reportMissingLeadIns;
public ReleasedContourState() : this(true) { }
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
public ReleasedContourState(bool reportMissingLeadIns)
{
this.reportMissingLeadIns = reportMissingLeadIns;
}
public ReleasedContourState Copy()
{
var copy = new ReleasedContourState(reportMissingLeadIns);
copy.obstacles.AddRange(obstacles);
return copy;
}
/// <summary>Consumes one whole owned program; part numbers are caller identity keys.</summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, CancellationToken token = default) => Check(execution, arrival, partNumber, false, token);
/// <summary>
/// As the analyzer does for cutoffs: rapids are checked, but cutoff cuts need no lead-in and,
/// being open, never become obstacles.
/// </summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, bool cutoff, CancellationToken token = default) =>
Check(execution, arrival, partNumber, cutoff, null, token).Strict;
/// <summary>
/// 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.
/// </summary>
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check(
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
CancellationToken token)
{
ArgumentNullException.ThrowIfNull(execution);
if (arrival is { } point)
PostVerificationGeometry.Validate(point);
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
var fixedFindings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
}
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)
{
var current = new CutGroup();
var unfinished = new List<CutGroup>();
// Part-local cuts read only by the open-cut proof below: closed contours, break cuts
// and leads no contour owns. Each lead belongs to the cut it enters or leaves.
var closed = new List<CutGroup>();
var otherOpen = new List<CutGroup>();
var loose = new CutGroup();
var leadIns = new List<PostVerificationGeometry.Curve>();
CutGroup previous = null;
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
var hasLead = false;
var contourNumber = 0;
var inBreakCut = false;
var reportedTab = false;
for (var index = 0; index < moves.Count; index++)
{
token.ThrowIfCancellationRequested();
var move = moves[index];
var owned = isFixed?.Invoke(index) == true;
if (!move.Rapid && move.Layer == LayerType.Break)
{
// A break cut is intentionally open and retained by its uncut tabs: it is never a
// rapid obstacle and never an unexplained fragment, but it is still pierced.
if (!inBreakCut)
{
Finish();
if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
"A break cut has no nonzero placed lead-in motion.")
{ Location = move.Curve.Start }, owned);
inBreakCut = true;
hasLead = false;
previous = new CutGroup();
previous.Leads.AddRange(leadIns);
leadIns.Clear();
otherOpen.Add(previous);
}
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
previous.Curves.Add(move.Curve);
continue;
}
inBreakCut = false;
if (move.Rapid)
{
Finish();
hasLead = false;
loose.Leads.AddRange(leadIns);
leadIns.Clear();
previous = null;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue;
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
System.Math.Max(start.Y, move.End.Y));
foreach (var obstacle in obstacles)
{
token.ThrowIfCancellationRequested();
// A rapid well clear of a contour's extent can neither cross nor touch it.
if (reach.IsClearOf(obstacle.Extent))
continue;
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
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(start, move.End, obstacle.Curves, token)
}, owned);
}
}
else if (move.Layer == LayerType.Leadin)
{
Finish();
previous = null;
hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon;
if (move.Curve.Length > PostVerificationGeometry.Epsilon)
leadIns.Add(move.Curve);
}
else if (move.Layer is LayerType.Leadout or LayerType.Scribe)
{
if (move.Layer == LayerType.Leadout && move.Curve.Length > PostVerificationGeometry.Epsilon)
{
// Withheld until the part ends, where the open-cut proof may show it cuts no gap.
if (current.Curves.Count > 0 && !PostVerificationGeometry.Closed(current.Curves))
Defer(new(PostVerificationKind.Incomplete, plate, part, null,
"A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."), owned);
(previous ?? loose).Leads.Add(move.Curve);
}
Finish();
hasLead = false;
if (move.Layer == LayerType.Scribe)
previous = null;
}
else if (move.Curve.Length > PostVerificationGeometry.Epsilon)
{
// A clean program's tab spans close their cutout but are not meant to be cut.
if (move.Layer == LayerType.Tab && !reportedTab)
{
reportedTab = true;
Add(new(PostVerificationKind.Incomplete, plate, part, null,
"A tabbed cutout still has its tabs as motions, so it may be cut free. " +
"Assign lead-ins or plan cutting to cut it in pieces around its tabs.")
{ Location = move.Curve.Start }, owned);
}
if (current.Curves.Count == 0)
{
contourNumber++;
if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
{ Location = move.Curve.Start }, owned);
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
// until an actually continuous chain closes.
var resumed = unfinished.FindIndex(chain =>
chain.Curves[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (resumed >= 0)
{
current = unfinished[resumed];
unfinished.RemoveAt(resumed);
}
current.Leads.AddRange(leadIns);
leadIns.Clear();
}
current.Curves.Add(move.Curve);
current.Fixed |= owned;
previous = current;
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(current.Curves))
Finish();
}
}
Finish();
loose.Leads.AddRange(leadIns);
// Open cuts that provably enclose nothing retain all their material, however many
// pieces a tabbed cutout was cut in. Otherwise they need manual review. Planning proves
// them too (its own tabbed cutouts are cut in pieces without lead-outs), but only
// pre-post review lets the proof clear a lead-out: planning keeps refusing to certify
// its own tabs followed by a lead-out.
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
&& unfinished.All(LeadsClearOpenEnds)
&& OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token);
for (var i = deferred.Count - 1; i >= 0 && !(retained && proveOpenCuts); i--)
deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding);
// Only one open fragment per part can be the planner's own certified tab, so a
// further fragment that a fixed emission cut makes this uncertainty that emission's.
if (unfinished.Count > 1 && !retained)
Add(new(PostVerificationKind.Incomplete, plate, part, null,
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
unfinished.Any(chain => chain.Fixed));
void Add(PostVerificationFinding finding, bool fixedEmission)
{
if (fixedEmission && fixedFindings != null)
fixedFindings.Add(finding);
else
findings.Add(finding);
}
// Records the finding's place among the others, in case it is reported after all.
void Defer(PostVerificationFinding finding, bool fixedEmission)
{
var target = fixedEmission && fixedFindings != null ? fixedFindings : findings;
deferred.Add((target, target.Count, finding));
}
void Finish()
{
if (current.Curves.Count == 0)
return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(current.Curves))
{
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
closed.Add(current);
}
else if (!cutoff)
unfinished.Add(current);
current = new CutGroup();
}
}
/// <summary>
/// Whether one part's open cuts, with their leads, can release no material. A closed
/// contour with its own leads already encloses whatever it releases and counts as one node.
/// 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.
/// </summary>
private const double JointTolerance = 1e-5;
/// <summary>
/// Whether every lead at an open end of <paramref name="group"/> stays out of the uncut gap
/// beyond it: a lead-out never reaches ahead of the end it leaves, and a lead-in never
/// behind the start it enters. A lead that curls into the gap can cut the tab with its kerf
/// even where its centerline does not touch the next cut.
/// </summary>
private static bool LeadsClearOpenEnds(CutGroup group)
{
var start = group.Curves[0].Start;
var end = group.Curves[^1].End;
var behindStart = group.Curves[0].Tangent(false) * -1;
var aheadOfEnd = group.Curves[^1].Tangent(true);
foreach (var lead in group.Leads)
{
if (Touches(lead, start) && lead.Reach(start, behindStart) > PostVerificationGeometry.Epsilon)
return false;
if (Touches(lead, end) && lead.Reach(end, aheadOfEnd) > PostVerificationGeometry.Epsilon)
return false;
}
return true;
static bool Touches(PostVerificationGeometry.Curve lead, Vector point) =>
lead.Start.DistanceTo(point) <= PostVerificationGeometry.Epsilon
|| lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon;
}
private static bool OpenCutsEncloseNothing(IReadOnlyList<CutGroup> closed, IReadOnlyList<CutGroup> open,
CancellationToken token)
{
var edges = open.SelectMany(group => group.Curves.Concat(group.Leads)).ToList();
var nodes = new DisjointSet(closed.Count + 2 * edges.Count);
int Node(int edge, bool end) => closed.Count + 2 * edge + (end ? 1 : 0);
try
{
for (var i = 0; i < closed.Count; i++)
for (var j = 0; j < i; j++)
if (Touch(closed[i], closed[j], token))
nodes.Union(i, j);
for (var a = 0; a < edges.Count; a++)
{
var edge = edges[a];
if (edge.Start.DistanceTo(edge.End) <= PostVerificationGeometry.Epsilon)
return false;
for (var g = 0; g < closed.Count; g++)
{
foreach (var other in closed[g].Curves.Concat(closed[g].Leads))
{
token.ThrowIfCancellationRequested();
if (edge.Extent.IsClearOf(other.Extent))
continue;
foreach (var point in Contacts(edge, other))
{
if (EndAt(edge, point, other) is not { } end)
return false;
nodes.Union(Node(a, end), g);
}
}
}
for (var b = a + 1; b < edges.Count; b++)
{
token.ThrowIfCancellationRequested();
var other = edges[b];
if (edge.Extent.IsClearOf(other.Extent))
continue;
foreach (var point in Contacts(edge, other))
{
if (EndAt(edge, point, other) is not { } end || EndAt(other, point, edge) is not { } otherEnd)
return false;
nodes.Union(Node(a, end), Node(b, otherEnd));
}
}
}
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException
or ArithmeticException)
{
return false;
}
var forest = new DisjointSet(closed.Count + 2 * edges.Count);
for (var a = 0; a < edges.Count; a++)
{
var start = nodes.Find(Node(a, false));
var end = nodes.Find(Node(a, true));
if (forest.Find(start) == forest.Find(end))
return false;
forest.Union(start, end);
}
return true;
static IReadOnlyList<Vector> Contacts(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b)
{
var points = a.Contacts(b, out var overlap);
if (overlap)
throw new ArgumentException("Cuts share a span.");
return points;
}
// Native contacts at a near-tangent joint (a lead or line meeting an arc) can land
// slightly off the vertex the two cuts share; that is still their shared end.
static bool? EndAt(PostVerificationGeometry.Curve curve, Vector point, PostVerificationGeometry.Curve other)
{
foreach (var end in new[] { false, true })
{
var vertex = end ? curve.End : curve.Start;
if (point.DistanceTo(vertex) <= PostVerificationGeometry.Epsilon
|| point.DistanceTo(vertex) <= JointTolerance
&& (vertex.DistanceTo(other.Start) <= PostVerificationGeometry.Epsilon
|| vertex.DistanceTo(other.End) <= PostVerificationGeometry.Epsilon))
return end;
}
return null;
}
static bool Touch(CutGroup a, CutGroup b, CancellationToken token)
{
foreach (var curve in a.Curves.Concat(a.Leads))
foreach (var other in b.Curves.Concat(b.Leads))
{
token.ThrowIfCancellationRequested();
if (!curve.Extent.IsClearOf(other.Extent) && Contacts(curve, other).Count > 0)
return true;
}
return false;
}
}
/// <summary>One contour's cut motions with the leads entering and leaving it.</summary>
private sealed class CutGroup
{
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
internal bool Fixed { get; set; }
}
private sealed class DisjointSet(int count)
{
private readonly int[] parents = Enumerable.Range(0, count).ToArray();
internal int Find(int node)
{
while (parents[node] != node)
node = parents[node] = parents[parents[node]];
return node;
}
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
}
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
{
internal PostVerificationGeometry.Extent Extent { get; } =
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
}
}
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// A seam overcut baked into a clean contour: the last cut runs on past the contour's own start
/// along its first cut, then a straight jog returns to the start (converted nests carry a source
/// CAM's 0.03 overcut this way). The retraced tail encloses no area, so trimming it leaves the
/// material unchanged; left in, the contour touches itself at its seam and nothing can certify it.
/// Only that exact shape is recognized. Any other self-contact is still refused by its consumer.
/// </summary>
internal static class SeamOvercut
{
/// <summary>
/// The same program when it has no seam overcut, otherwise an owned copy without them.
/// The source program is never edited.
/// </summary>
internal static Program Trimmed(Program program)
{
var edits = Find(program);
if (edits.Count == 0)
return program;
var copy = (Program)program.Clone();
Apply(copy, edits);
return copy;
}
/// <summary>Trims seam overcuts from an owned program in place; returns how many were removed.</summary>
internal static int Trim(Program program)
{
var edits = Find(program);
Apply(program, edits);
return edits.Count;
}
private static void Apply(Program program, List<Edit> edits)
{
// Last first, so removing a return jog does not shift an earlier edit's indices.
for (var i = edits.Count - 1; i >= 0; i--)
{
var edit = edits[i];
var last = (Motion)program.Codes[edit.LastCut];
last.EndPoint = edit.EndPoint;
if (last is ArcMove arc)
arc.CenterPoint = edit.CenterPoint;
program.Codes.RemoveAt(edit.ReturnJog);
}
}
private static List<Edit> Find(Program program)
{
var edits = new List<Edit>();
if (program?.Codes == null)
return edits;
var incremental = program.Mode == Mode.Incremental;
var run = new List<Segment>();
// Null while the position is unknown: after a subprogram call in an absolute program.
Vector? position = new Vector();
for (var index = 0; index < program.Codes.Count; index++)
{
var code = program.Codes[index];
if (code is not Motion motion)
{
Finish();
if (code is SubProgramCall && !incremental)
position = null;
continue;
}
// Incremental coordinates only need a consistent origin within one contour.
var reference = incremental ? position ?? new Vector() : new Vector();
var end = reference + motion.EndPoint;
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
or ArcMove { Layer: LayerType.Cut or LayerType.Display };
if (!cut || position == null)
{
Finish();
position = end;
continue;
}
run.Add(new(index, motion, position.Value, end,
motion is ArcMove arc ? reference + arc.CenterPoint : null, reference));
position = end;
}
Finish();
return edits;
void Finish()
{
if (TryTrim(run, out var edit))
edits.Add(edit);
run.Clear();
}
}
private static bool TryTrim(List<Segment> run, out Edit edit)
{
edit = default;
if (run.Count < 3)
return false;
var first = run[0];
var last = run[^2];
var jog = run[^1];
var seam = first.Start;
var overcut = last.End;
if (jog.Motion is not LinearMove || !NominalContourClosure.IsClosed(seam, jog.End)
|| HasVariables(last.Motion) || HasVariables(jog.Motion)
|| !IsFinite(seam) || !IsFinite(overcut) || !IsFinite(last.Start)
|| overcut.DistanceTo(seam) <= Tolerance.Epsilon || last.Start.DistanceTo(seam) <= Tolerance.Epsilon)
return false;
if (first.Center == null && last.Center == null)
{
var length = first.Start.DistanceTo(first.End);
if (length <= Tolerance.Epsilon)
return false;
var direction = (first.End - first.Start) * (1 / length);
// The last cut lies on the first cut's line, passes the seam and stops inside the first cut.
var along = Dot(overcut - seam, direction);
var before = Dot(seam - last.Start, direction);
if (System.Math.Abs(Cross(overcut - seam, direction)) > Tolerance.Epsilon
|| System.Math.Abs(Cross(last.Start - seam, direction)) > Tolerance.Epsilon
|| along <= Tolerance.Epsilon || along >= length - Tolerance.Epsilon || before <= Tolerance.Epsilon)
return false;
edit = new(last.Index, jog.Index, seam - last.Reference, new Vector());
return true;
}
if (first.Center is not { } firstCenter || last.Center is not { } lastCenter
|| !IsFinite(firstCenter) || !IsFinite(lastCenter)
|| ((ArcMove)first.Motion).Rotation != ((ArcMove)last.Motion).Rotation)
return false;
var radius = seam.DistanceTo(firstCenter);
if (radius <= Tolerance.Epsilon || firstCenter.DistanceTo(lastCenter) > Tolerance.Epsilon
|| System.Math.Abs(overcut.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon
|| System.Math.Abs(overcut.DistanceTo(firstCenter) - radius) > Tolerance.Epsilon
|| System.Math.Abs(seam.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon)
return false;
var clockwise = ((ArcMove)first.Motion).Rotation == RotationType.CW;
var margin = Tolerance.Epsilon / radius;
// The overcut end lies inside the first arc's span, and the seam inside the last arc's.
var firstSweep = first.Start.DistanceTo(first.End) <= Tolerance.Epsilon
? 2 * System.Math.PI : Travel(firstCenter, seam, first.End, clockwise);
var passed = Travel(firstCenter, seam, overcut, clockwise);
var lastSweep = Travel(lastCenter, last.Start, overcut, clockwise);
var toSeam = Travel(lastCenter, last.Start, seam, clockwise);
if (passed <= margin || passed >= System.Math.PI || passed >= firstSweep - margin
|| toSeam <= margin || toSeam >= lastSweep - margin
|| System.Math.Abs(lastSweep - toSeam - passed) > margin)
return false;
// Keep the shortened arc's center equidistant from both of its ends.
var chord = seam - last.Start;
var normal = new Vector(-chord.Y, chord.X) * (1 / last.Start.DistanceTo(seam));
var middle = (last.Start + seam) * 0.5;
var center = middle + normal * Dot(lastCenter - middle, normal);
edit = new(last.Index, jog.Index, seam - last.Reference, center - last.Reference);
return true;
}
/// <summary>The angle travelled 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);
var end = System.Math.Atan2(to.Y - center.Y, to.X - center.X);
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
return travel < 0 ? travel + 2 * System.Math.PI : travel;
}
private static bool HasVariables(Motion motion) => motion.VariableRefs is { Count: > 0 };
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
/// <summary>One cut of a contour in the walk's coordinates; <see cref="Reference"/> maps them back to its code.</summary>
private readonly record struct Segment(int Index, Motion Motion, Vector Start, Vector End, Vector? Center, Vector Reference);
/// <summary>The last cut's new code-relative end and center, and the return jog to remove.</summary>
private readonly record struct Edit(int LastCut, int ReturnJog, Vector EndPoint, Vector CenterPoint);
}
@@ -0,0 +1,258 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact text record of cutting settings for detecting in-place edits after capture. It is a
/// freshness fingerprint, not a serializer.
/// Only the exact built-in settings types are supported — the same set
/// <see cref="OwnedCuttingParameters"/> copies: <see cref="CuttingParameters"/>,
/// <see cref="SequenceParameters"/>, <see cref="AssignmentParameters"/> and the built-in
/// lead-in, lead-out and tab types. Every member is written explicitly by type, so nothing is
/// discovered reflectively and no code of another type ever runs: each object's runtime type
/// is checked before any of its members is read, and any other runtime type (a subclass
/// included) throws <see cref="NotSupportedException"/>. Doubles are written by bit pattern
/// and every number is formatted invariantly, so the text is exact and culture-free.
/// </summary>
internal static class StateFingerprint
{
/// <summary>Fingerprint of <paramref name="settings"/>; null renders as a marker.</summary>
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
internal static string Of(CuttingParameters settings)
{
var writer = new Writer();
writer.Settings(settings);
return writer.ToString();
}
/// <summary>
/// Fingerprint of a drawing's cut pins in order: each pin's point, lead-in, lead-out and
/// tab. A null list and an empty list differ.
/// </summary>
internal static string Of(IReadOnlyList<ContourCutPin> pins)
{
var writer = new Writer();
writer.Pins(pins);
return writer.ToString();
}
private sealed class Writer
{
private readonly StringBuilder text = new();
public override string ToString() => text.ToString();
internal void Pins(IReadOnlyList<ContourCutPin> pins)
{
if (pins == null)
{
text.Append("null;");
return;
}
text.Append("Pins{");
foreach (var pin in pins)
{
if (!Begin(pin, typeof(ContourCutPin)))
continue;
Number(pin.Point.X);
Number(pin.Point.Y);
Lead(pin.LeadIn);
Out(pin.LeadOut);
TabSettings(pin.Tab);
text.Append('}');
}
text.Append('}');
}
internal void Settings(CuttingParameters p)
{
if (!Begin(p, typeof(CuttingParameters)))
return;
Int(p.Id);
Text(p.MachineName);
Text(p.MaterialName);
Text(p.Grade);
Number(p.Thickness);
Number(p.Kerf);
Number(p.PartSpacing);
Lead(p.ExternalLeadIn);
Out(p.ExternalLeadOut);
Lead(p.InternalLeadIn);
Out(p.InternalLeadOut);
Lead(p.ArcCircleLeadIn);
Out(p.ArcCircleLeadOut);
Number(p.PierceClearance);
Number(p.PierceDiameter);
Flag(p.RoundLeadInAngles);
Number(p.LeadInAngleIncrement);
Number(p.AutoTabMinSize);
Number(p.AutoTabMaxSize);
TabSettings(p.TabConfig);
Flag(p.TabsEnabled);
Sequence(p.Sequencing);
Assignment(p.Assignment);
text.Append('}');
}
private void Sequence(SequenceParameters s)
{
if (!Begin(s, typeof(SequenceParameters)))
return;
Int((int)s.Method);
Number(s.SmallCutoutWidth);
Number(s.SmallCutoutHeight);
Number(s.MediumCutoutWidth);
Number(s.MediumCutoutHeight);
Number(s.DistanceMediumSmall);
Flag(s.AlternateRowsColumns);
Flag(s.AlternateCutoutsWithinRowColumn);
Number(s.MinDistanceBetweenRowsColumns);
text.Append('}');
}
private void Assignment(AssignmentParameters a)
{
if (!Begin(a, typeof(AssignmentParameters)))
return;
Int((int)a.Method);
Text(a.Preference);
Number(a.MinGeometryLength);
text.Append('}');
}
private void Lead(LeadIn lead)
{
if (lead == null)
{
text.Append("null;");
return;
}
switch (lead)
{
case NoLeadIn when Begin(lead, typeof(NoLeadIn)):
break;
case LineLeadIn l when Begin(lead, typeof(LineLeadIn)):
Number(l.Length);
Number(l.ApproachAngle);
break;
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
Number(l.Radius);
break;
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
Number(l.LineLength);
Number(l.ApproachAngle);
Number(l.ArcRadius);
break;
case LineLineLeadIn l when Begin(lead, typeof(LineLineLeadIn)):
Number(l.Length1);
Number(l.ApproachAngle1);
Number(l.Length2);
Number(l.ApproachAngle2);
break;
case CleanHoleLeadIn l when Begin(lead, typeof(CleanHoleLeadIn)):
Number(l.LineLength);
Number(l.ArcRadius);
Number(l.Kerf);
break;
default:
throw Unsupported(lead);
}
text.Append('}');
}
private void Out(LeadOut lead)
{
if (lead == null)
{
text.Append("null;");
return;
}
switch (lead)
{
case NoLeadOut when Begin(lead, typeof(NoLeadOut)):
break;
case LineLeadOut l when Begin(lead, typeof(LineLeadOut)):
Number(l.Length);
Number(l.ApproachAngle);
break;
case ArcLeadOut l when Begin(lead, typeof(ArcLeadOut)):
Number(l.Radius);
break;
default:
throw Unsupported(lead);
}
text.Append('}');
}
private void TabSettings(Tab tab)
{
if (tab == null)
{
text.Append("null;");
return;
}
switch (tab)
{
case NormalTab t when Begin(tab, typeof(NormalTab)):
Number(t.CutoutMinWidth);
Number(t.CutoutMinHeight);
Number(t.CutoutMaxWidth);
Number(t.CutoutMaxHeight);
break;
case BreakerTab t when Begin(tab, typeof(BreakerTab)):
Number(t.BreakerDepth);
Number(t.BreakerLeadInLength);
Number(t.BreakerAngle);
break;
case MachineTab t when Begin(tab, typeof(MachineTab)):
Int(t.MachineTabId);
break;
default:
throw Unsupported(tab);
}
Number(tab.Size);
Lead(tab.TabLeadIn);
Out(tab.TabLeadOut);
text.Append('}');
}
// Writes the exact type tag and returns true; null writes a marker and returns false.
// A runtime type other than the expected exact type throws before any member is read.
private bool Begin(object value, Type exact)
{
if (value == null)
{
text.Append("null;");
return false;
}
if (value.GetType() != exact)
throw Unsupported(value);
text.Append(exact.Name).Append('{');
return true;
}
private void Number(double value) =>
text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
private void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
private void Flag(bool value) => text.Append(value ? "1;" : "0;");
// Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
private void Text(string value)
{
if (value == null)
text.Append("~;");
else
text.Append(value.Length.ToString(CultureInfo.InvariantCulture)).Append(':').Append(value).Append(';');
}
}
private static NotSupportedException Unsupported(object value) =>
new($"Cutting settings of type {value.GetType().FullName} cannot be captured exactly; " +
"only the built-in settings types are supported.");
}
@@ -0,0 +1,55 @@
using System;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// How one contour of a drawing must be cut: where it is entered and the lead-in,
/// lead-out and optional tab used there. <see cref="Point"/> is in the drawing's source
/// frame (the frame of <see cref="Drawing.SourceEntities"/>), which does not move when the
/// program is re-origined; <see cref="CutPinResolver"/> maps it to a placed contour.
/// Immutable: only the exact built-in lead and tab types are accepted, they are copied
/// in, and every property returns a fresh copy.
/// </summary>
public sealed class ContourCutPin
{
private readonly LeadIn leadIn;
private readonly LeadOut leadOut;
private readonly Tab tab;
/// <exception cref="ArgumentException">A missing or invalid lead, a nonfinite point, or a tab with a lead-out.</exception>
/// <exception cref="NotSupportedException">A lead or tab that is not an exact built-in type.</exception>
public ContourCutPin(Vector point, LeadIn leadIn, LeadOut leadOut, Tab tab = null)
{
PostVerificationGeometry.Validate(point);
var ownedLeadIn = OwnedCuttingParameters.CopyLeadIn(leadIn);
var ownedLeadOut = OwnedCuttingParameters.CopyLeadOut(leadOut);
var ownedTab = tab == null ? null : OwnedCuttingParameters.CopyTab(tab);
// A tab leaves the contour open at its end; a lead-out after an open contour is
// refused by the planner, so the pin cannot ask for both.
if (ownedTab != null && ownedLeadOut.GetType() != typeof(NoLeadOut))
throw new ArgumentException("A pin with a tab cannot have a lead-out.", nameof(leadOut));
Point = point;
this.leadIn = ownedLeadIn;
this.leadOut = ownedLeadOut;
this.tab = ownedTab;
}
/// <summary>The contour entry in the drawing's source frame.</summary>
public Vector Point { get; }
/// <summary>A copy of the pinned lead-in.</summary>
public LeadIn LeadIn => OwnedCuttingParameters.CopyLeadIn(leadIn);
/// <summary>A copy of the pinned lead-out; always <see cref="NoLeadOut"/> when <see cref="HasTab"/>.</summary>
public LeadOut LeadOut => OwnedCuttingParameters.CopyLeadOut(leadOut);
/// <summary>A copy of the pinned tab, or null for a closed contour.</summary>
public Tab Tab => tab == null ? null : OwnedCuttingParameters.CopyTab(tab);
public bool HasTab => tab != null;
}
}
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,6 @@ namespace OpenNest.CNC.CuttingStrategy
{
External,
Internal,
ArcCircle
ArcCircle,
}
}
@@ -15,7 +15,8 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
public LeadIn InternalLeadIn { get; set; } =
new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
@@ -23,6 +24,22 @@ namespace OpenNest.CNC.CuttingStrategy
public double PierceClearance { get; set; } = 0.0625;
/// <summary>
/// Diameter of the crater a pierce blows through this plate; 0 turns the pierce zone off.
/// When set, every lead-in pierce must keep half of it from all part material.
/// </summary>
public double PierceDiameter { get; set; }
/// <summary>The pierce zone's radius, or 0 when <see cref="PierceDiameter"/> is off.</summary>
public double GetPierceZoneRadius() =>
PierceDiameter > 0 && double.IsFinite(PierceDiameter) ? PierceDiameter / 2 : 0;
/// <summary>The clearance a pierce keeps from its own contour: the larger setting wins.</summary>
public double GetEffectivePierceClearance() => System.Math.Max(PierceClearance, GetPierceZoneRadius());
public bool RoundLeadInAngles { get; set; }
public double LeadInAngleIncrement { get; set; } = 5.0;
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -7,19 +7,23 @@ namespace OpenNest.CNC.CuttingStrategy
{
public double Radius { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenter = new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle));
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
@@ -30,10 +34,10 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle));
arcCenterY + Radius * System.Math.Sin(contourNormalAngle)
);
}
public override LeadIn Scale(double factor) =>
new ArcLeadIn { Radius = Radius * factor };
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor };
}
}
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double ArcRadius { get; set; }
public double Kerf { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle));
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle));
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, Kerf = Kerf };
new CleanHoleLeadIn
{
LineLength = LineLength * factor,
ArcRadius = ArcRadius * factor,
Kerf = Kerf,
};
}
}
@@ -1,12 +1,15 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
public abstract class LeadIn
{
public abstract List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
public abstract List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double ApproachAngle { get; set; } = 135.0;
public double ArcRadius { get; set; }
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
@@ -22,13 +25,14 @@ namespace OpenNest.CNC.CuttingStrategy
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle));
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
@@ -43,10 +47,16 @@ namespace OpenNest.CNC.CuttingStrategy
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle));
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override LeadIn Scale(double factor) =>
new LineArcLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, ApproachAngle = ApproachAngle };
new LineArcLeadIn
{
LineLength = LineLength * factor,
ArcRadius = ArcRadius * factor,
ApproachAngle = ApproachAngle,
};
}
}
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -9,15 +9,18 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length { get; set; }
public double ApproachAngle { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
};
}
@@ -26,7 +29,8 @@ namespace OpenNest.CNC.CuttingStrategy
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
return new Vector(
contourStartPoint.X + Length * System.Math.Cos(approachAngle),
contourStartPoint.Y + Length * System.Math.Sin(approachAngle));
contourStartPoint.Y + Length * System.Math.Sin(approachAngle)
);
}
public override LeadIn Scale(double factor) =>
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -11,21 +11,25 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length2 { get; set; }
public double ApproachAngle2 { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midPoint = new Vector(
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle));
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(midPoint) { Layer = LayerType.Leadin },
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin },
};
}
@@ -38,10 +42,17 @@ namespace OpenNest.CNC.CuttingStrategy
var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2);
return new Vector(
midX + Length1 * System.Math.Cos(firstAngle),
midY + Length1 * System.Math.Sin(firstAngle));
midY + Length1 * System.Math.Sin(firstAngle)
);
}
public override LeadIn Scale(double factor) =>
new LineLineLeadIn { Length1 = Length1 * factor, ApproachAngle1 = ApproachAngle1, Length2 = Length2 * factor, ApproachAngle2 = ApproachAngle2 };
new LineLineLeadIn
{
Length1 = Length1 * factor,
ApproachAngle1 = ApproachAngle1,
Length2 = Length2 * factor,
ApproachAngle2 = ApproachAngle2,
};
}
}
@@ -1,17 +1,17 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
public class NoLeadIn : LeadIn
{
public override List<ICode> Generate(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
return new List<ICode>
{
new RapidMove(contourStartPoint)
};
return new List<ICode> { new RapidMove(contourStartPoint) };
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -7,8 +7,11 @@ namespace OpenNest.CNC.CuttingStrategy
{
public double Radius { get; set; }
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
@@ -16,11 +19,12 @@ namespace OpenNest.CNC.CuttingStrategy
var endPoint = new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2));
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)
);
return new List<ICode>
{
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout }
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout },
};
}
}
@@ -1,11 +1,14 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
public abstract class LeadOut
{
public abstract List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
public abstract List<ICode> Generate(
Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
}
}
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -9,18 +9,19 @@ namespace OpenNest.CNC.CuttingStrategy
public double Length { get; set; }
public double ApproachAngle { get; set; } = 90.0;
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
var endPoint = new Vector(
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle));
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)
);
return new List<ICode>
{
new LinearMove(endPoint) { Layer = LayerType.Leadout }
};
return new List<ICode> { new LinearMove(endPoint) { Layer = LayerType.Leadout } };
}
}
}
@@ -1,12 +1,15 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
public class NoLeadOut : LeadOut
{
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
public override List<ICode> Generate(
Vector contourEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
return new List<ICode>();
}
@@ -9,7 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
BottomSide = 4,
EdgeStart = 5,
LeftSide = 7,
RightSideAlt = 8
RightSideAlt = 8,
}
public class SequenceParameters
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -10,8 +10,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double BreakerAngle { get; set; }
public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
Vector tabStartPoint,
Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var codes = new List<ICode>();
@@ -21,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
var scoreAngle = contourNormalAngle + System.Math.PI;
var scoreEnd = new Vector(
tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle),
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle));
tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)
);
codes.Add(new LinearMove(scoreEnd));
codes.Add(new RapidMove(tabEndPoint));
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -8,13 +8,13 @@ namespace OpenNest.CNC.CuttingStrategy
public int MachineTabId { get; set; }
public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
Vector tabStartPoint,
Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
return new List<ICode>
{
new RapidMove(tabEndPoint)
};
return new List<ICode> { new RapidMove(tabEndPoint) };
}
}
}
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -11,8 +11,11 @@ namespace OpenNest.CNC.CuttingStrategy
public double CutoutMaxHeight { get; set; }
public override List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
Vector tabStartPoint,
Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{
var codes = new List<ICode>();
@@ -29,8 +32,10 @@ namespace OpenNest.CNC.CuttingStrategy
public bool AppliesToCutout(double cutoutWidth, double cutoutHeight)
{
return cutoutWidth >= CutoutMinWidth && cutoutWidth <= CutoutMaxWidth
&& cutoutHeight >= CutoutMinHeight && cutoutHeight <= CutoutMaxHeight;
return cutoutWidth >= CutoutMinWidth
&& cutoutWidth <= CutoutMaxWidth
&& cutoutHeight >= CutoutMinHeight
&& cutoutHeight <= CutoutMaxHeight;
}
}
}
@@ -1,5 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
@@ -10,7 +10,10 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadOut TabLeadOut { get; set; }
public abstract List<ICode> Generate(
Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
Vector tabStartPoint,
Vector tabEndPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
);
}
}
+1 -3
View File
@@ -6,9 +6,7 @@
public const int UseMax = -2;
public Feedrate()
{
}
public Feedrate() { }
public Feedrate(double value)
{
+2 -3
View File
@@ -1,10 +1,9 @@

namespace OpenNest.CNC
namespace OpenNest.CNC
{
public enum KerfType
{
None,
Left,
Right
Right,
}
}
+15 -2
View File
@@ -1,4 +1,3 @@

namespace OpenNest.CNC
{
public enum LayerType
@@ -7,6 +6,20 @@ namespace OpenNest.CNC
Scribe,
Cut,
Leadin,
Leadout
Leadout,
/// <summary>
/// An intentionally open cut through part material, left uncut at tabs (a breakoff's
/// break line). It cuts like <see cref="Cut"/> but is not a material boundary; the scrap
/// it may be pierced from lies to the left of travel.
/// </summary>
Break,
/// <summary>
/// An uncut span of a material boundary (a tabbed cutout's tab). It bounds material like
/// <see cref="Cut"/> so the cutout stays a closed hole, but it is never cut: emission
/// leaves it out, cutting the rest of the contour as open pieces.
/// </summary>
Tab,
}
}
+4 -7
View File
@@ -6,14 +6,10 @@ namespace OpenNest.CNC
public class LinearMove : Motion
{
public LinearMove()
: this(new Vector())
{
}
: this(new Vector()) { }
public LinearMove(double x, double y)
: this(new Vector(x, y))
{
}
: this(new Vector(x, y)) { }
public LinearMove(Vector endPoint)
{
@@ -34,7 +30,8 @@ namespace OpenNest.CNC
{
Layer = Layer,
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
VariableRefs =
VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null,
};
}
+2 -3
View File
@@ -1,9 +1,8 @@

namespace OpenNest.CNC
namespace OpenNest.CNC
{
public enum Mode
{
Absolute,
Incremental
Incremental,
}
}
+130
View File
@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
namespace OpenNest.CNC
{
/// <summary>
/// Re-times <see cref="CutSimulationStep"/>s with trapezoidal acceleration, in the style of
/// Grbl's lookahead planner. Each cutting run (moves between a pierce and the next travel)
/// starts and ends at rest. Corners slow to a junction-deviation speed, arcs are capped at
/// their centripetal limit, and a backward then forward pass keeps every change of speed
/// within the acceleration. Travel and pierces are planned on their own, from rest to rest.
/// </summary>
public static class MotionPlanner
{
private const double Straight = 0.999999;
/// <summary>
/// Replaces each step's <see cref="CutSimulationStep.Duration"/> with its accelerated time,
/// from its <see cref="CutSimulationStep.NominalSpeed"/>. Pierces keep their dwell.
/// </summary>
public static void Plan(List<CutSimulationStep> steps, MotionProfile motion)
{
ArgumentNullException.ThrowIfNull(steps);
ArgumentNullException.ThrowIfNull(motion);
var start = 0;
while (start < steps.Count)
{
var end = start + 1;
if (IsCutting(steps[start]))
{
while (end < steps.Count && IsCutting(steps[end]) && steps[end].Kind == steps[start].Kind)
end++;
PlanRun(steps, start, end, motion.CutAcceleration, motion.CornerDeviation);
}
else if (steps[start].Kind == CutSimulationStepKind.Rapid)
{
PlanRun(steps, start, end, motion.RapidAcceleration, 0);
}
start = end;
}
}
private static bool IsCutting(CutSimulationStep step) =>
step.Kind is CutSimulationStepKind.Cut or CutSimulationStepKind.Scribe;
private static void PlanRun(List<CutSimulationStep> steps, int start, int end, double acceleration, double deviation)
{
if (acceleration <= 0)
return; // Keep the constant-speed timing.
var count = end - start;
var maxSpeed = new double[count];
var entry = new double[count + 1]; // entry[count] is the run's exit: at rest.
for (var i = 0; i < count; i++)
{
var step = steps[start + i];
var nominal = step.NominalSpeed;
maxSpeed[i] = step.IsArc && step.Radius > 0
? System.Math.Min(nominal, System.Math.Sqrt(acceleration * step.Radius))
: nominal;
entry[i] = i == 0 ? 0 : JunctionSpeed(steps[start + i - 1], step, acceleration, deviation, System.Math.Min(maxSpeed[i - 1], maxSpeed[i]));
}
// Backward: every step must be able to slow to the next one's entry.
for (var i = count - 1; i >= 0; i--)
entry[i] = System.Math.Min(entry[i], Reachable(entry[i + 1], acceleration, steps[start + i].Length));
// Forward: and to reach that entry from its own.
for (var i = 0; i < count; i++)
entry[i + 1] = System.Math.Min(entry[i + 1], Reachable(entry[i], acceleration, steps[start + i].Length));
for (var i = 0; i < count; i++)
Profile(steps[start + i], entry[i], entry[i + 1], maxSpeed[i], acceleration);
}
private static double Reachable(double speed, double acceleration, double length) =>
System.Math.Sqrt(speed * speed + 2 * acceleration * length);
/// <summary>
/// The fastest the head may pass from <paramref name="previous"/> into <paramref name="next"/>:
/// v² = a·δ·sin(θ/2) / (1 − sin(θ/2)), θ being the angle the path turns through.
/// </summary>
internal static double JunctionSpeed(CutSimulationStep previous, CutSimulationStep next, double acceleration, double deviation, double cap)
{
var exit = previous.TangentAt(1);
var enter = next.TangentAt(0);
var cosTheta = -(exit.X * enter.X + exit.Y * enter.Y);
if (cosTheta < -Straight)
return cap;
if (cosTheta > Straight || deviation <= 0)
return 0;
var sinHalf = System.Math.Sqrt(0.5 * (1 - cosTheta));
var speed = System.Math.Sqrt(acceleration * deviation * sinHalf / (1 - sinHalf));
return System.Math.Min(cap, speed);
}
private static void Profile(CutSimulationStep step, double entry, double exit, double maxSpeed, double acceleration)
{
var length = step.Length;
if (length <= 0 || maxSpeed <= 0)
return; // Nothing to accelerate: keep the constant-speed timing.
var cruise = maxSpeed;
var accelDistance = (cruise * cruise - entry * entry) / (2 * acceleration);
var decelDistance = (cruise * cruise - exit * exit) / (2 * acceleration);
if (accelDistance + decelDistance > length)
{
// Too short to reach full speed: a triangle peaking where the two ramps meet.
cruise = System.Math.Sqrt((2 * acceleration * length + entry * entry + exit * exit) / 2);
cruise = System.Math.Max(cruise, System.Math.Max(entry, exit));
accelDistance = (cruise * cruise - entry * entry) / (2 * acceleration);
decelDistance = (cruise * cruise - exit * exit) / (2 * acceleration);
}
var cruiseDistance = System.Math.Max(0, length - accelDistance - decelDistance);
var seconds = (cruise - entry) / acceleration + (cruise - exit) / acceleration
+ (cruise > 0 ? cruiseDistance / cruise : 0);
step.EntrySpeed = entry;
step.CruiseSpeed = cruise;
step.ExitSpeed = exit;
step.Acceleration = acceleration;
step.Duration = TimeSpan.FromSeconds(seconds);
}
}
}
+121 -33
View File
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.CNC
{
@@ -10,7 +10,10 @@ namespace OpenNest.CNC
{
public List<ICode> Codes;
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, VariableDefinition> Variables { get; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<int, Program> SubPrograms { get; } = new();
private Mode mode;
@@ -64,9 +67,17 @@ namespace OpenNest.CNC
{
if (code is Motion m)
{
var cmd = m is RapidMove ? "G00" : (m is ArcMove am ? (am.Rotation == RotationType.CW ? "G02" : "G03") : "G01");
var cmd =
m is RapidMove
? "G00"
: (
m is ArcMove am
? (am.Rotation == RotationType.CW ? "G02" : "G03")
: "G01"
);
sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}");
if (m is ArcMove arc) sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
if (m is ArcMove arc)
sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
sb.AppendLine();
}
}
@@ -79,6 +90,9 @@ namespace OpenNest.CNC
SetModeAbs();
// Several calls can share one sub-program (identical holes); rotate each once.
var rotatedSubPrograms = new HashSet<Program>(ReferenceEqualityComparer.Instance);
for (int i = 0; i < Codes.Count; ++i)
{
var code = Codes[i];
@@ -87,7 +101,19 @@ namespace OpenNest.CNC
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program != null)
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
{
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
var dx = subpgm.Offset.X - origin.X;
var dy = subpgm.Offset.Y - origin.Y;
subpgm.Offset = new Geometry.Vector(
origin.X + dx * cos - dy * sin,
origin.Y + dx * sin + dy * cos
);
}
if (subpgm.Program != null && rotatedSubPrograms.Add(subpgm.Program))
subpgm.Program.Rotate(angle, origin);
}
@@ -115,6 +141,11 @@ namespace OpenNest.CNC
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(subpgm.Offset.X + x, subpgm.Offset.Y + y);
}
if (code is Motion == false)
continue;
@@ -137,6 +168,14 @@ namespace OpenNest.CNC
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + voffset.X,
subpgm.Offset.Y + voffset.Y
);
}
if (code is Motion == false)
continue;
@@ -239,7 +278,8 @@ namespace OpenNest.CNC
var code = Codes[i];
var motion = code as Motion;
if (motion == null) continue;
if (motion == null)
continue;
return motion.EndPoint;
}
@@ -255,7 +295,8 @@ namespace OpenNest.CNC
var code = Codes[i];
var motion = code as Motion;
if (motion == null) continue;
if (motion == null)
continue;
pos += motion.EndPoint;
}
@@ -267,18 +308,27 @@ namespace OpenNest.CNC
return new Vector(0, 0);
}
/// <summary>
/// Bounding box of the geometry the program visits. The tool's starting position is not
/// part of the geometry, so the origin only contributes when the program reaches it.
/// An empty program returns a zero-size box at the origin.
/// </summary>
public Box BoundingBox()
{
var origin = new Vector(0, 0);
return BoundingBox(ref origin);
return BoundingBox(ref origin, out var box) ? box : new Box(0, 0, 0, 0);
}
private Box BoundingBox(ref Vector pos)
private bool BoundingBox(ref Vector pos, out Box result)
{
double minX = 0.0;
double minY = 0.0;
double maxX = 0.0;
double maxY = 0.0;
// Capture the frame origin at entry. Sub-program Offsets and
// absolute-mode endpoints are relative to this fixed origin.
var frameOrigin = pos;
var minX = double.PositiveInfinity;
var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity;
var maxY = double.NegativeInfinity;
for (int i = 0; i < Codes.Count; ++i)
{
@@ -289,18 +339,19 @@ namespace OpenNest.CNC
case CodeType.LinearMove:
{
var line = (LinearMove)code;
var pt = Mode == Mode.Absolute ?
line.EndPoint :
line.EndPoint + pos;
var pt =
Mode == Mode.Absolute
? frameOrigin + line.EndPoint
: line.EndPoint + pos;
if (pt.X > maxX)
maxX = pt.X;
else if (pt.X < minX)
if (pt.X < minX)
minX = pt.X;
if (pt.Y > maxY)
maxY = pt.Y;
else if (pt.Y < minY)
if (pt.Y < minY)
minY = pt.Y;
pos = pt;
@@ -311,18 +362,19 @@ namespace OpenNest.CNC
case CodeType.RapidMove:
{
var line = (RapidMove)code;
var pt = Mode == Mode.Absolute
? line.EndPoint
: line.EndPoint + pos;
var pt =
Mode == Mode.Absolute
? frameOrigin + line.EndPoint
: line.EndPoint + pos;
if (pt.X > maxX)
maxX = pt.X;
else if (pt.X < minX)
if (pt.X < minX)
minX = pt.X;
if (pt.Y > maxY)
maxY = pt.Y;
else if (pt.Y < minY)
if (pt.Y < minY)
minY = pt.Y;
pos = pt;
@@ -345,8 +397,8 @@ namespace OpenNest.CNC
}
else
{
endpt = arc.EndPoint;
centerpt = arc.CenterPoint;
endpt = frameOrigin + arc.EndPoint;
centerpt = frameOrigin + arc.CenterPoint;
}
double minX1;
@@ -420,7 +472,14 @@ namespace OpenNest.CNC
case CodeType.SubProgramCall:
{
var subpgm = (SubProgramCall)code;
var box = subpgm.Program.BoundingBox(ref pos);
if (subpgm.Program == null)
break;
// Sub-program frame origin in this program's frame
// is frameOrigin + Offset, regardless of current pos.
pos = frameOrigin + subpgm.Offset;
if (!subpgm.Program.BoundingBox(ref pos, out var box))
break;
if (box.Left < minX)
minX = box.Left;
@@ -439,16 +498,19 @@ namespace OpenNest.CNC
}
}
return new Box(minX, minY, maxX - minX, maxY - minY);
if (minX > maxX || minY > maxY)
{
result = new Box(0, 0, 0, 0);
return false;
}
result = new Box(minX, minY, maxX - minX, maxY - minY);
return true;
}
public object Clone()
{
var pgm = new Program()
{
mode = this.mode,
Rotation = this.Rotation
};
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
var codes = new ICode[Length];
@@ -460,6 +522,32 @@ namespace OpenNest.CNC
foreach (var kvp in Variables)
pgm.Variables[kvp.Key] = kvp.Value;
// The copy owns its sub-programs: rotating it must never turn the source's holes.
// Calls that shared one sub-program keep sharing one copy.
Dictionary<Program, Program> subCopies = null;
Program CopyOf(Program sub)
{
subCopies ??= new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
if (!subCopies.TryGetValue(sub, out var copy))
{
copy = (Program)sub.Clone();
subCopies[sub] = copy;
}
return copy;
}
foreach (var kvp in SubPrograms)
pgm.SubPrograms[kvp.Key] = CopyOf(kvp.Value);
foreach (var code in codes)
{
if (code is SubProgramCall call && call.Program != null)
call.BindProgram(CopyOf(call.Program));
}
return pgm;
}
Loaded 100 of 1379 files, more files were not shown because too many files have changed in this diff. Show more