359 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
657 changed files with 78846 additions and 6965 deletions

No files matched your search

+9 -4
View File
@@ -28,7 +28,7 @@ dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
```
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`.
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.
@@ -37,18 +37,19 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `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. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest.IO`: ACadSharp 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.
- Clipper is for cached CPU region preparation, never per-pair hot loops. Preserve the hand-written `Collision` kernel's GPU-port contract. Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding. Do not loosen tolerances to hide failures.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check 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.
@@ -56,9 +57,13 @@ 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)
- [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`.
+3
View File
@@ -5,6 +5,9 @@ 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; }
+20 -4
View File
@@ -5,6 +5,7 @@ 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;
@@ -13,10 +14,14 @@ 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 physical sheet to its source stock identity.</summary>
/// <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>
@@ -24,10 +29,11 @@ public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
public class NestResponse
{
public const int CurrentSchemaVersion = 3;
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>
@@ -42,8 +48,12 @@ public class NestResponse
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; }
@@ -86,6 +96,8 @@ public class NestResponse
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
? []
@@ -97,7 +109,7 @@ public class NestResponse
var nestEntry = zip.CreateEntry("nest.nest");
using var nestMs = new MemoryStream();
new NestWriter(Nest).Write(nestMs);
new NestWriter(Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(nestMs);
nestMs.Position = 0;
await using (var stream = nestEntry.Open())
{
@@ -154,7 +166,7 @@ public class NestResponse
await stream.CopyToAsync(nestMs);
}
nestMs.Position = 0;
nest = new NestReader(nestMs).Read();
nest = new NestReader(nestMs) { BestFitStore = BestFitCacheStore.Shared }.Read();
}
return new NestResponse
@@ -169,8 +181,10 @@ public class NestResponse
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,
};
@@ -178,6 +192,7 @@ public class NestResponse
private sealed class NestResponseArchiveDto
{
public NestCostSummary Costs { get; init; }
public int SchemaVersion { get; init; }
public int SheetCount { get; init; }
public double Utilization { get; init; }
@@ -188,6 +203,7 @@ public class NestResponse
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; } = [];
}
+18 -9
View File
@@ -28,6 +28,7 @@ public static class NestRunner
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);
@@ -82,7 +83,6 @@ public static class NestRunner
});
}
var stock = CreateStock(request);
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = NestPipeline.Run(new NestPipelineRequest(
@@ -91,6 +91,9 @@ public static class NestRunner
// 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)
@@ -100,7 +103,7 @@ public static class NestRunner
PartSpacing = proposed.Stock.PartSpacing,
EdgeSpacing = proposed.Stock.EdgeSpacing,
Quadrant = proposed.Stock.Quadrant,
Quantity = 1,
Quantity = proposed.Quantity,
};
plate.Parts.AddRange(proposed.Parts);
nest.Plates.Add(plate);
@@ -108,17 +111,19 @@ public static class NestRunner
// 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)
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count());
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.Count(p => p.Stock.Id == s.Id);
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);
@@ -133,7 +138,7 @@ public static class NestRunner
return Task.FromResult(
new NestResponse
{
SheetCount = nest.Plates.Count,
SheetCount = nest.Plates.Sum(p => p.Quantity),
Utilization = CalculateUtilization(nest),
CutTime = cutTime,
Elapsed = sw.Elapsed,
@@ -143,7 +148,9 @@ public static class NestRunner
: 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,
@@ -209,11 +216,13 @@ public static class NestRunner
plate.Quantity,
plate.PartSpacing,
plate.EdgeSpacing,
plate.Quadrant
plate.Quadrant,
plate.Cost
)
);
}
NestJobValidator.ValidateStockCosts(stock);
return stock;
}
@@ -242,11 +251,11 @@ public static class NestRunner
private static double CalculateUtilization(Nest nest)
{
var sheetArea = nest.Plates.Sum(plate => plate.Area());
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.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) * plate.Quantity
);
return placedArea / sheetArea;
}
+3 -2
View File
@@ -5,6 +5,7 @@ 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;
@@ -290,7 +291,7 @@ namespace OpenNest.Benchmark
// 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).Read();
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;
@@ -316,7 +317,7 @@ namespace OpenNest.Benchmark
throw new InvalidOperationException(
"Output must not overwrite the source nest."
);
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
new OpenNest.IO.NestWriter(materialized.Nest) { BestFitStore = BestFitCacheStore.Shared }.Write(path);
var report = new
{
Source = job.SourceFile,
+2 -1
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.IO;
@@ -39,7 +40,7 @@ namespace OpenNest.Benchmark
try
{
nest = new NestReader(file).Read();
nest = new NestReader(file) { BestFitStore = BestFitCacheStore.Shared }.Read();
}
catch (Exception ex)
{
+357 -99
View File
@@ -5,10 +5,15 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using OpenNest;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
@@ -75,6 +80,20 @@ static class NestConsole
return 0;
}
if (!options.AutoNest && (options.MaxSheets != 1 || options.Stock != null))
{
Console.Error.WriteLine("Error: --max-sheets and --stock require --autonest.");
return 1;
}
if (options.MaxSheets < 1 || options.Stock is { Count: 0 })
{
Console.Error.WriteLine(
"Error: --max-sheets needs a whole number >= 1 and --stock needs WxL[:cost] sizes separated by commas (for example 60x120,48x96:85)."
);
return 1;
}
// Validate --engine up front: autonest names a jobs engine, plain fill names a
// single-plate placement strategy. Unknown names exit with the valid choices.
if (options.AutoNest)
@@ -90,19 +109,37 @@ static class NestConsole
}
else
{
try
{
PlateFillService.ResolveStrategy(options.Engine);
}
catch (NotSupportedException)
var engine = EngineNameResolver.Classify(options.Engine);
if (!engine.IsFillStrategy)
{
Console.Error.WriteLine(
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
engine.IsJobEngine
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)"
);
return 1;
}
}
if (!options.PlanCutting && (options.CuttingParamsFile != null || options.KeepPartOrder
|| options.AcceptUnverifiedPlan || options.AllPlates))
{
Console.Error.WriteLine(
"Error: --cutting-params, --keep-part-order, --accept-unverified-plan and --all-plates require --plan-cutting."
);
return 1;
}
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
{
Console.Error.WriteLine(
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template."
);
return 1;
}
if (options.InputFiles.Count == 0)
{
PrintUsage();
@@ -118,42 +155,74 @@ static class NestConsole
}
}
// Read explicit settings before nesting, so a bad file fails without wasting the fill.
CuttingParameters cuttingParameters = null;
if (options.CuttingParamsFile != null)
{
try
{
cuttingParameters = CuttingParametersSerializer.DeserializeExplicit(
File.ReadAllText(options.CuttingParamsFile));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException
or NotSupportedException)
{
Console.Error.WriteLine($"Error: cannot read cutting settings '{options.CuttingParamsFile}': {ex.Message}");
return 1;
}
}
var nest = LoadOrCreateNest(options);
if (nest == null)
return 1;
var plate = nest.Plates[options.PlateIndex];
IReadOnlyList<Plate> plates = new[] { plate };
var overlapCount = 0;
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
if (options.NoFill)
{
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
return 2;
Console.WriteLine($"Nest: {nest.Name}");
overlapCount = CheckOverlaps(nest, plates, options);
}
else
{
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
{
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
return 2;
}
ApplyTemplate(plate, options);
ApplyOverrides(plate, options);
var drawing = ResolveDrawing(nest, options);
if (drawing == null)
return 1;
var existingCount = plate.Parts.Count;
if (!options.AutoNest && !options.KeepParts)
plate.Parts.Clear();
PrintHeader(nest, plate, drawing, existingCount, options);
var (success, elapsed, accepted, filled) = Fill(nest, plate, drawing, options, token);
if (!accepted)
return 2; // No save or post may run after a rejected proposal.
plates = filled;
overlapCount = CheckOverlaps(nest, plates, options);
PrintResults(success, nest, plates, elapsed);
}
ApplyTemplate(plate, options);
ApplyOverrides(plate, options);
var drawing = ResolveDrawing(nest, options);
if (drawing == null)
return 1;
var existingCount = plate.Parts.Count;
if (!options.AutoNest && !options.KeepParts)
plate.Parts.Clear();
PrintHeader(nest, plate, drawing, existingCount, options);
var (success, elapsed, accepted) = Fill(nest, plate, drawing, options, token);
if (!accepted)
return 2; // No save or post may run after a rejected proposal.
var overlapCount = CheckOverlaps(plate, options);
PrintResults(success, plate, elapsed);
token.ThrowIfCancellationRequested();
if (options.PlanCutting && !PlanCutting(nest, options.AllPlates ? nest.Plates : plates,
cuttingParameters, options, token))
return 2; // Like a rejected nest: nothing is saved or posted.
if (!SaveAndPost(nest, options))
return 1;
@@ -225,6 +294,14 @@ static class NestConsole
case "--autonest":
o.AutoNest = true;
break;
case "--max-sheets" when i + 1 < args.Length:
o.MaxSheets = int.TryParse(args[++i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sheets)
? sheets
: 0;
break;
case "--stock" when i + 1 < args.Length:
o.Stock = ParseStock(args[++i]);
break;
case "--engine" when i + 1 < args.Length:
o.Engine = args[++i];
break;
@@ -243,6 +320,24 @@ static class NestConsole
case "--list-posts":
o.ListPosts = true;
break;
case "--no-fill":
o.NoFill = true;
break;
case "--plan-cutting":
o.PlanCutting = true;
break;
case "--cutting-params" when i + 1 < args.Length:
o.CuttingParamsFile = args[++i];
break;
case "--keep-part-order":
o.KeepPartOrder = true;
break;
case "--accept-unverified-plan":
o.AcceptUnverifiedPlan = true;
break;
case "--all-plates":
o.AllPlates = true;
break;
case "--help":
case "-h":
PrintUsage();
@@ -257,23 +352,54 @@ static class NestConsole
return o;
}
/// <summary>Parses "WxL[:cost],..."; any malformed entry returns an empty list, which validation rejects.</summary>
static List<PlateOption> ParseStock(string value)
{
var stock = new List<PlateOption>();
foreach (var entry in value.Split(',', StringSplitOptions.TrimEntries))
{
var parts = entry.Split(':', StringSplitOptions.TrimEntries);
var cost = 0.0;
if (
parts.Length > 2
|| !Size.TryParse(parts[0], out var size)
|| !double.IsFinite(size.Width)
|| !double.IsFinite(size.Length)
|| size.Width <= 0
|| size.Length <= 0
|| (
parts.Length == 2
&& (
!double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out cost)
|| !double.IsFinite(cost)
|| cost <= 0
)
)
)
return new List<PlateOption>();
stock.Add(new PlateOption { Width = size.Width, Length = size.Length, Cost = cost });
}
return stock;
}
static bool IsCadFile(string path) =>
path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase);
static Nest LoadOrCreateNest(Options options)
{
var nestFile = options.InputFiles.FirstOrDefault(f =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
);
var dxfFiles = options
.InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
.ToList();
var dxfFiles = options.InputFiles.Where(IsCadFile).ToList();
// If we have a nest file, load it and optionally add DXFs.
if (nestFile != null)
{
var nest = new NestReader(nestFile).Read();
var nest = new NestReader(nestFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
if (nest.Plates.Count == 0)
{
@@ -318,9 +444,11 @@ static class NestConsole
return null;
}
// Build the plate as the desktop does: Quantity = 1 so part counts and the
// drawings' nested quantities include it.
var newNest = new Nest { Name = "DXF Import" };
var plate = new Plate { Size = options.PlateSize.Value };
newNest.Plates.Add(plate);
newNest.PlateDefaults.Size = options.PlateSize.Value;
newNest.CreatePlate();
foreach (var dxf in dxfFiles)
{
@@ -377,7 +505,7 @@ static class NestConsole
return;
}
var templateNest = new NestReader(options.TemplateFile).Read();
var templateNest = new NestReader(options.TemplateFile) { BestFitStore = BestFitCacheStore.Shared }.Read();
var templatePlate = templateNest.PlateDefaults.CreateNew();
plate.Quadrant = templatePlate.Quadrant;
plate.EdgeSpacing = templatePlate.EdgeSpacing;
@@ -446,7 +574,7 @@ static class NestConsole
);
}
static (bool success, long elapsedMs, bool accepted) Fill(
static (bool success, long elapsedMs, bool accepted, IReadOnlyList<Plate> plates) Fill(
Nest nest,
Plate plate,
Drawing drawing,
@@ -457,6 +585,7 @@ static class NestConsole
var sw = Stopwatch.StartNew();
bool success;
var accepted = true;
IReadOnlyList<Plate> plates = new[] { plate };
if (options.AutoNest)
{
@@ -477,15 +606,17 @@ static class NestConsole
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
);
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
var outcome = AutoNestJob(nest, plate, nestItems, options, token);
accepted = outcome.accepted;
success = outcome.committed > 0;
if (accepted)
plates = outcome.plates;
}
else
{
// Single-plate fill: explicit placement strategy through the public service;
// the process-global engine registry is never consulted.
var strategy = ResolveFillStrategy(options.Engine);
var strategy = EngineNameResolver.Classify(options.Engine).FillStrategy;
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
var parts = PlateFillService.FillItem(
strategy,
@@ -503,95 +634,201 @@ static class NestConsole
}
sw.Stop();
return (success, sw.ElapsedMilliseconds, accepted);
return (success, sw.ElapsedMilliseconds, accepted, plates);
}
static (bool accepted, int committed) AutoNestJob(
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
{
var rejected = (false, 0, (IReadOnlyList<Plate>)Array.Empty<Plate>());
// One sheet of the target's size keeps the original single-sheet stock exactly;
// otherwise the target is the template for every offered size, unlimited per size
// and capped by --max-sheets physical sheets in total.
var singleSheet = options.MaxSheets == 1 && options.Stock == null;
var stock = singleSheet
? NestStockBuilder.SinglePlate(plate)
: NestStockBuilder.FromTemplate(plate, options.Stock);
var result = NestPipeline.Run(new NestPipelineRequest(
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
options.Engine, nestItems, stock,
singleSheet ? null : new NestJobOptions(maxPlates: options.MaxSheets)), token: token);
foreach (var violation in result.Violations)
Console.Error.WriteLine($"Violation: {violation}");
foreach (var unplaced in result.Unplaced)
Console.Error.WriteLine($"Unplaced: {unplaced.Describe()}");
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
var tooManySheets = result.Raw.Plates.Count > options.MaxSheets;
if (!result.CanKeep || tooManySheets || (!result.IsValid && !options.AllowInvalid))
{
Console.Error.WriteLine(result.Plates.Count > 1
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
Console.Error.WriteLine(tooManySheets
? $"Error: result uses {result.Raw.Plates.Count} sheets, more than --max-sheets {options.MaxSheets}, even with --allow-invalid. Nothing saved."
: !result.CanKeep
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
return (false, 0);
return rejected;
}
token.ThrowIfCancellationRequested();
var proposed = result.Plates.SingleOrDefault();
var committed = proposed?.Parts.Count ?? 0;
var committed = result.Plates.Sum(p => p.Parts.Count * p.Quantity);
if (committed == 0)
{
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
return (false, 0);
return rejected;
}
plate.Parts.Clear();
plate.Size = proposed.Stock.Size;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = 1;
plate.Parts.AddRange(proposed.Parts);
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
return (true, committed);
var costs = result.IsValid && options.Stock != null ? NestCostSummary.FromAccepted(result) : null;
// The first layout replaces the target's parts; further layouts become new sheets
// right after it, carrying the target's cutting settings. Other plates are unchanged.
var insertAt = nest.Plates.IndexOf(plate) + 1;
var targetUsed = false;
var applied = NestPipelineCommit.ApplyToPlates(result, () =>
{
if (!targetUsed)
{
targetUsed = true;
plate.Parts.Clear();
return plate;
}
var created = new Plate(plate.Size)
{
GrainAngle = plate.GrainAngle,
CuttingParameters = plate.CuttingParameters,
};
nest.Plates.Insert(insertAt++, created);
return created;
}, options.AllowInvalid, token);
Console.WriteLine($"Engine: {options.Engine} — committed {committed} placements on {applied.Sum(p => p.Quantity)} sheet(s)");
if (costs != null)
Console.WriteLine($"Cost ({costs.Basis}): gross {costs.GrossTotal:F2}, salvage credit {costs.SalvageCredit:F2}, net {costs.NetScore:F2}");
return (true, committed, applied);
}
static string ResolveFillStrategy(string engineName)
static int CheckOverlaps(Nest nest, IReadOnlyList<Plate> plates, Options options)
{
try
{
return PlateFillService.ResolveStrategy(engineName);
}
catch (NotSupportedException)
{
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
);
Console.Error.WriteLine(
isJobEngine
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
);
Environment.Exit(1);
throw; // unreachable
}
}
static int CheckOverlaps(Plate plate, Options options)
{
if (!options.CheckOverlaps || plate.Parts.Count == 0)
if (!options.CheckOverlaps)
return 0;
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
Console.WriteLine(
hasOverlaps
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
: "Overlap check: PASS"
);
var total = 0;
foreach (var plate in plates.Where(p => p.Parts.Count > 0))
{
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
var label = plates.Count > 1 ? $"Plate {nest.Plates.IndexOf(plate)}: " : "";
Console.WriteLine(
hasOverlaps
? $"{label}OVERLAPS DETECTED: {overlapPts.Count} intersection points"
: $"{label}Overlap check: PASS"
);
total += overlapPts.Count;
}
return overlapPts.Count;
return total;
}
static void PrintResults(bool success, Plate plate, long elapsedMs)
static void PrintResults(bool success, Nest nest, IReadOnlyList<Plate> plates, long elapsedMs)
{
if (plates.Count > 1)
{
foreach (var plate in plates)
Console.WriteLine(
$"Plate {nest.Plates.IndexOf(plate)}: {plate.Size.Width:F1} x {plate.Size.Length:F1} x{plate.Quantity}, {plate.Parts.Count} parts, utilization {plate.Utilization():P1}"
);
}
var area = plates.Sum(p => p.Size.Width * p.Size.Length * p.Quantity);
var utilization = area > 0
? plates.Sum(p => p.Utilization() * p.Size.Width * p.Size.Length * p.Quantity) / area
: 0;
Console.WriteLine(
$"""
Result: {(success ? "success" : "failed")}
Parts placed: {plate.Parts.Count}
Utilization: {plate.Utilization():P1}
Parts placed: {plates.Sum(p => p.Parts.Count * p.Quantity)}
Utilization: {utilization:P1}
Time: {elapsedMs}ms
"""
);
}
/// <summary>
/// Plans and applies cutting (part order, contour order, entries and lead-ins) on the
/// occupied plates in scope, all or nothing. Settings are the explicit file or the plates'
/// own saved settings; none are invented. Unverified plans apply only with per-invocation
/// acceptance, which is never consent to post.
/// </summary>
static bool PlanCutting(Nest nest, IEnumerable<Plate> plates, CuttingParameters explicitParameters,
Options options, CancellationToken token)
{
var scope = plates.Where(p => p.Parts.Count > 0).ToArray();
if (scope.Length == 0)
{
Console.Error.WriteLine("Error: no occupied plates to plan cutting for. Nothing saved.");
return false;
}
var parameters = explicitParameters ?? SavedCuttingParameters(scope);
if (parameters == null)
{
Console.Error.WriteLine(
"Error: --plan-cutting needs confirmed cutting settings: pass --cutting-params <settings.json>, or plan plates that all carry the same saved settings. Nothing saved."
);
return false;
}
Console.WriteLine("---");
Console.WriteLine($"Cutting settings: {(explicitParameters != null ? options.CuttingParamsFile : "saved with the plates")}");
var batch = CuttingPlanBatch.Capture(scope, parameters, options.KeepPartOrder,
scope.Select(p => nest.Plates.IndexOf(p)).ToArray(), token);
var proposal = batch.Plan(new PlanningProgress(scope.Length), token);
token.ThrowIfCancellationRequested();
foreach (var line in proposal.Describe(UnitsHelper.GetShortString(nest.Units),
"Pass --cutting-params settings that select"))
Console.WriteLine(line);
if (!(proposal.CanApply || options.AcceptUnverifiedPlan && proposal.CanApplyWithWarnings))
{
Console.Error.WriteLine(proposal.RequiresWarningAcceptance
? "Cutting plan not applied: it is unverified. Review the warnings above; to apply it anyway, explicitly use --accept-unverified-plan for this invocation. Nothing saved."
: "Cutting plan not applied: every plate must be ready. Nothing saved.");
return false;
}
var commit = proposal.Apply(options.AcceptUnverifiedPlan, token);
if (commit.Status != CuttingCommitStatus.Applied)
{
Console.Error.WriteLine($"Error: cutting plan not applied ({commit.Status}): {commit.Message} Nothing saved.");
return false;
}
Console.WriteLine($"Cutting plan applied to {scope.Length} plate(s).");
return true;
}
/// <summary>The plates' saved settings when every plate carries the same ones; otherwise null.</summary>
static CuttingParameters SavedCuttingParameters(IReadOnlyList<Plate> plates)
{
var first = plates[0].CuttingParameters;
if (first == null)
return null;
var json = CuttingParametersSerializer.Serialize(first);
return plates.All(p => p.CuttingParameters != null
&& CuttingParametersSerializer.Serialize(p.CuttingParameters) == json) ? first : null;
}
/// <summary>Reports synchronously, so phase lines print in order with the summary.</summary>
sealed class PlanningProgress(int plateCount) : IProgress<CuttingPlanProgress>
{
public void Report(CuttingPlanProgress value) => Console.WriteLine(
(plateCount > 1 ? $"Planning plate {value.PlateNumber}: " : "Planning: ") + value.Phase switch
{
CuttingPlanPhase.Reordering => "finding a part order...",
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
_ => value.Phase + "...",
});
}
static void Save(Nest nest, Options options)
{
if (options.NoSave)
@@ -599,7 +836,7 @@ static class NestConsole
var outputFile = NestOutputPath(options);
new NestWriter(nest).Write(outputFile);
new NestWriter(nest) { BestFitStore = BestFitCacheStore.Shared }.Write(outputFile);
Console.WriteLine($"Saved: {outputFile}");
}
@@ -760,11 +997,24 @@ static class NestConsole
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
--max-sheets <n> With --autonest: physical sheets the job may use (default: 1); extra
layouts are inserted as new plates after the target plate
--stock <WxL[:cost],...> With --autonest: sheet sizes to offer, each unlimited (default: the
target plate's size); spacing, quadrant and clamps follow the target
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...).
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant)
--keep-parts Don't clear existing parts before filling
--no-fill Keep the loaded .nest as it is (no fill); plan, save or post it
--plan-cutting After filling (or loading with --no-fill), plan cutting (part order,
contour order, entries, lead-ins); applied only when every plate is
ready or --accept-unverified-plan is given (else exit 2)
--cutting-params <path> Cutting settings JSON for --plan-cutting (default: the plates' own
saved settings, which must agree; never invented)
--keep-part-order With --plan-cutting: keep the current whole-part order
--all-plates With --plan-cutting: plan every occupied plate, not just the target
--accept-unverified-plan Explicitly apply an unverified cutting plan for this invocation
--check-overlaps Run overlap detection after fill (exit code 1 if found)
--no-save Skip saving output file
--post <name> Run a post processor after nesting
@@ -791,6 +1041,8 @@ static class NestConsole
public bool KeepParts;
public bool AutoNest;
public bool AllowInvalid;
public int MaxSheets = 1;
public List<PlateOption> Stock;
public string Engine = "Default";
public string TemplateFile;
public string PostName;
@@ -798,6 +1050,12 @@ static class NestConsole
public string PostOutput;
public string PostsDir;
public bool ListPosts;
public bool NoFill;
public bool PlanCutting;
public string CuttingParamsFile;
public bool KeepPartOrder;
public bool AcceptUnverifiedPlan;
public bool AllPlates;
public double? RepairBendsMillimeters;
public BendRepairUnits CadUnits;
}
+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;
}
}
+80 -17
View File
@@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch
/// 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 plans;
return new AutomaticCutOffStaging(plans, []);
var restoreActions = new List<(int PlateNumber, Action Restore)>();
try
@@ -66,21 +74,7 @@ public static class AutomaticCutOffBatch
}
catch (Exception applyError)
{
var rollbackErrors = 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.
rollbackErrors.Add(new InvalidOperationException(
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
}
}
var rollbackErrors = RestoreAll(restoreActions);
if (rollbackErrors.Count > 0)
throw new InvalidOperationException(
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
@@ -91,7 +85,27 @@ public static class AutomaticCutOffBatch
throw new InvalidOperationException(
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
}
return plans;
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)
@@ -117,3 +131,52 @@ public static class AutomaticCutOffBatch
};
}
}
/// <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));
}
}
+189 -19
View File
@@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions
/// 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
@@ -29,8 +35,10 @@ public enum AutomaticCutOffDiagnosticCode
LimitedCutOffConflict,
EmptyCut,
SegmentedCut,
ClampBlockedCut,
NoSafeTailSeparator,
TailBelowMinimum,
ShiftedCut,
}
public sealed record AutomaticCutOffDiagnostic(
@@ -69,6 +77,12 @@ public sealed class AutomaticCutOffPlan
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue;
/// <summary>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);
}
@@ -78,6 +92,7 @@ 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.
@@ -93,6 +108,8 @@ public static class AutomaticCutOffPlanner
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));
@@ -118,7 +135,11 @@ public static class AutomaticCutOffPlanner
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance)
+ GeometryTolerance;
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
var usedSpan = hasTailCandidate ? separator : length;
// 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));
@@ -130,34 +151,79 @@ public static class AutomaticCutOffPlanner
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>();
// Multiplication by an integer avoids drift from repeated floating-point addition.
for (var index = 1; index <= (int)candidateCount; index++)
if (shifted)
{
var distance = index * options.Spacing;
if (distance >= usedSpan - GeometryTolerance)
break;
AddCandidate(sign * distance, false);
// 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);
}
if (hasTailCandidate)
else
{
if (length - separator >= options.MinimumTailLength)
AddCandidate(sign * separator, true);
else
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
"The proposed tail is shorter than the minimum tail length; the final separator was skipped. "
+ "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.",
X: sign * separator));
// 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
{
@@ -168,12 +234,74 @@ public static class AutomaticCutOffPlanner
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(
@@ -185,6 +313,24 @@ public static class AutomaticCutOffPlanner
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();
@@ -202,7 +348,7 @@ public static class AutomaticCutOffPlanner
if (!usable)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.EmptyCut,
"The line has no usable cut segments after part clearance and minimum-length filtering; it is not a partition.",
"The line has no usable cut segments after part clearance and minimum-length filtering.",
X: x));
if (isSeparator)
@@ -218,16 +364,33 @@ public static class AutomaticCutOffPlanner
else if (usable && !IsFullSpanProgram(program, bounds))
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.SegmentedCut,
"Part clearance or segment filtering interrupts this line; nominal spacing does not guarantee disconnected scrap.",
"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. No separated tail is claimed; review manually.",
"No safe full-width tail separator survives the current settings. Review the tail manually.",
X: x));
}
@@ -235,6 +398,10 @@ public static class AutomaticCutOffPlanner
{
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 &&
@@ -242,6 +409,9 @@ public static class AutomaticCutOffPlanner
"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));
+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;
}
}
+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;
}
}
}
+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;
}
}
@@ -33,6 +33,9 @@ internal enum AutomaticEntryKind
/// <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>
@@ -47,6 +47,7 @@ internal static class ContourProgramVerifier
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();
@@ -55,11 +56,13 @@ internal static class ContourProgramVerifier
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) && curve.Contains(first.Start)
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
ringIndex = r;
@@ -67,28 +70,71 @@ internal static class ContourProgramVerifier
break;
}
}
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
}
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;
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
if (missing > Epsilon)
// 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) > Epsilon)
|| 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, token, ref budget)) return false;
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)
{
@@ -107,20 +153,24 @@ internal static class ContourProgramVerifier
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, token, ref budget)) return false;
|| !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, CancellationToken token, ref int budget)
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
{
foreach (var curve in actual)
{
var consumed = 0.0;
while (consumed < curve.Length - Epsilon)
// 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)
@@ -137,13 +187,13 @@ internal static class ContourProgramVerifier
continue;
}
var length = System.Math.Min(remaining, curve.Length - consumed);
if (!nominal.SameDirection(curve)
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
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 - Epsilon)
if (offset >= nominal.Length - tolerance)
{
index++;
offset = 0;
@@ -151,7 +201,7 @@ internal static class ContourProgramVerifier
}
}
return cyclic || index == expected.Count || index == expected.Count - 1
&& expected[index].Length - offset <= Epsilon;
&& expected[index].Length - offset <= tolerance;
}
private static List<Run> Runs(OwnedExecution execution)
@@ -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,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);
}
@@ -4,22 +4,43 @@ 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, string reason)
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,
@@ -29,16 +50,20 @@ public sealed class LeadMaterialSnapshot
{
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();
if (motion.Rapid || motion.Layer is LayerType.Scribe or LayerType.Leadin or LayerType.Leadout)
// 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;
}
if (motion.Layer is not (LayerType.Cut or LayerType.Display))
// 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)
@@ -46,7 +71,8 @@ public sealed class LeadMaterialSnapshot
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve);
if (PostVerificationGeometry.Closed(chain))
chainTabs.Add(motion.Layer == LayerType.Tab);
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish();
}
Finish();
@@ -66,11 +92,23 @@ public sealed class LeadMaterialSnapshot
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) <= PostVerificationGeometry.Epsilon
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon)
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon)))
|| !(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;
@@ -86,26 +124,36 @@ public sealed class LeadMaterialSnapshot
}
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(), null);
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 (!PostVerificationGeometry.Closed(chain))
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
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[]>(), ex.Message);
return new LeadMaterialSnapshot(Array.Empty<PostVerificationGeometry.Curve[]>(), Array.Empty<bool[]>(), ex.Message);
}
}
@@ -10,15 +10,52 @@ 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)
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
@@ -30,11 +67,25 @@ public static class LeadPathValidator
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.");
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;
@@ -48,10 +99,27 @@ public static class LeadPathValidator
}
var adjacentIndex = groupEdge + step;
var genuineJoint = false;
var breakLead = false;
if (adjacentIndex >= 0 && adjacentIndex < execution.Motions.Count)
{
var adjacent = execution.Motions[adjacentIndex];
if (!adjacent.Rapid && adjacent.Layer is LayerType.Cut or LayerType.Display
// 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];
@@ -77,48 +145,163 @@ public static class LeadPathValidator
}
}
if (!genuineJoint)
return new(true, false, "Lead chain has no genuine adjacent target contour entry or exit.");
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.");
var failure = CheckMaterial(target, allowed);
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, $"Lead motion {i} contacts or enters target material outside its adjacent contour joint ({failure}).");
foreach (var material in otherMaterials)
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 (CheckMaterial(material, null) != null)
return new(true, false, "Lead contacts or enters another placed material. "
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.");
+ "For locked parts, edit the leads or unlock the part before replanning."), other);
}
return PierceZone();
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
// 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()
{
foreach (var boundary in material.Rings.SelectMany(r => r))
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();
if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = move.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}";
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);
}
// 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 material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
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;
}
}
// Missing leads are the ReleasedContourState check's responsibility.
return new(true, true, null);
}
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;
}
}
@@ -30,6 +30,7 @@ internal static class OwnedCuttingParameters
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),
@@ -65,7 +66,7 @@ internal static class OwnedCuttingParameters
};
}
private static LeadIn CopyLeadIn(LeadIn source) => source switch
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
@@ -86,7 +87,7 @@ internal static class OwnedCuttingParameters
_ => throw new NotSupportedException("Unsupported lead-in runtime type.")
};
private static LeadOut CopyLeadOut(LeadOut source) => source switch
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
@@ -96,7 +97,7 @@ internal static class OwnedCuttingParameters
_ => throw new NotSupportedException("Unsupported lead-out runtime type.")
};
private static Tab CopyTab(Tab source)
internal static Tab CopyTab(Tab source)
{
Tab copy = source switch
{
@@ -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;
}
@@ -12,7 +12,7 @@ namespace OpenNest.CNC.CuttingPlanning;
/// 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 and exact settings content.
/// 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
@@ -24,12 +24,12 @@ public sealed class PlateCuttingState
private readonly int quadrant;
private readonly ObservableList<CutOff> cutOffList;
private readonly CutOffRecord[] cutOffs;
private readonly Dictionary<Drawing, (Program Program, Program Copy, bool IsCutOff)> drawings;
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, (Program, Program, bool)> drawings, Dictionary<CuttingParameters, string> settings)
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> settings)
{
this.settings = settings;
plateSettings = plate.CuttingParameters;
@@ -61,7 +61,7 @@ public sealed class PlateCuttingState
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, (Program, Program, bool)>(ReferenceEqualityComparer.Instance);
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);
@@ -72,11 +72,13 @@ public sealed class PlateCuttingState
throw new ArgumentException("Plate contains a missing part, drawing or program.");
var drawing = part.BaseDrawing;
if (!drawings.ContainsKey(drawing))
drawings.Add(drawing, (drawing.Program, drawing.Program == null ? null
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff));
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)));
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.")
@@ -130,12 +132,19 @@ public sealed class PlateCuttingState
return $"Part {i + 1} program was edited in place.";
if (!SameSettings(live.CuttingParameters))
return $"Part {i + 1} cutting settings were edited in place.";
var (drawingProgram, drawingCopy, isCutOff) = drawings[part.BaseDrawing];
if (!ReferenceEquals(part.BaseDrawing.Program, drawingProgram)
|| !ProgramContent.Equal(drawingProgram, drawingCopy, token))
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 != isCutOff)
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.";
@@ -188,7 +197,11 @@ public sealed class PlateCuttingState
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);
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);
@@ -12,6 +12,13 @@ namespace OpenNest.CNC.CuttingPlanning;
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>
@@ -23,17 +30,118 @@ 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, CuttingParameters parameters)
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();
@@ -45,12 +153,30 @@ public sealed class PreparedContours
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)
@@ -59,13 +185,13 @@ public sealed class PreparedContours
scribes.Add(entity);
continue;
}
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display)
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)
if (current.Entities.Count != 0 || entity.Layer == SpecialLayers.Tab)
throw new ArgumentException("Circle interrupts an open contour.");
current.Entities.Add(entity);
FinishContour();
@@ -74,7 +200,7 @@ public sealed class PreparedContours
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 (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour();
}
if (current.Entities.Count != 0 || contours.Count == 0)
@@ -90,20 +216,32 @@ public sealed class PreparedContours
var outer = contours[perimeter];
contours.RemoveAt(perimeter);
contours.Add(outer);
return new(contours.ToArray(), scribes, ownedParameters);
foreach (var cut in breakCuts)
cut.UpdateBounds();
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters);
void FinishContour()
{
current.UpdateBounds();
contours.Add(current);
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);
}
@@ -112,6 +250,7 @@ public sealed class PreparedContours
/// 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)
{
@@ -119,6 +258,8 @@ public sealed class PreparedContours
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++)
@@ -149,6 +290,25 @@ public sealed class PreparedContours
}
}
/// <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 };
@@ -156,6 +316,27 @@ public sealed class PreparedContours
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
@@ -170,6 +351,8 @@ public sealed class PreparedContours
{
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));
@@ -183,7 +366,8 @@ public sealed class PreparedContours
/// <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.
/// 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,
@@ -192,6 +376,8 @@ public sealed class PreparedContours
{
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);
@@ -254,12 +440,11 @@ public sealed class PreparedContours
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
{
token.ThrowIfCancellationRequested();
var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
// 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, cornerPoint, inward: false);
AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true);
AddInset(corner.EntityOrdinal, inward: false);
AddInset((corner.EntityOrdinal + 1) % count, inward: true);
}
}
@@ -276,7 +461,7 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested();
return fallbacks;
void AddInset(int entityOrdinal, Vector corner, bool inward)
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
@@ -285,7 +470,9 @@ public sealed class PreparedContours
return;
var direction = (line.EndPoint - line.StartPoint).Normalize();
var offset = direction * (2 * lead);
var point = inward ? corner + offset : corner - offset;
// 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.
@@ -441,17 +628,29 @@ public sealed class PreparedContours
// 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);
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 once.
// 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);
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
@@ -461,7 +660,9 @@ public sealed class PreparedContours
{
token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
// 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)
@@ -488,6 +689,17 @@ public sealed class PreparedContours
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
@@ -510,6 +722,17 @@ public sealed class PreparedContours
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();
@@ -11,10 +11,20 @@ namespace OpenNest.CNC.CuttingPlanning;
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();
var copy = new ReleasedContourState(reportMissingLeadIns);
copy.obstacles.AddRange(obstacles);
return copy;
}
@@ -28,7 +38,18 @@ public sealed class ReleasedContourState
/// being open, never become obstacles.
/// </summary>
public IReadOnlyList<PostVerificationFinding> Check(OwnedExecution execution, Vector? arrival,
int partNumber, bool cutoff, CancellationToken token = default)
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)
@@ -36,24 +57,65 @@ public sealed class ReleasedContourState
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token);
return findings.AsReadOnly();
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)
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
bool proveOpenCuts = false)
{
var contour = new List<PostVerificationGeometry.Curve>();
var unfinished = new List<PostVerificationGeometry.Curve[]>();
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;
foreach (var move in moves)
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),
@@ -66,73 +128,290 @@ public sealed class ReleasedContourState
if (reach.IsClearOf(obstacle.Extent))
continue;
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
$"of part {obstacle.Part}."));
$"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 && contour.Count > 0
&& !PostVerificationGeometry.Closed(contour)
&& move.Curve.Length > PostVerificationGeometry.Epsilon)
findings.Add(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."));
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)
{
if (contour.Count == 0)
// 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 (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
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 previous = unfinished.FindIndex(chain =>
chain[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (previous >= 0)
var resumed = unfinished.FindIndex(chain =>
chain.Curves[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon);
if (resumed >= 0)
{
contour.AddRange(unfinished[previous]);
unfinished.RemoveAt(previous);
current = unfinished[resumed];
unfinished.RemoveAt(resumed);
}
current.Leads.AddRange(leadIns);
leadIns.Clear();
}
contour.Add(move.Curve);
current.Curves.Add(move.Curve);
current.Fixed |= owned;
previous = current;
// A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(contour))
if (PostVerificationGeometry.Closed(current.Curves))
Finish();
}
}
Finish();
if (unfinished.Count > 1)
findings.Add(new(PostVerificationKind.Incomplete, plate, part, null,
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."));
"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 (contour.Count == 0)
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(contour))
obstacles.Add(new(part, contourNumber, contour.ToArray()));
if (!cutoff && PostVerificationGeometry.Closed(current.Curves))
{
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
closed.Add(current);
}
else if (!cutoff)
unfinished.Add(contour.ToArray());
contour.Clear();
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; } =
@@ -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);
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using OpenNest.CNC.CuttingStrategy;
@@ -23,11 +24,51 @@ internal static class StateFingerprint
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
internal static string Of(CuttingParameters settings)
{
var text = new StringBuilder();
Settings(settings);
return text.ToString();
var writer = new Writer();
writer.Settings(settings);
return writer.ToString();
}
void Settings(CuttingParameters p)
/// <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;
@@ -45,6 +86,7 @@ internal static class StateFingerprint
Lead(p.ArcCircleLeadIn);
Out(p.ArcCircleLeadOut);
Number(p.PierceClearance);
Number(p.PierceDiameter);
Flag(p.RoundLeadInAngles);
Number(p.LeadInAngleIncrement);
Number(p.AutoTabMinSize);
@@ -56,7 +98,7 @@ internal static class StateFingerprint
text.Append('}');
}
void Sequence(SequenceParameters s)
private void Sequence(SequenceParameters s)
{
if (!Begin(s, typeof(SequenceParameters)))
return;
@@ -72,7 +114,7 @@ internal static class StateFingerprint
text.Append('}');
}
void Assignment(AssignmentParameters a)
private void Assignment(AssignmentParameters a)
{
if (!Begin(a, typeof(AssignmentParameters)))
return;
@@ -82,7 +124,7 @@ internal static class StateFingerprint
text.Append('}');
}
void Lead(LeadIn lead)
private void Lead(LeadIn lead)
{
if (lead == null)
{
@@ -122,7 +164,7 @@ internal static class StateFingerprint
text.Append('}');
}
void Out(LeadOut lead)
private void Out(LeadOut lead)
{
if (lead == null)
{
@@ -146,7 +188,7 @@ internal static class StateFingerprint
text.Append('}');
}
void TabSettings(Tab tab)
private void TabSettings(Tab tab)
{
if (tab == null)
{
@@ -180,7 +222,7 @@ internal static class StateFingerprint
// 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.
bool Begin(object value, Type exact)
private bool Begin(object value, Type exact)
{
if (value == null)
{
@@ -193,15 +235,15 @@ internal static class StateFingerprint
return true;
}
void Number(double value) =>
private void Number(double value) =>
text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';');
void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
private void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';');
void Flag(bool value) => text.Append(value ? "1;" : "0;");
private void Flag(bool value) => text.Append(value ? "1;" : "0;");
// Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "".
void Text(string value)
private void Text(string value)
{
if (value == null)
text.Append("~;");
@@ -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;
}
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -19,6 +20,7 @@ namespace OpenNest.CNC.CuttingStrategy
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
{
var entities = partProgram.ToGeometry();
var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
@@ -77,10 +79,13 @@ namespace OpenNest.CNC.CuttingStrategy
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribeEntities);
EmitBreakCuts(result, breakCuts, BreakCutContours(profile));
foreach (var entry in cutoutEntries)
{
if (!entry.Shape.IsClosed())
if (IsTabbedCutout(entry.Shape))
EmitTabbedCutout(result, entry.Shape);
else if (!entry.Shape.IsClosed())
EmitRawContour(result, entry.Shape);
else
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
@@ -110,6 +115,7 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
var entities = partProgram.ToGeometry();
var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
@@ -120,14 +126,20 @@ namespace OpenNest.CNC.CuttingStrategy
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribeEntities);
EmitBreakCuts(result, breakCuts, BreakCutContours(profile));
// Find the target shape that contains the clicked entity
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
// Emit cutouts — only the target gets lead-in/out (skip open contours)
// Emit cutouts — only the target gets lead-in/out (skip open contours)
foreach (var cutout in profile.Cutouts)
{
if (!cutout.IsClosed())
// A tabbed cutout's pieces are always entered from its slug, like break cuts.
if (IsTabbedCutout(cutout))
{
EmitTabbedCutout(result, cutout);
}
else if (!cutout.IsClosed())
{
EmitRawContour(result, cutout);
}
@@ -234,43 +246,61 @@ namespace OpenNest.CNC.CuttingStrategy
return null;
}
/// <param name="contourParameters">Per-contour settings by contour ordinal (a drawing pin's
/// own lead-in, lead-out and tab); a null list or entry uses <see cref="Parameters"/>.</param>
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
IReadOnlyList<Shape> tabbedCutouts = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
EmitChosenContours(result, shapes, choices);
EmitBreakCuts(result, breakCuts, shapes);
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
EmitTabbedCutout(result, cutout);
EmitChosenContours(result, shapes, choices, contourParameters);
result.Mode = Mode.Incremental;
return result;
}
// Diagnostic seam for candidate lead validation (PreparedContours
// .EmitCandidateForValidation): emit a single already-validated choice with its
// ORIGINAL contour type — the last shape is External even when emitted alone, so a
// ORIGINAL contour type — the last shape is External even when emitted alone, so a
// perimeter never degrades to a hole because it was isolated. Output must not be
// installed on a Part or accepted as a complete plan.
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
CuttingPlanning.ContourChoice choice)
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
EmitChosenContours(result, shapes, new[] { choice });
EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
result.Mode = Mode.Incremental;
return result;
}
// One contour per choice in order; the perimeter (last shape) is always External.
// Contour emission itself reads only the contour's own geometry and settings, never
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
private void EmitChosenContours(Program result, Shape[] shapes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters)
{
foreach (var choice in choices)
var shared = Parameters;
try
{
var shape = shapes[choice.ContourOrdinal];
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
exactCirclePrograms: true);
foreach (var choice in choices)
{
// A contour's own settings apply to its emission alone; scribes and every
// other contour keep the shared settings.
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
var shape = shapes[choice.ContourOrdinal];
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
exactCirclePrograms: true, scrapPierce: choice.Pierce);
}
}
finally
{
Parameters = shared;
}
}
@@ -390,7 +420,8 @@ namespace OpenNest.CNC.CuttingStrategy
Vector point,
Entity entity,
ContourType? forceType = null,
bool exactCirclePrograms = false
bool exactCirclePrograms = false,
Vector? scrapPierce = null
)
{
var contourType = forceType ?? DetectContourType(shape);
@@ -415,7 +446,9 @@ namespace OpenNest.CNC.CuttingStrategy
);
}
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance());
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius());
// Build hole sub-program relative to (0,0)
var holeCenter = circle.Center;
@@ -454,8 +487,17 @@ namespace OpenNest.CNC.CuttingStrategy
return;
}
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
Parameters.PierceClearance, out var leadInNormal);
double leadInNormal;
// A planner-placed scrap pierce replaces the configured lead-in with a straight one.
if (scrapPierce is { } pierce)
leadIn = StraightLeadIn(point, pierce, out leadInNormal);
else
{
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
Parameters.GetEffectivePierceClearance(), out leadInNormal);
if (contourType == ContourType.Internal)
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius());
}
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
var reindexedShape = shape.ReindexAt(point, entity);
@@ -483,7 +525,7 @@ namespace OpenNest.CNC.CuttingStrategy
else
{
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
Parameters.PierceClearance, out leadOutNormal);
Parameters.GetEffectivePierceClearance(), out leadOutNormal);
}
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
@@ -783,7 +825,7 @@ namespace OpenNest.CNC.CuttingStrategy
{
var normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
// Sum unit normals rather than averaging angles (which fails at 0/2π).
// Sum unit normals rather than averaging angles (which fails at 0/2Ï€).
// Winding makes this point into the scrap even at reflex corners.
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
@@ -840,7 +882,7 @@ namespace OpenNest.CNC.CuttingStrategy
if (entity is Line line)
{
// Perpendicular to line direction: tangent + π/2 = left side.
// Perpendicular to line direction: tangent + π/2 = left side.
// Left side = outward for CW winding; for CCW winding, outward
// is on the right side, so flip.
var tangent = line.EndPoint.AngleFrom(line.StartPoint);
@@ -851,7 +893,7 @@ namespace OpenNest.CNC.CuttingStrategy
else if (entity is Arc arc)
{
// Radial direction from center to point.
// Flip when the arc direction differs from the contour winding —
// Flip when the arc direction differs from the contour winding —
// that indicates a concave feature where radial points inward.
normal = point.AngleFrom(arc.Center);
if (arc.Rotation != winding)
@@ -859,7 +901,7 @@ namespace OpenNest.CNC.CuttingStrategy
}
else if (entity is Circle circle)
{
// Radial outward — always correct regardless of winding
// Radial outward — always correct regardless of winding
normal = point.AngleFrom(circle.Center);
}
else
@@ -887,18 +929,24 @@ namespace OpenNest.CNC.CuttingStrategy
return polygon.RotationDirection();
}
private LeadIn ClampLeadInForCircle(
/// <summary>
/// Shortens a round hole's lead-in so its pierce keeps <paramref name="clearance"/> from
/// the hole wall. A hole no wider than twice the clearance keeps the lead as it is.
/// Shared by program generation and the manual placement preview.
/// </summary>
public static LeadIn ClampLeadInForCircle(
LeadIn leadIn,
Circle circle,
Vector contourPoint,
double normalAngle
double normalAngle,
double clearance
)
{
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
if (leadIn is NoLeadIn || clearance <= 0)
return leadIn;
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
var maxRadius = circle.Radius - Parameters.PierceClearance;
var maxRadius = circle.Radius - clearance;
if (maxRadius <= 0)
return leadIn;
@@ -936,6 +984,85 @@ namespace OpenNest.CNC.CuttingStrategy
return leadIn.Scale(scale);
}
/// <summary>
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
/// returned as given. Otherwise the pierce moves onto the line from
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a
/// round hole), as close to the contour as the zone allows, and the lead becomes a
/// straight line from there, generated with <paramref name="normal"/>. A hole too small
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
/// knows nothing of parts nested inside the hole. Shared by program generation and the
/// manual placement preview.
/// </summary>
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
double pierceRadius)
{
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|| !hole.IsClosed())
return leadIn;
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
var polygon = circle == null
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
: null;
if (Clear(leadIn.GetPiercePoint(point, normal)))
return leadIn;
var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
if (!Inside(pole))
return leadIn;
// Clearance grows monotonically toward the pole of a convex hole, so the bisection
// finds the shortest clear lead; in other holes it still ends on a clear point.
var pierce = pole;
if (Clear(pole))
{
double low = 0, high = 1;
for (var i = 0; i < 40; i++)
{
var middle = (low + high) / 2;
if (Clear(point + (pole - point) * middle))
high = middle;
else
low = middle;
}
pierce = point + (pole - point) * high;
}
var length = pierce.DistanceTo(point);
if (length <= Math.Tolerance.Epsilon)
return leadIn;
var direction = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
return new LineLeadIn
{
Length = length,
ApproachAngle = Angle.ToDegrees(Angle.NormalizeRad(direction - normal + Angle.HalfPI)),
};
bool Inside(Vector p) => circle != null
? p.DistanceTo(circle.Center) < circle.Radius
: polygon.ContainsPoint(p);
bool Clear(Vector p) => double.IsFinite(p.X) && double.IsFinite(p.Y) && Inside(p)
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
}
/// <summary>
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
/// normal to generate it with.
/// </summary>
private static LeadIn StraightLeadIn(Vector point, Vector pierce, out double normal)
{
normal = System.Math.Atan2(pierce.Y - point.Y, pierce.X - point.X);
return new LineLeadIn { Length = pierce.DistanceTo(point), ApproachAngle = 90 };
}
// Bounds the pole search on long, thin holes; the result is checked before use.
private const int PoleSearchCells = 4096;
private LeadIn SelectLeadIn(ContourType contourType)
{
return contourType switch
@@ -998,7 +1125,7 @@ namespace OpenNest.CNC.CuttingStrategy
break;
}
// No intersection — entity is entirely inside circle, remove it
// No intersection — entity is entirely inside circle, remove it
if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon)
{
entities.RemoveAt(entities.Count - 1);
@@ -1029,15 +1156,27 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
var moves = new List<ICode>();
var position = startPoint;
foreach (var entity in shape.Entities)
{
if (entity is Line line)
{
moves.Add(new LinearMove(line.EndPoint) { Layer = layer });
position = line.EndPoint;
}
else if (entity is Arc arc)
{
// A contour entered away from its seam crosses the seam's admitted closure gap
// mid-cut. Starting the arc there would move its start off its own circle, so
// bridge the gap with a straight move and cut the arc exactly as drawn. A lead-in
// must join the contour itself, so an entry on the seam is left as it was.
var arcStart = arc.StartPoint();
var gap = position.DistanceTo(arcStart);
if (moves.Count > 0 && gap > Diagnostics.PostVerificationGeometry.Epsilon
&& CuttingPlanning.NominalContourClosure.IsClosed(position, arcStart))
moves.Add(new LinearMove(arcStart) { Layer = layer });
position = arc.EndPoint();
moves.Add(
new ArcMove(
arc.EndPoint(),
@@ -1054,6 +1193,7 @@ namespace OpenNest.CNC.CuttingStrategy
moves.Add(
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
);
position = startPoint;
}
else
{
@@ -1066,6 +1206,253 @@ namespace OpenNest.CNC.CuttingStrategy
return moves;
}
/// <summary>
/// Removes break-cut entities from <paramref name="entities"/> (in program order) and
/// returns them as travel-ordered open chains, one per uninterrupted run.
/// </summary>
internal static List<Shape> TakeBreakCuts(List<Entity> entities)
{
var cuts = new List<Shape>();
Shape current = null;
foreach (var entity in entities)
{
if (entity.Layer != SpecialLayers.Break)
{
current = null;
continue;
}
if (current == null
|| EntityEndPoint(current.Entities[^1]).DistanceTo(EntityStartPoint(entity)) > Tolerance.Epsilon)
{
current = new Shape();
cuts.Add(current);
}
current.Entities.Add(entity);
}
entities.RemoveAll(e => e.Layer == SpecialLayers.Break);
foreach (var cut in cuts)
cut.UpdateBounds();
return cuts;
}
// A clean program's break cut travels with its scrap on the left. It is entered from the
// scrap, with the internal lead-in (holes are pierced in scrap the same way), at whichever
// end has more scrap before the part's contours: near the outline the scrap narrows to a
// wedge. NoLeadIn pierces on the line. There is no lead-out: the cut ends at a tab, which a
// run-out would cut into. Entering at the far end cuts it the other way round.
private void EmitBreakCuts(Program program, IReadOnlyList<Shape> cuts, IReadOnlyList<Shape> contours)
{
if (cuts == null)
return;
var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn();
foreach (var cut in cuts)
{
var (entry, scrap, fromEnd) = BreakCutEntry(cut, contours);
var path = cut;
if (fromEnd)
{
path = (Shape)cut.Clone();
path.Reverse();
}
var normal = Angle.NormalizeRad(System.Math.Atan2(scrap.Y, scrap.X));
program.Codes.AddRange(leadIn.Generate(entry, normal, fromEnd ? RotationType.CW : RotationType.CCW));
program.Codes.AddRange(ConvertShapeToMoves(path, entry, LayerType.Break));
}
}
/// <summary>
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
/// whether that is the cut's far end.
/// </summary>
public static (Vector Entry, Vector ScrapDirection, bool FromEnd) BreakCutEntry(Shape cut,
IReadOnlyList<Shape> contours)
{
var start = EntityStartPoint(cut.Entities[0]);
var end = EntityEndPoint(cut.Entities[^1]);
var startScrap = LeftOf(Tangent(cut.Entities[0], atEnd: false));
var endScrap = LeftOf(Tangent(cut.Entities[^1], atEnd: true));
return Room(end, endScrap, contours) > Room(start, startScrap, contours)
? (end, endScrap, true)
: (start, startScrap, false);
}
/// <summary>Whether <paramref name="shape"/> has <see cref="SpecialLayers.Tab"/> spans: a tabbed cutout.</summary>
internal static bool IsTabbedCutout(Shape shape) => shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab);
/// <summary>
/// The cut pieces of a closed tabbed cutout in its own travel order, starting after a tab:
/// each maximal run of entities that are not on the tab layer.
/// </summary>
internal static List<Shape> TabbedCutoutPieces(Shape cutout)
{
var entities = cutout.Entities;
var count = entities.Count;
var first = -1;
for (var i = 0; i < count && first < 0; i++)
if (entities[i].Layer != SpecialLayers.Tab && entities[(i + count - 1) % count].Layer == SpecialLayers.Tab)
first = i;
var pieces = new List<Shape>();
if (first < 0)
return pieces;
Shape current = null;
for (var k = 0; k < count; k++)
{
var entity = entities[(first + k) % count];
if (entity.Layer == SpecialLayers.Tab)
{
current = null;
continue;
}
if (current == null)
{
current = new Shape();
pieces.Add(current);
}
current.Entities.Add(entity);
}
foreach (var piece in pieces)
piece.UpdateBounds();
return pieces;
}
// A tabbed cutout is cut as open pieces between its tabs, in the cutout's own direction.
// Each piece is entered from the slug with the internal lead-in (NoLeadIn pierces on the
// contour) and has no lead-out, since it ends at a tab a run-out would cut into. A lead-in
// that would reach back over the tab behind its piece is mirrored about the entry normal,
// so it approaches from the piece's side instead. On an arc, a lead ending in an arc would
// meet the cut tangentially (a contact verification cannot resolve), so it pierces straight
// in from the same distance.
private void EmitTabbedCutout(Program program, Shape cutout)
{
var winding = DetermineWinding(cutout);
var leadIn = Parameters?.InternalLeadIn ?? new NoLeadIn();
foreach (var piece in TabbedCutoutPieces(cutout))
{
var first = piece.Entities[0];
var entry = EntityStartPoint(first);
var normal = ComputeNormal(entry, first, ContourType.Internal, winding);
var tangent = TravelTangent(first, entry);
program.Codes.AddRange(TabbedLeadIn(leadIn, entry, normal, tangent, winding, first is Arc));
program.Codes.AddRange(ConvertShapeToMoves(piece, entry));
}
}
private static List<ICode> TabbedLeadIn(LeadIn leadIn, Vector entry, double normal, Vector tangent,
RotationType winding, bool onArc)
{
var codes = leadIn.Generate(entry, normal, winding);
if (!(onArc && codes.Count > 0 && codes[^1] is ArcMove))
{
if (ReachBehind(codes, entry, tangent) <= Tolerance.Epsilon)
return codes;
var mirrored = codes.Select(code => Mirror(code, entry, tangent)).ToList();
if (ReachBehind(mirrored, entry, tangent) <= Tolerance.Epsilon)
return mirrored;
}
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
var pierce = leadIn.GetPiercePoint(entry, normal);
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
}
// How far the lead motions of `codes` (absolute, after their first rapid) reach behind
// `entry`, against the unit travel `tangent`.
private static double ReachBehind(List<ICode> codes, Vector entry, Vector tangent)
{
var behind = new Vector(-tangent.X, -tangent.Y);
double Along(Vector p) => (p.X - entry.X) * behind.X + (p.Y - entry.Y) * behind.Y;
var reach = double.NegativeInfinity;
Vector? position = null;
foreach (var code in codes)
{
if (code is not Motion motion)
continue;
var end = motion.EndPoint;
if (position is { } start && code is not RapidMove)
{
reach = System.Math.Max(reach, System.Math.Max(Along(start), Along(end)));
if (code is ArcMove arc)
{
var center = arc.CenterPoint;
var radius = center.DistanceTo(end);
var extreme = new Vector(center.X + behind.X * radius, center.Y + behind.Y * radius);
if (OnArc(center, start, end, extreme, arc.Rotation))
reach = System.Math.Max(reach, Along(extreme));
}
}
position = end;
}
return reach;
}
private static bool OnArc(Vector center, Vector start, Vector end, Vector point, RotationType rotation)
{
var a0 = System.Math.Atan2(start.Y - center.Y, start.X - center.X);
var a1 = System.Math.Atan2(end.Y - center.Y, end.X - center.X);
var ap = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
var sign = rotation == RotationType.CCW ? 1 : -1;
var sweep = Angle.NormalizeRad(sign * (a1 - a0));
if (sweep <= Tolerance.Epsilon)
sweep = Angle.TwoPI;
return Angle.NormalizeRad(sign * (ap - a0)) <= sweep;
}
// `code` reflected across the line through `entry` along its normal (negating the
// tangent component), with arc directions swapped.
private static ICode Mirror(ICode code, Vector entry, Vector tangent)
{
Vector Reflect(Vector p)
{
var along = (p.X - entry.X) * tangent.X + (p.Y - entry.Y) * tangent.Y;
return new Vector(p.X - 2 * along * tangent.X, p.Y - 2 * along * tangent.Y);
}
return code switch
{
RapidMove rapid => new RapidMove(Reflect(rapid.EndPoint)),
LinearMove line => new LinearMove(Reflect(line.EndPoint)) { Layer = line.Layer },
ArcMove arc => new ArcMove(Reflect(arc.EndPoint), Reflect(arc.CenterPoint),
arc.Rotation == RotationType.CW ? RotationType.CCW : RotationType.CW)
{ Layer = arc.Layer },
_ => code,
};
}
private static List<Shape> BreakCutContours(ShapeProfile profile) =>
new List<Shape>(profile.Cutouts) { profile.Perimeter };
// Distance from a break cut end along its scrap direction to the nearest contour.
private static double Room(Vector point, Vector direction, IReadOnlyList<Shape> contours)
{
const double reach = 1e6;
var ray = new Line(point, point + direction * reach);
var nearest = reach;
foreach (var contour in contours ?? Array.Empty<Shape>())
foreach (var entity in contour.Entities)
if (entity.Intersects(ray, out var hits))
foreach (var hit in hits)
nearest = System.Math.Min(nearest, hit.DistanceTo(point));
return nearest;
}
private static Vector LeftOf(Vector tangent)
{
var length = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
return length <= 0 ? new Vector(0, 1) : new Vector(-tangent.Y / length, tangent.X / length);
}
private static Vector Tangent(Entity entity, bool atEnd)
{
if (entity is Arc arc)
{
var angle = atEnd ? arc.EndAngle : arc.StartAngle;
return arc.IsReversed
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
}
var line = (Line)entity;
return line.EndPoint - line.StartPoint;
}
private static Vector GetShapeStartPoint(Shape shape)
{
var first = shape.Entities[0];
@@ -24,6 +24,19 @@ 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;
+15 -1
View File
@@ -1,4 +1,4 @@
namespace OpenNest.CNC
namespace OpenNest.CNC
{
public enum LayerType
{
@@ -7,5 +7,19 @@
Cut,
Leadin,
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,
}
}
+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);
}
}
}
+20 -8
View File
@@ -31,16 +31,16 @@ namespace OpenNest.CNC
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
{
// Draw the rapid from the previous tool position to the program's first
// pierce point. The walk then starts at the program origin (basePos), not
// the pierce: the skipped first rapid still advances pos, so starting at
// the pierce would apply a nonzero Incremental first delta twice (as in
// lead-in programs) and shift every later rapid by it.
// Draw the approach to the first rapid destination, or to the program
// origin when it starts cutting without a rapid. Only an initial rapid
// (or call arrival) is already represented by this approach; a later
// first rapid must remain visible. Walk starts at the program origin,
// so an incremental initial rapid is not applied twice.
var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce));
var pos = basePos;
Walk(pgm, basePos, ref pos, skipFirst: true, results);
Walk(pgm, basePos, ref pos, skipFirst: StartsWithRapidOrCall(pgm), results);
// The last rapid ends at a pierce, not necessarily the final tool position.
return pos;
}
@@ -53,11 +53,23 @@ namespace OpenNest.CNC
return FirstPiercePoint(call.Program, basePos + call.Offset);
if (pgm[i] is Motion motion)
return motion.EndPoint + basePos;
return motion is RapidMove ? motion.EndPoint + basePos : basePos;
}
return basePos;
}
private static bool StartsWithRapidOrCall(Program pgm)
{
for (var i = 0; i < pgm.Length; i++)
{
if (pgm[i] is SubProgramCall { Program: not null })
return true;
if (pgm[i] is Motion motion)
return motion is RapidMove;
}
return false;
}
private static void Walk(
Program pgm,
Vector basePos,
@@ -83,7 +95,7 @@ namespace OpenNest.CNC
results.Add(new Segment(pos, firstPierce));
var subPos = holeBase;
Walk(program, holeBase, ref subPos, skipFirst: true, results);
Walk(program, holeBase, ref subPos, skipFirst: StartsWithRapidOrCall(program), results);
pos = subPos;
}
else if (code is Motion motion)
+46
View File
@@ -0,0 +1,46 @@
using System;
namespace OpenNest
{
/// <summary>
/// The sheet edge a <see cref="ClampRail"/> clamp sits on, named relative to the plate
/// origin so one rail resolves onto every sheet size and quadrant.
/// </summary>
public enum ClampEdge
{
/// <summary>The edge along X through the origin (operator side).</summary>
OriginSide = 0,
/// <summary>The edge along X across the sheet's width from the origin.</summary>
FarSide = 1,
/// <summary>The edge along Y through the origin.</summary>
OriginEnd = 2,
/// <summary>The edge along Y across the sheet's length from the origin.</summary>
FarEnd = 3,
}
public static class ClampEdges
{
/// <summary>True for the two edges along X, whose clamp positions are X distances.</summary>
public static bool RunsAlongX(this ClampEdge edge) =>
edge is ClampEdge.OriginSide or ClampEdge.FarSide;
public static bool IsDefined(ClampEdge edge) =>
edge is ClampEdge.OriginSide or ClampEdge.FarSide or ClampEdge.OriginEnd or ClampEdge.FarEnd;
}
/// <summary>
/// One clamp of a <see cref="ClampRail"/>: its edge and its center measured from the plate
/// origin along that edge (X for the sides, Y for the ends). Orders by edge, then center.
/// </summary>
public readonly record struct RailClamp(ClampEdge Edge, double Center) : IComparable<RailClamp>
{
public int CompareTo(RailClamp other)
{
var edge = Edge.CompareTo(other.Edge);
return edge != 0 ? edge : Center.CompareTo(other.Center);
}
}
}
+248
View File
@@ -0,0 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// The machine's fixed clamp rail: identical rectangular clamps, each on one sheet edge
/// (<see cref="ClampEdge"/>) with its center measured from the plate origin along that
/// edge. Plates always start at the origin, so the same rail resolves onto every sheet
/// size without re-scaling. A clamp whose footprint lies wholly beyond a sheet is skipped;
/// one that still reaches onto it is kept whole: the zone is an avoidance area, so its
/// full footprint applies even where it hangs past the sheet.
/// </summary>
public sealed class ClampRail : IEquatable<ClampRail>
{
/// <summary>
/// Planner default minutes to remove one clamp and put it back after cutting (D9),
/// used when a caller does not supply its own value.
/// </summary>
public const double DefaultRemovalMinutes = 5.0;
/// <summary>A rail whose clamps all sit on the origin-side edge.</summary>
public ClampRail(
double width,
double depth,
IEnumerable<double> centers,
double removalMinutes = DefaultRemovalMinutes
)
: this(
width,
depth,
(centers ?? throw new ArgumentNullException(nameof(centers)))
.Select(c => new RailClamp(ClampEdge.OriginSide, c)),
removalMinutes
)
{ }
public ClampRail(
double width,
double depth,
IEnumerable<RailClamp> clamps,
double removalMinutes = DefaultRemovalMinutes
)
{
ArgumentNullException.ThrowIfNull(clamps);
if (!double.IsFinite(width) || width <= 0)
throw new ArgumentOutOfRangeException(
nameof(width),
"Clamp width must be finite and positive."
);
if (!double.IsFinite(depth) || depth <= 0)
throw new ArgumentOutOfRangeException(
nameof(depth),
"Clamp depth must be finite and positive."
);
if (!double.IsFinite(removalMinutes) || removalMinutes < 0)
throw new ArgumentOutOfRangeException(
nameof(removalMinutes),
"Clamp removal time must be finite and zero or more."
);
var sorted = new SortedSet<RailClamp>();
foreach (var clamp in clamps)
{
if (!double.IsFinite(clamp.Center) || clamp.Center < 0)
throw new ArgumentOutOfRangeException(
"centers",
"Clamp centers must be finite and measured from the origin (zero or more)."
);
if (!ClampEdges.IsDefined(clamp.Edge))
throw new ArgumentOutOfRangeException(nameof(clamps), "Unknown clamp edge.");
sorted.Add(clamp);
}
Width = width;
Depth = depth;
RemovalMinutes = removalMinutes;
Clamps = sorted.ToArray();
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
}
/// <summary>Clamp extent along its edge.</summary>
public double Width { get; }
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
public double Depth { get; }
/// <summary>
/// Minutes to remove one clamp and put it back after cutting (D9). Added to a sheet's
/// cut-time estimate once per clamp <see cref="Plate.RemovedClamps"/> lists there.
/// </summary>
public double RemovalMinutes { get; }
/// <summary>
/// Distinct clamps ordered by edge, then center. A clamp's position in this list
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
/// it had before clamps could sit on other edges.
/// </summary>
public IReadOnlyList<RailClamp> Clamps { get; }
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
public IReadOnlyList<double> Centers { get; }
/// <summary>A copy with the same size and removal time and these clamps.</summary>
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
new(Width, Depth, clamps, RemovalMinutes);
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
public static string IdOf(int index) => $"clamp-{index + 1}";
/// <summary>
/// The footprint <paramref name="clamp"/> would have on a sheet, in plate coordinates
/// (lower-left corner origin), or null when it lies wholly beyond the sheet. The clamp
/// need not belong to this rail; it takes this rail's width and depth.
/// </summary>
public Box Footprint(RailClamp clamp, Size size, int quadrant) =>
Footprint(clamp, Width, Depth, size, quadrant);
/// <summary>
/// The footprint of a <paramref name="width"/> by <paramref name="depth"/> clamp at
/// <paramref name="clamp"/>; see <see cref="Footprint(RailClamp, Size, int)"/>.
/// </summary>
public static Box Footprint(RailClamp clamp, double width, double depth, Size size, int quadrant)
{
var length = size.Length;
var sheetWidth = size.Width;
if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth))
return null;
var extendsLeft = quadrant is 2 or 3;
var extendsDown = quadrant is 3 or 4;
var half = width / 2;
// Distances from the origin along the edge.
var near = clamp.Center - half;
var far = clamp.Center + half;
if (clamp.Edge.RunsAlongX())
{
if (near >= length)
return null; // wholly beyond this sheet
// The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y.
var atTop = (clamp.Edge == ClampEdge.FarSide) != extendsDown;
// Plate coordinates are measured from the lower-left corner; a sheet extending
// to -X from the origin has its origin edge at x = length.
var x = extendsLeft ? length - far : near;
return new Box(x, atTop ? sheetWidth - depth : 0, width, depth);
}
if (near >= sheetWidth)
return null;
var atRight = (clamp.Edge == ClampEdge.FarEnd) != extendsLeft;
var y = extendsDown ? sheetWidth - far : near;
return new Box(atRight ? length - depth : 0, y, depth, width);
}
/// <summary>
/// The zones this rail places on a sheet, in plate coordinates (the frame of
/// <see cref="Plate.ClampZones"/>: origin at the sheet's lower-left corner). The
/// quadrant locates the plate origin, and with it the origin-side edge, on the sheet.
/// Zones are whole clamp footprints, never clipped, so one reaching past a sheet end
/// (or deeper than a narrow sheet) extends beyond the sheet. Ids are <c>clamp-N</c> by
/// rail position, so one physical clamp keeps its id on every sheet even when others
/// are skipped.
/// </summary>
public IReadOnlyList<ClampZone> ResolveFor(Size size, int quadrant)
{
var zones = new List<ClampZone>(Clamps.Count);
for (var i = 0; i < Clamps.Count; i++)
{
var box = Footprint(Clamps[i], size, quadrant);
if (box != null)
zones.Add(new ClampZone(IdOf(i), box));
}
return zones;
}
/// <summary>True when the plate's zones are exactly (ids and bits) this rail's for its sheet.</summary>
public bool Matches(Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant));
}
/// <summary>
/// True when the plate's zones are exactly this rail's resolution for its sheet minus
/// the clamps named in <paramref name="removedClamps"/> (D8 last-resort removal, C13).
/// A null or empty set is the same check as <see cref="Matches(Plate)"/>.
/// </summary>
public bool Matches(Plate plate, IReadOnlyCollection<string> removedClamps)
{
ArgumentNullException.ThrowIfNull(plate);
if (removedClamps == null || removedClamps.Count == 0)
return Matches(plate);
var expected = ResolveFor(plate.Size, plate.Quadrant)
.Where(z => !removedClamps.Contains(z.Id))
.ToList();
return SameZones(plate.ClampZones, expected);
}
internal static bool SameZones(IList<ClampZone> current, IReadOnlyList<ClampZone> expected)
{
if (current.Count != expected.Count)
return false;
for (var i = 0; i < current.Count; i++)
{
var a = current[i];
var b = expected[i];
if (
!string.Equals(a.Id, b.Id, StringComparison.Ordinal)
|| a.Bounds.X != b.Bounds.X
|| a.Bounds.Y != b.Bounds.Y
|| a.Bounds.Length != b.Bounds.Length
|| a.Bounds.Width != b.Bounds.Width
)
return false;
}
return true;
}
public bool Equals(ClampRail other) =>
other is not null
&& Width.Equals(other.Width)
&& Depth.Equals(other.Depth)
&& RemovalMinutes.Equals(other.RemovalMinutes)
&& Clamps.SequenceEqual(other.Clamps);
public override bool Equals(object obj) => Equals(obj as ClampRail);
public override int GetHashCode()
{
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count);
foreach (var clamp in Clamps)
hash = HashCode.Combine(hash, clamp);
return hash;
}
public override string ToString() =>
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]";
private static string Describe(RailClamp clamp) =>
clamp.Edge == ClampEdge.OriginSide
? clamp.Center.ToString("R")
: $"{clamp.Edge} {clamp.Center:R}";
}
}
+52
View File
@@ -0,0 +1,52 @@
using System;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
/// origin at the plate corner, X along the edge, Y depth from it). Machine edge clamps
/// are the first users; a future "don't cut here" tag is another entry of this shape.
/// Zones are exclusion and display only - never material, quantity, utilization,
/// cut sequence or CNC output. Rectangles only: axis-aligned by construction.
/// </summary>
public sealed class ClampZone
{
public ClampZone(string id, Box bounds)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentNullException.ThrowIfNull(bounds);
if (
!double.IsFinite(bounds.X)
|| !double.IsFinite(bounds.Y)
|| !double.IsFinite(bounds.Length)
|| !double.IsFinite(bounds.Width)
)
throw new ArgumentException(
$"Clamp zone '{id}' bounds must have finite coordinates and size.",
nameof(bounds)
);
if (bounds.Length <= 0 || bounds.Width <= 0)
throw new ArgumentException(
$"Clamp zone '{id}' must have a positive length (X) and width (Y).",
nameof(bounds)
);
Id = id;
// Detached copy: the zone owns its rectangle, callers may keep mutating theirs.
Bounds = new Box(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
/// <summary>Stable identifier used by persistence and diagnostics.</summary>
public string Id { get; }
/// <summary>
/// The rectangle in plate coordinates: Length is the extent along X, Width the
/// extent along Y (depth from the y = 0 operator-side edge for machine clamps).
/// </summary>
public Box Bounds { get; }
public override string ToString() =>
$"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}";
}
}
+54
View File
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
/// (<see cref="ClampRail.ResolveFor"/>), into the signed placement frame of its parts,
/// cut-offs and motions. The translation matches the sheet itself and the engine stock:
/// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are
/// kept, never clipped. Every plate uses the sheet frame, including plates the engine builds
/// from a <c>NestPlateStock</c>, so this is the only translation any consumer applies.
/// </summary>
public static class ClampZoneFrame
{
/// <summary>
/// Detached placement-frame copies in the plate's zone order; empty when there are none.
/// Throws <see cref="ArgumentException"/> for a missing or malformed zone: zone bounds stay
/// publicly mutable, so construction-time validation is not proof of current geometry.
/// </summary>
public static IReadOnlyList<ClampZone> ToPlacement(Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
var zones = plate.ClampZones;
if (zones == null || zones.Count == 0)
return Array.Empty<ClampZone>();
if (plate.Quadrant is < 1 or > 4)
throw new ArgumentException("Quadrant must be 1 through 4.", nameof(plate));
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
if (!double.IsFinite(left) || !double.IsFinite(bottom))
throw new ArgumentException("Sheet size must be finite.", nameof(plate));
var mapped = new ClampZone[zones.Count];
for (var i = 0; i < mapped.Length; i++)
{
var zone = zones[i];
if (zone?.Bounds == null)
throw new ArgumentException($"Clamp zone {i + 1} is missing.", nameof(plate));
var box = zone.Bounds;
var x = box.X + left;
var y = box.Y + bottom;
if (!(box.Length > 0) || !(box.Width > 0) || !double.IsFinite(box.Length)
|| !double.IsFinite(box.Width) || !double.IsFinite(box.X) || !double.IsFinite(box.Y)
|| !double.IsFinite(x) || !double.IsFinite(y)
|| !double.IsFinite(x + box.Length) || !double.IsFinite(y + box.Width))
throw new ArgumentException(
$"Clamp zone '{zone.Id}' must have finite bounds and a positive size.", nameof(plate));
mapped[i] = new ClampZone(zone.Id, new Box(x, y, box.Length, box.Width));
}
return mapped;
}
}
+32 -2
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -9,7 +9,24 @@ namespace OpenNest.Converters
{
public static Program ToProgram(IList<Entity> geometry)
{
var shapes = ShapeBuilder.GetShapes(geometry);
// Break cuts keep their order and direction (their scrap side): they are emitted as
// given, one program run per continuous chain, before the contours.
var breakCuts = new List<Shape>();
var contours = new List<Entity>();
foreach (var entity in geometry)
{
if (entity.Layer != SpecialLayers.Break)
{
contours.Add(entity);
continue;
}
var last = breakCuts.Count == 0 ? null : breakCuts[^1].Entities[^1];
if (last == null || EndOf(last).DistanceTo(StartOf(entity)) > Tolerance.Epsilon)
breakCuts.Add(new Shape());
breakCuts[^1].Entities.Add(entity);
}
var shapes = ShapeBuilder.GetShapes(contours);
if (shapes.Count == 0)
return null;
@@ -34,6 +51,9 @@ namespace OpenNest.Converters
var pgm = new Program();
foreach (var cut in breakCuts)
pgm.Merge(ToProgram(cut));
foreach (var shape in shapes)
{
var subpgm = ToProgram(shape);
@@ -46,6 +66,10 @@ namespace OpenNest.Converters
return pgm;
}
private static Vector StartOf(Entity entity) => entity is Arc arc ? arc.StartPoint() : ((Line)entity).StartPoint;
private static Vector EndOf(Entity entity) => entity is Arc arc ? arc.EndPoint() : ((Line)entity).EndPoint;
public static Program ToProgram(Shape shape)
{
var pgm = new Program();
@@ -153,6 +177,12 @@ namespace OpenNest.Converters
)
return LayerType.Scribe;
if (string.Equals(name, SpecialLayers.Break.Name, System.StringComparison.OrdinalIgnoreCase))
return LayerType.Break;
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
return LayerType.Tab;
return LayerType.Cut;
}
}
+7 -1
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
@@ -207,6 +207,12 @@ namespace OpenNest.Converters
case LayerType.Scribe:
return SpecialLayers.Scribe;
case LayerType.Break:
return SpecialLayers.Break;
case LayerType.Tab:
return SpecialLayers.Tab;
default:
return new Layer(layer.ToString());
}
@@ -0,0 +1,170 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Converters;
/// <summary>
/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks
/// only in each placed part's saved cutting program; the drawing itself had none, so removing
/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too.
/// </summary>
public static class DrawingMarkRecovery
{
private const double MatchTolerance = 0.001;
/// <summary>
/// For each drawing without etch marks whose saved placed programs all carry the same marks,
/// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed
/// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that
/// already have marks, are left alone. Returns the number of drawings that were given marks.
/// </summary>
public static int Apply(Nest nest)
{
if (nest == null)
return 0;
var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList();
var recovered = 0;
foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance))
{
var drawing = (Drawing)group.Key;
if (Apply(drawing, group.ToList()))
recovered++;
}
return recovered;
}
/// <summary>Recovers marks for one drawing from its placed parts; true when marks were added.</summary>
public static bool Apply(Drawing drawing, IReadOnlyList<Part> placedParts)
{
if (drawing?.Program == null || drawing.IsCutOff || placedParts == null)
return false;
// An incremental call's end position is not tracked by Program.EndPoint, so only
// flat drawings (which is what conversion produces) can be extended safely.
if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall))
return false;
var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns)
.ToList();
if (saved.Count == 0)
return false;
var marks = MarksInDrawingFrame(saved[0]);
if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part))))
return false;
AppendMarks(drawing.Program, marks);
drawing.UpdateArea();
drawing.RecomputeCanonicalAngle();
foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing)
&& !part.HasManualLeadIns))
part.Update();
return true;
}
private static bool HasMarks(Program program) =>
program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }
|| code is SubProgramCall { Program: not null } call && HasMarks(call.Program));
/// <summary>
/// The part's marks as they would sit in its drawing: <see cref="Part.RemoveLeadIns"/>
/// rebuilds the part as the drawing turned by <see cref="Part.Rotation"/>, so undo that turn.
/// </summary>
private static List<Entity> MarksInDrawingFrame(Part part)
{
var marks = ConvertProgram.ToGeometry(part.Program)
.Where(entity => entity.Layer == SpecialLayers.Scribe)
.ToList();
if (!Tolerance.IsEqualTo(part.Rotation, 0))
foreach (var mark in marks)
mark.Rotate(-part.Rotation);
return marks;
}
private static bool SameMarks(List<Entity> expected, List<Entity> actual)
{
if (expected.Count != actual.Count)
return false;
var unmatched = actual.ToList();
foreach (var mark in expected)
{
var index = unmatched.FindIndex(other => SameMark(mark, other));
if (index < 0)
return false;
unmatched.RemoveAt(index);
}
return true;
}
// Direction does not matter for an etch: both marks must lie on each other.
private static bool SameMark(Entity a, Entity b) =>
a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a);
private static bool LiesOn(List<Vector> points, Entity entity) =>
points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
private static List<Vector> Samples(Entity entity) => entity switch
{
Line line => new List<Vector>
{
line.StartPoint,
line.EndPoint,
new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
},
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
Circle circle => new List<Vector>
{
new(circle.Center.X + circle.Radius, circle.Center.Y),
new(circle.Center.X - circle.Radius, circle.Center.Y),
new(circle.Center.X, circle.Center.Y + circle.Radius),
new(circle.Center.X, circle.Center.Y - circle.Radius),
},
_ => new List<Vector>(),
};
/// <summary>
/// Appends the marks after the drawing's existing motion, so the existing codes and the
/// drawing's first-rapid origin stay exactly as they were.
/// </summary>
private static void AppendMarks(Program program, List<Entity> marks)
{
var shape = new Shape();
shape.Entities.AddRange(marks);
var markProgram = ConvertGeometry.ToProgram(shape);
// Start from the drawing's end point, with an explicit rapid onto the first mark.
var start = EndOf(program);
var first = markProgram.Codes.OfType<Motion>().First();
var positioned = new Program(Mode.Absolute);
positioned.Codes.Add(new RapidMove(start));
if (first is not RapidMove)
positioned.Codes.Add(new RapidMove(StartOf(marks[0])));
positioned.Codes.AddRange(markProgram.Codes);
if (program.Mode == Mode.Incremental)
positioned.Mode = Mode.Incremental;
// Drop the move onto the drawing's end point; it is where the drawing already stops.
program.Codes.AddRange(positioned.Codes.Skip(1));
}
// Where a flat program leaves the head (callers exclude sub-program calls).
private static Vector EndOf(Program program)
{
var position = Vector.Zero;
foreach (var motion in program.Codes.OfType<Motion>())
position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint;
return position;
}
private static Vector StartOf(Entity entity) => entity switch
{
Line line => line.StartPoint,
Arc arc => arc.StartPoint(),
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
_ => Vector.Zero,
};
}
+88
View File
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Converters
{
/// <summary>What a drawing does with a source entity.</summary>
public enum EntityRole
{
/// <summary>Cut through the material.</summary>
Cut,
/// <summary>Marked on the surface (a Scribe pass): never material.</summary>
Etch,
/// <summary>
/// Shown with the part for reference only. It is kept out of the program, so it is never
/// cut, posted, nested against or verified.
/// </summary>
Display,
}
/// <summary>
/// Applies per-entity role overrides. An entity without an override keeps the role its layer
/// implies: mark layers (ETCH, SCRIBE, ENGRAVE) etch, everything else cuts.
/// </summary>
public static class EntityRoles
{
public static EntityRole Default(Entity entity) =>
ScribeLayerRepair.IsMarkLayer(entity.Layer) ? EntityRole.Etch : EntityRole.Cut;
public static EntityRole Of(Entity entity, IReadOnlyDictionary<Guid, EntityRole> overrides) =>
overrides != null && overrides.TryGetValue(entity.Id, out var role) ? role : Default(entity);
/// <summary>
/// The entities a program is built from: display-only entities are left out and entities
/// whose override differs from their layer's role are replaced by copies, with the same
/// ID, on a layer the converters read as that role. Inputs are never mutated.
/// </summary>
public static List<Entity> ForProgram(IEnumerable<Entity> entities, IReadOnlyDictionary<Guid, EntityRole> overrides)
{
var result = new List<Entity>();
foreach (var entity in entities)
{
if (overrides == null || !overrides.TryGetValue(entity.Id, out var role) || role == Default(entity))
{
result.Add(entity);
continue;
}
if (role == EntityRole.Display)
continue;
var copy = entity.Clone();
copy.Id = entity.Id;
copy.Layer = new Layer(role == EntityRole.Etch ? SpecialLayers.Scribe.Name : SpecialLayers.Cut.Name)
{
Color = entity.Layer?.Color ?? SpecialLayers.Cut.Color,
IsVisible = true,
};
result.Add(copy);
}
return result;
}
/// <summary>
/// A drawing's unsuppressed display-only entities as copies in its program frame (source
/// coordinates shifted by -<c>Source.Offset</c>, before any program rotation).
/// </summary>
public static List<Entity> DisplayGeometry(Drawing drawing)
{
var result = new List<Entity>();
if (drawing?.SourceEntities == null || drawing.EntityRoles == null || drawing.EntityRoles.Count == 0)
return result;
var offset = drawing.Source?.Offset ?? Vector.Zero;
foreach (var entity in drawing.SourceEntities)
{
if (drawing.SuppressedEntityIds.Contains(entity.Id) || Of(entity, drawing.EntityRoles) != EntityRole.Display)
continue;
var copy = entity.Clone();
copy.Id = entity.Id;
copy.Offset(-offset.X, -offset.Y);
result.Add(copy);
}
return result;
}
}
}
+156 -4
View File
@@ -39,6 +39,72 @@ namespace OpenNest
Drawing.Program = program;
}
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
internal (double Length, int SegmentCount) MeasureSegments(
Plate plate,
CutOffSettings settings,
Dictionary<Part, Entity> cache = null
)
{
var segments = ComputeSegments(plate, settings, cache);
return (segments.Sum(s => s.End - s.Start), segments.Count);
}
/// <summary>
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
/// one continuous straight line on the definition's axis across the whole sheet, so it
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
/// </summary>
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
{
if (part?.Program == null || definition == null || sheet == null
|| part.Program.Mode != Mode.Absolute)
return false;
var vertical = definition.Axis == CutOffAxis.Vertical;
var line = vertical ? definition.Position.X : definition.Position.Y;
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
var acrossMax = vertical ? sheet.Right : sheet.Top;
var alongMin = vertical ? sheet.Bottom : sheet.Left;
var alongMax = vertical ? sheet.Top : sheet.Right;
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
return false;
Vector? position = null;
double? low = null, high = null;
var cutting = false;
foreach (var code in part.Program.Codes)
{
switch (code)
{
case RapidMove rapid:
if (cutting)
return false; // A second pierce leaves an uncut gap.
position = rapid.EndPoint + part.Location;
break;
case LinearMove move when position != null:
var end = move.EndPoint + part.Location;
if (!OnLine(position.Value) || !OnLine(end))
return false;
foreach (var value in new[] { Along(position.Value), Along(end) })
{
low = low == null ? value : System.Math.Min(low.Value, value);
high = high == null ? value : System.Math.Max(high.Value, value);
}
position = end;
cutting = true;
break;
case Motion:
return false;
}
}
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
bool OnLine(Vector point) =>
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
double Along(Vector point) => vertical ? point.Y : point.X;
}
private string GetName()
{
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
@@ -78,8 +144,21 @@ namespace OpenNest
if (part.BaseDrawing.IsCutOff)
continue;
Entity perimeter = null;
cache?.TryGetValue(part, out perimeter);
Entity perimeter;
if (cache != null)
{
cache.TryGetValue(part, out perimeter);
}
else
{
// No pre-built cache: this is a direct/uncached caller (e.g. an
// external API consumer calling Regenerate without going through
// Plate.RegenerateCutOffs or the UI handlers, which always pass a
// cache). Build the same converted-material envelope on the fly so
// this path gets the same safety as the cached one, instead of
// silently trusting the part's bare BoundingBox.
perimeter = Plate.BuildPartPerimeter(part);
}
var partExclusions = GetPartExclusions(
part,
perimeter,
@@ -134,8 +213,22 @@ namespace OpenNest
double clearance
)
{
var bb = part.BoundingBox;
var cached = part.BoundingBox;
var roundoff = GetBoundsRoundoff(part);
// Keep the farther of the cached and converted perimeter extents. The
// perimeter's fine intersection/offset can miss a near-tangent sliver;
// only a line in that narrow outer band needs the conservative box.
var bb = cached;
if (perimeter != null)
{
var converted = perimeter.BoundingBox;
var left = System.Math.Min(cached.Left, converted.Left);
var right = System.Math.Max(cached.Right, converted.Right);
var bottom = System.Math.Min(cached.Bottom, converted.Bottom);
var top = System.Math.Max(cached.Top, converted.Top);
bb = new Box(left, bottom, right - left, top - bottom);
}
var (cachedMin, cachedMax) = AxisBounds(cached, clearance);
var (partMin, partMax) = AxisBounds(bb, clearance);
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
// Match the planner's representational allowance in BOTH pruning and
@@ -150,6 +243,8 @@ namespace OpenNest
if (perimeter != null)
{
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
return new List<(double Start, double End)> { (partStart, partEnd) };
var perimeterExclusions = IntersectPerimeter(
perimeter,
cutPosition,
@@ -180,7 +275,16 @@ namespace OpenNest
magnitude = System.Math.Max(magnitude,
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
var step = System.Math.BitIncrement(magnitude) - magnitude;
return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0;
// Imported, nearly tangent arcs can amplify endpoint rounding when
// ConvertProgram fits their centers to a chord. Keep that allowance
// tiny and apply it to both pruning and fallback exclusions above;
// larger refits still require an explicit geometry repair. The allowance is
// absolute: rotated small arcs on ordinary sheets refit by ~5e-11, which a
// magnitude-scaled step count would reject.
var arcFitRoundoff = Math.Tolerance.Epsilon / 100000;
return double.IsFinite(step)
? System.Math.Min(System.Math.Max(8 * step, arcFitRoundoff), Math.Tolerance.Epsilon / 4)
: 0;
}
private List<(double Start, double End)> IntersectPerimeter(
@@ -192,6 +296,9 @@ namespace OpenNest
)
{
var offset = OffsetOutward(perimeter, clearance);
if (offset != null && offset.All(target => target is Polygon))
return SlicePolygons(offset.Cast<Polygon>(), cutPosition);
var usedOffset = offset != null;
var targets = offset ?? new List<Entity> { perimeter };
var cutLine = new Line(
@@ -234,6 +341,51 @@ namespace OpenNest
return result;
}
/// <summary>
/// Exact cross-section of the clearance outline on the cut line. A vertex on the line
/// counts as below it (half-open edges), so a line through a vertex counts one
/// crossing when the outline passes through and zero or two when it only touches. Edges
/// lying on the line are excluded whole. Unlike <see cref="Entity.Intersects"/>, which
/// reports a shared vertex once per incident edge, this never needs the bounding-box
/// fallback, so a lattice line through a vertex is trimmed like any other line.
/// </summary>
private List<(double Start, double End)> SlicePolygons(IEnumerable<Polygon> polygons, double cutPosition)
{
var crossings = new List<double>();
var result = new List<(double Start, double End)>();
foreach (var polygon in polygons)
{
var vertices = polygon.Vertices;
if (vertices.Count < 3)
return null;
var closed = vertices[0].X == vertices[^1].X && vertices[0].Y == vertices[^1].Y;
var edges = closed ? vertices.Count - 1 : vertices.Count;
for (var i = 0; i < edges; i++)
{
var from = vertices[i];
var to = vertices[(i + 1) % vertices.Count];
var (acrossFrom, alongFrom) = Split(from);
var (acrossTo, alongTo) = Split(to);
if (acrossFrom == cutPosition && acrossTo == cutPosition)
result.Add((System.Math.Min(alongFrom, alongTo), System.Math.Max(alongFrom, alongTo)));
else if (acrossFrom > cutPosition != acrossTo > cutPosition)
crossings.Add(acrossFrom == cutPosition ? alongFrom
: acrossTo == cutPosition ? alongTo
: alongFrom + (cutPosition - acrossFrom) * (alongTo - alongFrom) / (acrossTo - acrossFrom));
}
}
if (crossings.Count % 2 != 0)
return null;
crossings.Sort();
for (var i = 0; i < crossings.Count; i += 2)
result.Add((crossings[i], crossings[i + 1]));
return result;
(double Across, double Along) Split(Vector point) =>
Axis == CutOffAxis.Vertical ? (point.X, point.Y) : (point.Y, point.X);
}
/// <summary>
/// Grows the perimeter by the clearance as one Clipper region offset, so slots
/// narrower than twice the clearance close up instead of leaving a gap the cut
+13 -1
View File
@@ -1,6 +1,6 @@
using System;
namespace OpenNest.Api;
namespace OpenNest;
public class CutParameters
{
@@ -11,6 +11,18 @@ public class CutParameters
public string PostProcessor { get; set; }
public Units Units { get; set; }
/// <summary>
/// Acceleration limits; null estimates every move at full speed. Applied by
/// <see cref="CNC.CutSimulation"/> and the plate/nest overloads of <see cref="Timing.CalculateTime(Plate, CutParameters)"/>.
/// </summary>
public MotionProfile Motion { get; set; }
/// <summary>Feed for interior contours (holes); null or zero uses <see cref="Feedrate"/>.</summary>
public double? HoleFeedrate { get; set; }
/// <summary>Leave out contours that only scribe/etch, as the CI Fiber post does by default.</summary>
public bool SkipScribe { get; set; }
public static CutParameters Default =>
new()
{
+13
View File
@@ -0,0 +1,13 @@
namespace OpenNest
{
/// <summary>
/// Cutting process a library drawing is prepared for, stored by name so new
/// processes can be added without a migration.
/// </summary>
public enum CutType
{
Laser,
Waterjet,
Plasma,
}
}
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>A nominal manual cut-off line meeting an active, physical clamp footprint.</summary>
public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location);
/// <summary>
/// Read-only axis-aligned clamp contact checks. Nominal cut-off definitions are checked
/// even if their current regenerated program is stale or trimmed by nearby parts.
/// A removed clamp is absent from Plate.ClampZones and therefore cannot be reported.
/// Zones are compared in the plate's signed placement frame (<see cref="ClampZoneFrame"/>).
/// </summary>
public static class ClampCutDiagnostics
{
public static IReadOnlyList<CutOffClampFinding> Find(Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
var findings = new List<CutOffClampFinding>();
if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0)
return findings.AsReadOnly();
var zones = ClampZoneFrame.ToPlacement(plate);
var sheet = plate.BoundingBox(false);
for (var index = 0; index < plate.CutOffs.Count; index++)
{
var cut = plate.CutOffs[index];
if (cut == null || !Enum.IsDefined(cut.Axis))
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
var start = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
var end = cut.Axis == CutOffAxis.Vertical
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
foreach (var zone in zones)
if (Intersects(start, end, zone.Bounds, out var location))
findings.Add(new CutOffClampFinding(index, zone.Id, location));
}
return findings.AsReadOnly();
}
/// <summary>True for contact with the whole rectangle, including a boundary touch.</summary>
public static bool Intersects(Vector from, Vector to, Box box, out Vector location)
{
location = default;
if (box == null || !Finite(from) || !Finite(to) || !Finite(box.Location)
|| !double.IsFinite(box.Right) || !double.IsFinite(box.Top)
|| box.Length <= 0 || box.Width <= 0)
throw new ArgumentException("Clamp contact geometry must be finite and nonempty.");
var dx = to.X - from.X;
var dy = to.Y - from.Y;
if (!double.IsFinite(dx) || !double.IsFinite(dy)
|| !double.IsFinite(from.X - box.Left) || !double.IsFinite(box.Right - from.X)
|| !double.IsFinite(from.Y - box.Bottom) || !double.IsFinite(box.Top - from.Y))
throw new ArgumentException("Clamp contact motion is too large.");
var enter = 0.0;
var exit = 1.0;
if (!Clip(-dx, from.X - box.Left, ref enter, ref exit)
|| !Clip(dx, box.Right - from.X, ref enter, ref exit)
|| !Clip(-dy, from.Y - box.Bottom, ref enter, ref exit)
|| !Clip(dy, box.Top - from.Y, ref enter, ref exit))
return false;
var midpoint = (enter + exit) * 0.5;
location = new Vector(from.X + dx * midpoint, from.Y + dy * midpoint);
return true;
}
private static bool Clip(double p, double q, ref double enter, ref double exit)
{
if (p == 0)
return q >= 0;
var ratio = q / p;
if (p < 0)
enter = System.Math.Max(enter, ratio);
else
exit = System.Math.Min(exit, ratio);
return enter <= exit;
}
private static bool Finite(Vector value) => double.IsFinite(value.X) && double.IsFinite(value.Y);
}
@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>
/// Shared material of two hole-aware outlines as connected islands, through Clipper2.
/// Callers pass part-local coordinates (rebased near the origin) and add the origin back.
/// <para>
/// Coordinates are rounded to a 1e-8 model-unit grid, far below the 0.001 chord tolerance
/// and the <see cref="OpenNest.Math.Tolerance.Epsilon"/> area threshold. Each operand is
/// its outline minus the union of its holes (outline positive, holes negative,
/// <see cref="FillRule.Positive"/>).
/// Nonfinite or out-of-range coordinates throw <see cref="ArithmeticException"/>, which the
/// analyzer turns into an incomplete-check issue rather than a clear result.
/// </para>
/// </summary>
internal static class OverlapClipping
{
private const double Scale = 1e8;
// Scaled coordinates stay several orders of magnitude inside Clipper2's int64 range.
private const double MaxCoordinate = 1e9;
/// <summary>One connected island: a counterclockwise outline and clockwise holes, both closed.</summary>
internal sealed record Island(List<Vector> Outline, List<List<Vector>> Holes, PolygonAreaMoments Moments);
internal static List<Island> Intersect(Polygon outerA, IReadOnlyList<Polygon> holesA,
Polygon outerB, IReadOnlyList<Polygon> holesB)
{
var clipper = new Clipper64();
clipper.AddSubject(Region(outerA, holesA));
clipper.AddClip(Region(outerB, holesB));
var tree = new PolyTree64();
if (!clipper.Execute(ClipType.Intersection, FillRule.Positive, tree))
throw new InvalidOperationException("Clipper2 could not intersect the overlap material.");
var islands = new List<Island>();
Collect(tree, islands);
return islands;
}
private static void Collect(PolyPath64 node, List<Island> islands)
{
for (var i = 0; i < node.Count; i++)
{
var outer = (PolyPath64)node[i];
var outline = ToVectors(outer.Polygon, positive: true);
var holes = new List<List<Vector>>();
for (var h = 0; h < outer.Count; h++)
{
var hole = (PolyPath64)outer[h];
holes.Add(ToVectors(hole.Polygon, positive: false));
// An island can sit inside a hole formed by both operands' holes together.
Collect(hole, islands);
}
if (TryMoments(outline, holes, out var moments))
islands.Add(new Island(outline, holes, moments));
else if (Clipper.Area(outer.Polygon) / (Scale * Scale) >= OpenNest.Math.Tolerance.Epsilon)
throw new ArithmeticException("Overlap island area moments are invalid.");
// Otherwise a degenerate dust island below the reporting threshold is skipped.
}
}
/// <summary>
/// Net area and centroid of an outline minus its holes. Fails for degenerate,
/// nonfinite or nonpositive results.
/// </summary>
private static bool TryMoments(List<Vector> outline, List<List<Vector>> holes, out PolygonAreaMoments moments)
{
moments = default;
if (!PolygonAreaMoments.TryCompute(outline, out var outer))
return false;
var removed = new List<PolygonAreaMoments>(holes.Count);
foreach (var hole in holes)
{
if (!PolygonAreaMoments.TryCompute(hole, out var hollow))
return false;
removed.Add(hollow);
}
return PolygonAreaMoments.TrySubtract(outer, removed, out moments);
}
private static Paths64 Region(Polygon outer, IReadOnlyList<Polygon> holes)
{
var region = new Paths64 { ToPath(outer, positive: true) };
if (holes != null)
foreach (var hole in holes)
region.Add(ToPath(hole, positive: false));
return region;
}
private static Path64 ToPath(Polygon polygon, bool positive)
{
var vertices = polygon.Vertices;
var count = vertices.Count;
if (count > 1 && vertices[count - 1].X == vertices[0].X && vertices[count - 1].Y == vertices[0].Y)
count--;
var path = new Path64(count);
for (var i = 0; i < count; i++)
{
var point = vertices[i];
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y)
|| System.Math.Abs(point.X) > MaxCoordinate || System.Math.Abs(point.Y) > MaxCoordinate)
throw new ArithmeticException("Overlap material is outside the exact clipping range.");
path.Add(new Point64(System.Math.Round(point.X * Scale), System.Math.Round(point.Y * Scale)));
}
if (Clipper.IsPositive(path) != positive)
path.Reverse();
return path;
}
private static List<Vector> ToVectors(Path64 path, bool positive)
{
var points = new List<Vector>(path.Count + 1);
foreach (var point in path)
points.Add(new Vector(point.X / Scale, point.Y / Scale));
if (Clipper.IsPositive(path) != positive)
points.Reverse();
points.Add(points[0]); // Regions repeat their closing vertex exactly.
return points;
}
}
@@ -11,20 +11,71 @@ public sealed class OverlapGeometryStamp
{
private readonly Plate plate;
private readonly Entry[] entries;
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
private readonly (long Width, long Length, int Quadrant) sheet;
private OverlapGeometryStamp(Plate plate)
{
this.plate = plate;
sheet = SheetState(plate);
entries = new Entry[plate.Parts.Count];
for (var i = 0; i < entries.Length; i++)
entries[i] = new Entry(plate.Parts[i]);
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
for (var i = 0; i < zones.Length; i++)
zones[i] = ZoneState(plate.ClampZones[i]);
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
for (var i = 0; i < cutoffs.Length; i++)
cutoffs[i] = CutOffState(plate.CutOffs[i]);
}
// Missing entries are malformed input the analysis itself reports; freshness must not
// throw first (the desktop captures it before its error boundary).
private static (string, long, long, long, long) ZoneState(ClampZone zone) =>
zone?.Bounds is not { } box ? default
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
cut == null ? default
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
private static (long, long, int) SheetState(Plate plate) =>
(Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
internal bool SameZones(Plate current)
{
// Stored zones are sheet-relative: a size or quadrant change moves them in the
// parts' frame without changing their bits.
if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length
|| sheet != SheetState(current))
return false;
for (var i = 0; i < zones.Length; i++)
if (zones[i] != ZoneState(current.ClampZones[i]))
return false;
return true;
}
internal bool SameCutOffs(Plate current)
{
if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length
|| sheet != SheetState(current))
return false;
for (var i = 0; i < cutoffs.Length; i++)
if (cutoffs[i] != CutOffState(current.CutOffs[i]))
return false;
return true;
}
public bool Matches(Plate current)
{
if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
return false;
for (var i = 0; i < entries.Length; i++)
if (!entries[i].Matches(current.Parts[i]))
+119
View File
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>
/// A marker point on one island of shared material. The area centroid of a concave,
/// holed or split overlap can lie off the material, so markers use the island's pole of
/// inaccessibility instead, with a guaranteed-interior fallback.
/// </summary>
internal static class OverlapMarker
{
// Distance evaluations per island; a long sliver otherwise refines along its whole ridge.
internal const int MaxCells = 4096;
/// <summary>
/// The island's pole of inaccessibility (searched to a tenth of its area/perimeter, a
/// lower bound on a thin island's half-width) when that bounded search lands inside;
/// otherwise the midpoint of the widest interior run of a horizontal scan line; otherwise
/// <paramref name="fallback"/>. Rings are closed, as the clipping returns them.
/// </summary>
internal static Vector Find(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, double area,
Vector fallback, int maxCells = MaxCells)
{
var outer = ToPolygon(outline);
var hollow = holes.Select(ToPolygon).ToList();
var perimeter = outer.Perimeter() + hollow.Sum(hole => hole.Perimeter());
var precision = 0.1 * area / perimeter;
if (double.IsFinite(precision) && precision > 0)
{
var pole = PolyLabel.Find(outer, hollow, precision, maxCells);
if (Inside(outline, holes, pole))
return pole;
}
return TryScanLine(outline, holes, out var point) ? point : fallback;
}
/// <summary>
/// Midpoint of the widest interior run along a horizontal line through the middle of the
/// island, placed between vertex heights so crossings are unambiguous (as JTS's
/// interior-point-of-area). Even-odd pairing of outline and hole crossings keeps it
/// inside the material.
/// </summary>
private static bool TryScanLine(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, out Vector point)
{
point = default;
var minY = outline.Min(vertex => vertex.Y);
var maxY = outline.Max(vertex => vertex.Y);
var centre = (minY + maxY) / 2;
double below = minY, above = maxY;
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
{
if (vertex.Y <= centre && vertex.Y > below)
below = vertex.Y;
else if (vertex.Y > centre && vertex.Y < above)
above = vertex.Y;
}
var y = (below + above) / 2;
if (!(y > minY && y < maxY))
return false;
var crossings = new List<double>();
AddCrossings(outline, y, crossings);
foreach (var hole in holes)
AddCrossings(hole, y, crossings);
crossings.Sort();
var widest = 0.0;
for (var i = 0; i + 1 < crossings.Count; i += 2)
{
var width = crossings[i + 1] - crossings[i];
if (width > widest)
{
widest = width;
point = new Vector((crossings[i] + crossings[i + 1]) / 2, y);
}
}
return widest > 0;
}
private static void AddCrossings(IReadOnlyList<Vector> ring, double y, List<double> crossings)
{
for (var i = 0; i + 1 < ring.Count; i++)
{
var a = ring[i];
var b = ring[i + 1];
if ((a.Y > y) != (b.Y > y))
crossings.Add(a.X + (y - a.Y) * (b.X - a.X) / (b.Y - a.Y));
}
}
private static bool Inside(IReadOnlyList<Vector> outline, IReadOnlyList<List<Vector>> holes, Vector point) =>
double.IsFinite(point.X) && double.IsFinite(point.Y)
&& InsideRing(outline, point) && !holes.Any(hole => InsideRing(hole, point));
// Crossing-number test over a closed ring (half-open edges, so a vertex counts once).
private static bool InsideRing(IReadOnlyList<Vector> ring, Vector point)
{
var inside = false;
for (var i = 0; i + 1 < ring.Count; i++)
{
var a = ring[i];
var b = ring[i + 1];
if ((a.Y > point.Y) != (b.Y > point.Y)
&& point.X < a.X + (point.Y - a.Y) * (b.X - a.X) / (b.Y - a.Y))
inside = !inside;
}
return inside;
}
private static Polygon ToPolygon(IReadOnlyList<Vector> ring)
{
var polygon = new Polygon();
polygon.Vertices.AddRange(ring);
polygon.UpdateBounds();
return polygon;
}
}
@@ -9,14 +9,21 @@ namespace OpenNest.Diagnostics;
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
public static class OverlapPairPresentation
{
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
/// <summary>True for part-vs-clamp findings carried on the report (negative clamp slot).</summary>
public static bool IsClampFinding(PlateOverlapPair pair) => pair.PartBId < 0;
public static string Label(PlateOverlapPair pair) =>
IsClampFinding(pair) ? $"{pair.PartAId + 1}/clamp" : $"{pair.PartAId + 1}/{pair.PartBId + 1}";
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
{
var units = UnitsHelper.GetShortString(capturedUnits);
return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
var head = IsClampFinding(pair)
? $"Clamp overlap: {pair.PartAName} / {pair.PartBName}"
: $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}";
return head + "\n"
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
}
// Significant figures adapt to scale: a positive sliver must never read as zero.
@@ -27,7 +34,7 @@ public static class OverlapPairPresentation
public static double HitRadius(int deviceDpi) => 10.0 * deviceDpi / 96;
/// <summary>
/// Projects cached centroids into a screen-pixel coordinate space. The pointer must
/// Projects cached markers into a screen-pixel coordinate space. The pointer must
/// use that same space; view zoom never scales the DPI-adjusted hit radius.
/// Coincident or nearby markers return every matching pair in stable ID order.
/// </summary>
@@ -37,7 +44,7 @@ public static class OverlapPairPresentation
var radius = HitRadius(deviceDpi);
return pairs.Where(pair =>
{
var center = worldToScreen(pair.Centroid);
var center = worldToScreen(pair.Marker);
var dx = pointer.X - center.X;
var dy = pointer.Y - center.Y;
return dx * dx + dy * dy <= radius * radius;
@@ -21,14 +21,22 @@ public sealed class OverlapReportState
public PlateOverlapReport Report { get; private set; }
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
Array.Empty<CutOffClampFinding>();
public bool IsRunning => Status == OverlapCheckStatus.Checking;
public string Message => Status switch
{
OverlapCheckStatus.Checking => "Checking overlaps…",
OverlapCheckStatus.Current => Report.Pairs.Count == 0
? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
? (Report.Pairs.Count == 0 ? "No material overlaps detected"
: $"Overlaps: {Report.Pairs.Count} pairs")
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
+ (Report.CutOffClampFindings.Count == 0 ? ""
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
+ $"{uncheckedPartCount} parts could not be checked",
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
OverlapCheckStatus.Canceled => "Overlap check canceled",
@@ -55,7 +63,9 @@ public sealed class OverlapReportState
if (!CanComplete(generation, plate))
return false;
Report = report;
DisplayPairs = report.Pairs;
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
DisplayCutOffClampFindings = report.CutOffClampFindings;
displayStamp = stamp;
observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues);
@@ -106,10 +116,14 @@ public sealed class OverlapReportState
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
return;
var unchanged = displayStamp.UnchangedSlots(plate);
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
var sameZones = displayStamp.SameZones(plate);
if (!sameZones || !displayStamp.SameCutOffs(plate))
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId]
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
if (retained.Count != DisplayPairs.Count)
DisplayPairs = retained.AsReadOnly();
if (DisplayPairs.Count == 0)
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
ClearDisplayPairs();
else
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
@@ -118,6 +132,7 @@ public sealed class OverlapReportState
private void ClearDisplayPairs()
{
DisplayPairs = Array.Empty<PlateOverlapPair>();
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
displayStamp = null;
observedDisplayStamp = null;
}
@@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Diagnostics;
/// <summary>
/// Pairs of placed parts closer than the plate's part spacing. Candidates are found exactly as Auto
/// Nest's layout check finds them: each part's clean nesting material
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin
/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is
/// confirmed on the native contours flattened at <see cref="FineTolerance"/> and reported only when
/// that gap is short of the spacing by more than the slack. Zero spacing enforces nothing, so
/// contact stays valid.
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
/// that check. The sheet edge and clamps are not spacing-checked here.
/// </summary>
internal static class PartSpacingReview
{
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary>
internal const double FineTolerance = SpacingOutline.SpacingSlack / 20;
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
CancellationToken cancellationToken)
{
var spacing = plate.PartSpacing;
if (!double.IsFinite(spacing) || spacing < 0)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
return;
}
var inflateBy = SpacingOutline.InflationFor(spacing);
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
return;
var unreadable = overlap.Issues.Where(issue => issue.PartBId == null)
.Select(issue => issue.PartAId).ToHashSet();
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
var profiles = new Dictionary<(Program, long), ShapeProfile>();
var shapes = new ShapeProfile[parts.Count];
var fine = new FineRings[parts.Count];
var raw = new SpacingOutline[parts.Count];
var inflated = new SpacingOutline[parts.Count];
for (var i = 0; i < parts.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
if (unreadable.Contains(i))
continue;
try
{
var profile = Profile(parts[i], profiles);
if (profile == null)
continue;
shapes[i] = profile;
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0);
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy);
if (raw[i] == null || inflated[i] == null)
throw new ArgumentException("Part material has no closed perimeter.");
}
catch (Exception exception) when (IsInvalid(exception))
{
raw[i] = inflated[i] = null;
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null,
"Part spacing check: " + exception.Message));
}
}
var order = Enumerable.Range(0, parts.Count)
.Where(i => raw[i] != null)
.OrderBy(i => raw[i].Perimeter.BoundingBox.Left)
.ToList();
for (var a = 0; a < order.Count; a++)
{
cancellationToken.ThrowIfCancellationRequested();
var i = order[a];
var reach = inflated[i].Perimeter.BoundingBox;
for (var b = a + 1; b < order.Count; b++)
{
var j = order[b];
var other = raw[j].Perimeter.BoundingBox;
// Sorted by Left, so nothing further along can reach part i either.
if (other.Left > reach.Right + Tolerance.Epsilon)
break;
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j))
|| !inflated[i].Overlaps(raw[j]))
continue;
double gap;
Vector marker;
try
{
fine[i] ??= FineRings.Of(shapes[i], parts[i].Location);
fine[j] ??= FineRings.Of(shapes[j], parts[j].Location);
gap = Gap(fine[i], fine[j], spacing, out marker);
}
catch (Exception exception) when (IsInvalid(exception))
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber,
System.Math.Min(partNumbers[i], partNumbers[j]),
System.Math.Max(partNumbers[i], partNumbers[j]),
"Part spacing check: " + exception.Message));
continue;
}
if (!(gap < spacing - SpacingOutline.SpacingSlack))
continue;
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
partNumbers[second],
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
+ $"plate's part spacing ({spacing:F3}).")
{ Location = marker });
}
}
}
/// <summary>Clean drawing material, rotated to the part's pose; null when the part has none.
/// Converted once per program and rotation.</summary>
private static ShapeProfile Profile(Part part, Dictionary<(Program, long), ShapeProfile> profiles)
{
var program = part.BaseDrawing.Program;
var rotation = part.Rotation - program.Rotation;
if (!double.IsFinite(rotation))
throw new ArgumentException("Part pose must be finite.");
var key = (program, BitConverter.DoubleToInt64Bits(rotation));
if (profiles.TryGetValue(key, out var cached))
return cached;
// Conversion creates fresh geometry, so rotating it never touches the live program.
var entities = ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout)
.ForNesting();
if (rotation != 0)
foreach (var entity in entities)
entity.Rotate(rotation);
var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
if (profile != null && profile.Perimeter == null)
throw new ArgumentException("Part material has no closed perimeter.");
profiles[key] = profile;
return profile;
}
/// <summary>Closest boundary approach between two parts, including a part inside the other's
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
/// skipped. Infinity when no edges are that near.</summary>
private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
{
var best = double.PositiveInfinity;
var middle = a.Bounds.Center;
var perimeterA = Near(a.Perimeter, b.Bounds, reach);
var perimeterB = Near(b.Perimeter, a.Bounds, reach);
Measure(perimeterA, perimeterB);
foreach (var hole in a.Holes)
Measure(Near(hole, b.Bounds, reach), perimeterB);
foreach (var hole in b.Holes)
Measure(perimeterA, Near(hole, a.Bounds, reach));
marker = middle;
return best;
void Measure(List<Line> ringA, List<Line> ringB)
{
if (ringA.Count == 0 || ringB.Count == 0)
return;
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
if (distance < best)
{
best = distance;
middle = (onA + onB) * 0.5;
}
}
}
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
lines.Where(line =>
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
/// <summary>A part's native contours in world space, flattened at <see cref="FineTolerance"/>.
/// Circumscribed, so the measured gap never exceeds the true one by more than rounding.</summary>
private sealed class FineRings
{
public List<Line> Perimeter { get; private init; }
public List<List<Line>> Holes { get; private init; }
public Box Bounds { get; private init; }
public static FineRings Of(ShapeProfile profile, Vector location)
{
var perimeter = World(profile.Perimeter, location, out var bounds);
return new FineRings
{
Perimeter = perimeter.ToLines(),
Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
Bounds = bounds,
};
}
private static Polygon World(Shape shape, Vector location, out Box bounds)
{
var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
polygon.Offset(location);
polygon.UpdateBounds();
bounds = polygon.BoundingBox;
return polygon;
}
}
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
private static string Name(Part part) => part.BaseDrawing.Name;
private static bool BoxesTouch(Box a, Box b) =>
a.Left <= b.Right + Tolerance.Epsilon
&& b.Left <= a.Right + Tolerance.Epsilon
&& a.Bottom <= b.Top + Tolerance.Epsilon
&& b.Bottom <= a.Top + Tolerance.Epsilon;
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
}
+198 -29
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Converters;
using OpenNest.Geometry;
@@ -16,13 +17,41 @@ public static class PlateOverlapAnalyzer
{
public const double ChordTolerance = 0.001;
/// <summary>
/// Capture a whole plate including active clamps and nominal cut-off definitions.
/// Use this entry for the desktop overlap check so manual cut-off contacts
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
/// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
/// placement frame; a malformed zone throws <see cref="ArgumentException"/>.
/// </summary>
public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plate);
var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
cache ?? new OverlapMaterialCache(), cancellationToken);
var cutoffs = ClampCutDiagnostics.Find(plate);
cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
material.Clamps.ToList(), cutoffs.ToList());
}
/// <summary>
/// Captures poses and converts each distinct clean source program to owned entities once.
/// Inputs must not change during capture. Later analysis never reads live domain objects.
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
CancellationToken cancellationToken = default) =>
Capture(parts, new OverlapMaterialCache(), cancellationToken);
Capture(parts, null, new OverlapMaterialCache(), cancellationToken);
/// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, additionally capturing
/// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings
/// (C1) alongside part pairs. Null means no zones.
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
IReadOnlyList<ClampZone> clampZones, CancellationToken cancellationToken = default) =>
Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken);
/// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
@@ -30,6 +59,12 @@ public static class PlateOverlapAnalyzer
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
CancellationToken cancellationToken = default) =>
Capture(parts, null, cache, cancellationToken);
/// <inheritdoc cref="Capture(IReadOnlyList{Part}, IReadOnlyList{ClampZone}, CancellationToken)"/>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
IReadOnlyList<ClampZone> clampZones, OverlapMaterialCache cache,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(parts);
@@ -63,8 +98,21 @@ public static class PlateOverlapAnalyzer
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
}
}
var clamps = new List<CapturedClamp>();
if (clampZones != null)
{
for (var ordinal = 0; ordinal < clampZones.Count; ordinal++)
{
cancellationToken.ThrowIfCancellationRequested();
var zone = clampZones[ordinal];
// ClampZone validates finiteness and positive size at construction; the
// snapshot still owns its own copy so later plate edits never move it.
clamps.Add(new CapturedClamp(ordinal, zone.Id,
new Box(zone.Bounds.X, zone.Bounds.Y, zone.Bounds.Length, zone.Bounds.Width)));
}
}
cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapSnapshot(captured, issues);
return new PlateOverlapSnapshot(captured, issues, clamps);
}
private static OverlapSource CaptureSource(Program program)
@@ -73,8 +121,9 @@ public static class PlateOverlapAnalyzer
{
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
// Conversion creates fresh geometry, including expanded shared hole calls;
// no cloning/rotation of a live program or subprogram is necessary.
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
// no cloning/rotation of a live program or subprogram is necessary. A baked-in
// seam overcut is trimmed on an owned copy: it encloses no area.
return new OverlapSource(program, ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
&& entity.Layer != SpecialLayers.Leadin
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
@@ -91,7 +140,7 @@ public static class PlateOverlapAnalyzer
Analyze(Capture(parts, cancellationToken), cancellationToken);
/// <summary>
/// Returns deterministic pair reports containing closed world-coordinate overlap fragments.
/// Returns deterministic pair reports containing closed world-coordinate overlap islands.
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
/// </summary>
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
@@ -156,34 +205,15 @@ public static class PlateOverlapAnalyzer
try
{
// Keep pair clipping arithmetic near the parts where possible, then
// restore output to world space. Triangulation itself also uses stable
// local-origin winding so tiny holes in a huge part remain correct.
// restore output to world space.
var origin = a.Material.Outer.Vertices[0];
var localA = a.Material.Transform(0, origin * -1);
var localB = b.Material.Transform(0, origin * -1);
var result = Collision.Check(localA.Outer, localB.Outer, localA.Holes, localB.Holes);
if (!result.Overlaps)
if (!TryShare(localA, localB.Outer, localB.Holes, origin, cancellationToken,
out var regions, out var centroid, out var marker))
continue;
// Evaluate every hole-subtracted fragment before restoring world space.
// A failed moment must make this pair incomplete, never an origin marker.
var moments = new List<PolygonAreaMoments>();
var regions = new List<PlateOverlapRegion>();
foreach (var region in result.OverlapRegions)
{
cancellationToken.ThrowIfCancellationRequested();
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
throw new ArithmeticException("Overlap fragment area moments are invalid.");
moments.Add(fragment);
regions.Add(new PlateOverlapRegion(region.Vertices.Select(point => point + origin),
fragment.Area));
}
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
throw new ArithmeticException("Combined overlap area moments are invalid.");
var centroid = combined.Centroid + origin;
if (!OverlapMaterial.IsFinite(centroid))
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
pairs.Add(new PlateOverlapPair(a.Input.Id, b.Input.Id,
a.Input.Name, b.Input.Name, regions, centroid));
a.Input.Name, b.Input.Name, regions, centroid, marker));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
@@ -192,9 +222,148 @@ public static class PlateOverlapAnalyzer
}
}
cancellationToken.ThrowIfCancellationRequested();
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
.ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
snapshot.CutOffClampFindings.ToList());
}
/// <summary>
/// C1 part-vs-clamp findings: the prepared hole-aware material against each captured
/// zone rectangle with the same exact Collision oracle as part pairs. Deterministic in
/// (part input id, clamp ordinal) order; failures become part issues like any other
/// pair geometry failure. Empty zone lists return without touching the material.
/// </summary>
private static List<PlateOverlapPair> FindClampOverlaps(List<PreparedPart> prepared,
PlateOverlapSnapshot snapshot, List<PlateOverlapIssue> issues,
CancellationToken cancellationToken)
{
var findings = new List<PlateOverlapPair>();
if (snapshot.Clamps.Count == 0)
return findings;
foreach (var part in prepared)
{
var partBounds = part.Material.Outer.BoundingBox;
foreach (var clamp in snapshot.Clamps)
{
cancellationToken.ThrowIfCancellationRequested();
var zone = clamp.Bounds;
// The prepared material is inscribed at ChordTolerance, so real material can
// reach that far past its bounds; a tiny clamp there must still be checked.
var reach = KeepOutOverlap.NeedsToleranceFreeTest(zone) ? ChordTolerance : 0;
if (partBounds.Right + reach <= zone.Left || zone.Right <= partBounds.Left - reach
|| partBounds.Top + reach <= zone.Bottom || zone.Top <= partBounds.Bottom - reach)
continue;
try
{
// A clamp below the polygon tolerance collapses in Collision.Check and would
// read as clear (PM aa995123): judge it tolerance-free and report the whole
// (negligibly small) clamp rectangle as the overlap fragment.
if (KeepOutOverlap.NeedsToleranceFreeTest(zone))
{
// Re-read the native contours conservatively and finely enough for
// this clamp: the shared 0.001 chords can hide material between an
// arc and its chord.
var profile = new ShapeProfile(part.Input.Source.Entities.Select(e => e.Clone()).ToList());
if (profile.Perimeter == null)
throw new ArgumentException("Drawing has no closed material contour.");
if (KeepOutOverlap.ConservativePolygons(profile, zone)
.Select(polygons => new OverlapMaterial(polygons.Perimeter, polygons.Holes)
.Transform(part.Input.Rotation, part.Input.Location))
.Any(fine => KeepOutOverlap.MaterialEnters(fine.Outer, fine.Holes, zone)))
findings.Add(TinyClampFinding(part, clamp));
continue;
}
var origin = part.Material.Outer.Vertices[0];
var local = part.Material.Transform(0, origin * -1);
var rect = RectanglePolygon(zone, origin * -1);
if (!TryShare(local, rect, null, origin, cancellationToken,
out var regions, out var centroid, out var marker))
continue;
findings.Add(new PlateOverlapPair(part.Input.Id,
ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
$"Clamp '{clamp.Id}'", regions, centroid, marker));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message));
}
}
}
return findings;
}
/// <summary>
/// Shared material of a rebased operand with another rebased outline/holes, as world
/// regions (connected islands) plus the area centroid of them all and a marker point
/// inside the largest island. Returns false when the
/// total shared area does not exceed <see cref="OpenNest.Math.Tolerance.Epsilon"/>: contact and rounding
/// dust are not overlap. Invalid moments throw, so the pair becomes incomplete rather
/// than clear or marked at the origin.
/// </summary>
private static bool TryShare(OverlapMaterial localA, Polygon outerB, List<Polygon> holesB, Vector origin,
CancellationToken cancellationToken, out List<PlateOverlapRegion> regions, out Vector centroid,
out Vector marker)
{
regions = null;
centroid = default;
marker = default;
var islands = OverlapClipping.Intersect(localA.Outer, localA.Holes, outerB, holesB);
var area = islands.Sum(island => island.Moments.Area);
if (!double.IsFinite(area))
throw new ArithmeticException("Overlap area moments are invalid.");
if (area <= OpenNest.Math.Tolerance.Epsilon)
return false;
regions = new List<PlateOverlapRegion>(islands.Count);
foreach (var island in islands)
{
cancellationToken.ThrowIfCancellationRequested();
regions.Add(new PlateOverlapRegion(island.Outline.Select(point => point + origin), island.Moments.Area,
island.Holes.Select(hole => hole.Select(point => point + origin))));
}
if (!PolygonAreaMoments.TryCombine(islands.Select(island => island.Moments), out var combined))
throw new ArithmeticException("Combined overlap area moments are invalid.");
centroid = combined.Centroid + origin;
if (!OverlapMaterial.IsFinite(centroid))
throw new ArithmeticException("Overlap centroid is not finite in world coordinates.");
// The area centroid can lie between islands or in a hole: mark the largest island.
var largest = islands.Aggregate((best, island) => island.Moments.Area > best.Moments.Area ? island : best);
marker = OverlapMarker.Find(largest.Outline, largest.Holes, largest.Moments.Area, largest.Moments.Centroid)
+ origin;
if (!OverlapMaterial.IsFinite(marker))
throw new ArithmeticException("Overlap marker is not finite in world coordinates.");
return true;
}
private static PlateOverlapPair TinyClampFinding(PreparedPart part, CapturedClamp clamp)
{
var zone = clamp.Bounds;
var corners = new[]
{
new Vector(zone.Left, zone.Bottom), new Vector(zone.Right, zone.Bottom),
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
};
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre,
centre);
}
private static Polygon RectanglePolygon(Box box, Vector offset)
{
var polygon = new Polygon();
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Bottom + offset.Y));
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Bottom + offset.Y));
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Top + offset.Y));
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Top + offset.Y));
polygon.Close();
polygon.UpdateBounds();
return polygon;
}
/// <summary>
@@ -8,11 +8,14 @@ namespace OpenNest.Diagnostics;
public sealed class PlateOverlapReport
{
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
PlateOverlapSnapshot snapshot)
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
List<CutOffClampFinding> cutOffClampFindings = null)
{
Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly();
Snapshot = snapshot;
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
}
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
@@ -20,6 +23,16 @@ public sealed class PlateOverlapReport
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
/// <summary>
/// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a
/// negative <see cref="PlateOverlapPair.PartBId"/> slot encoding the zone ordinal. Findings
/// are reported like other overlap findings; they do not change <see cref="IsComplete"/>.
/// </summary>
public IReadOnlyList<PlateOverlapPair> ClampFindings { get; }
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
public bool IsComplete => Issues.Count == 0;
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
}
@@ -39,6 +52,7 @@ public sealed class PlateOverlapPair
Regions = source.Regions;
Area = source.Area;
Centroid = source.Centroid;
Marker = source.Marker;
bounds = source.bounds;
}
@@ -47,7 +61,7 @@ public sealed class PlateOverlapPair
new(this, partAId, partBId, partAName, partBName);
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
List<PlateOverlapRegion> regions, Vector centroid)
List<PlateOverlapRegion> regions, Vector centroid, Vector marker)
{
PartAId = partAId;
PartBId = partBId;
@@ -56,6 +70,7 @@ public sealed class PlateOverlapPair
Regions = regions.AsReadOnly();
Area = regions.Sum(region => region.Area);
Centroid = centroid;
Marker = marker;
var points = regions.SelectMany(region => region.Vertices).ToArray();
var left = points.Min(point => point.X);
var bottom = points.Min(point => point.Y);
@@ -67,7 +82,7 @@ public sealed class PlateOverlapPair
public int PartBId { get; }
public string PartAName { get; }
public string PartBName { get; }
/// <summary>Convex fragments, not connected islands; no mutable kernel polygons are exposed.</summary>
/// <summary>Connected islands of shared material; no mutable kernel polygons are exposed.</summary>
public IReadOnlyList<PlateOverlapRegion> Regions { get; }
public double Area { get; }
/// <summary>
@@ -75,21 +90,47 @@ public sealed class PlateOverlapPair
/// This can lie outside disconnected or concave shared material. Returned by value.
/// </summary>
public Vector Centroid { get; }
/// <summary>
/// Finite world-coordinate point on the shared material, inside the pair's largest island:
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
/// the bounded pole search. Markers, hit testing and inspection rings use this point.
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
/// its conservative whole-clamp region, so its marker is the clamp's centre, within half
/// the clamp's size of the material. Returned by value.
/// </summary>
public Vector Marker { get; }
/// <summary>A fresh world-coordinate bounds copy.</summary>
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
}
/// <summary>A positive-area, hole-subtracted convex polygon in world coordinates.</summary>
/// <summary>
/// One connected island of shared material in world coordinates, after hole subtraction:
/// a counterclockwise outline and its clockwise holes.
/// </summary>
public sealed class PlateOverlapRegion
{
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area)
private static readonly IReadOnlyList<IReadOnlyList<Vector>> NoHoles = System.Array.AsReadOnly(
System.Array.Empty<IReadOnlyList<Vector>>());
internal PlateOverlapRegion(IEnumerable<Vector> vertices, double area,
IEnumerable<IEnumerable<Vector>> holes = null)
{
Vertices = System.Array.AsReadOnly(vertices.ToArray());
Area = area;
Holes = holes == null
? NoHoles
: System.Array.AsReadOnly(holes
.Select(hole => (IReadOnlyList<Vector>)System.Array.AsReadOnly(hole.ToArray()))
.ToArray());
}
/// <summary>Read-only vertices with an exactly repeated closing vertex.</summary>
/// <summary>Read-only counterclockwise outline with an exactly repeated closing vertex.</summary>
public IReadOnlyList<Vector> Vertices { get; }
/// <summary>Read-only clockwise hole rings, each with an exactly repeated closing vertex.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Holes { get; }
/// <summary>Positive net area: the outline's area minus its holes'.</summary>
public double Area { get; }
}
@@ -102,15 +143,32 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
/// </summary>
public sealed class PlateOverlapSnapshot
{
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
{
Parts = parts.AsReadOnly();
Issues = issues.AsReadOnly();
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
}
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
internal IReadOnlyList<CapturedClamp> Clamps { get; }
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
}
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
double Rotation, Vector Location);
/// <summary>A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index.</summary>
internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds);
/// <summary>Deterministic negative PartBId for a part-vs-clamp finding's clamp slot.</summary>
internal static class ClampFindingSlot
{
public static int IdFor(int ordinal) => -2 - ordinal;
public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId;
}
@@ -56,6 +56,51 @@ public static class PostVerificationAnalyzer
}
var materialParts = new List<Part>();
var indices = new List<int>();
// Stored zones use the sheet's lower-left frame; motions and parts are signed.
// Validate them before any early exit: zone bounds are publicly mutable.
IReadOnlyList<ClampZone> clamps;
try
{
clamps = ClampZoneFrame.ToPlacement(plate);
}
catch (Exception exception) when (IsInvalid(exception))
{
clamps = null;
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
"Clamp check: " + exception.Message));
}
// One clamp warning per cut-off part and clamp: the placed-program check below
// reports only what the nominal definition check has not already reported.
var cutOffClampContacts = new HashSet<(int PartNumber, string ClampId)>();
if (clamps != null)
{
try
{
foreach (var contact in ClampCutDiagnostics.Find(plate))
{
var cut = plate.CutOffs[contact.CutOffIndex];
var partIndex = -1;
for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++)
if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing))
{
partIndex = partSlot;
break;
}
if (partIndex >= 0)
cutOffClampContacts.Add((partIndex + 1, contact.ClampId));
findings.Add(new(PostVerificationKind.Overlap, plateNumber,
partIndex < 0 ? null : partIndex + 1, null,
$"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. "
+ "Review the clamp and cut before posting; this warning is not a repair.")
{ Location = contact.Location, ClampId = contact.ClampId });
}
}
catch (Exception exception) when (IsInvalid(exception))
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
"Clamp/cut-off check: " + exception.Message));
}
}
var obstacles = new ReleasedContourState();
Vector? position = startPoint;
for (var index = 0; index < plate.Parts.Count; index++)
@@ -94,7 +139,48 @@ public static class PostVerificationAnalyzer
&& move.Layer is LayerType.Cut or LayerType.Display
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
Incomplete("Placed program has no cutting contour motions for this drawing.");
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken,
proveOpenCuts: true);
if (clamps is { Count: > 0 })
foreach (var move in moves)
{
cancellationToken.ThrowIfCancellationRequested();
if (!move.Rapid || move.Start is not { } from
|| from.X == move.End.X && from.Y == move.End.Y)
continue;
foreach (var zone in clamps)
if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact))
findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber,
partNumber, null,
$"Direct XY rapid crosses active clamp '{zone.Id}'. "
+ "Head height is not modeled; review the posted route and clamp setup.")
{
Location = (from + move.End) * 0.5,
RapidStart = from,
RapidEnd = move.End,
ContactPoints = new[] { contact },
ClampId = zone.Id
});
}
// The placed program is what posts: a definition edited without
// regenerating it can still cut through a clamp its nominal line misses.
if (cutoff && clamps is { Count: > 0 })
foreach (var move in moves)
{
cancellationToken.ThrowIfCancellationRequested();
if (move.Rapid || move.Start is not { } from)
continue;
foreach (var zone in clamps)
if (!cutOffClampContacts.Contains((partNumber, zone.Id))
&& CutTouches(move, from, zone.Bounds, out var contact))
{
cutOffClampContacts.Add((partNumber, zone.Id));
findings.Add(new(PostVerificationKind.Overlap, plateNumber, partNumber, null,
$"The placed cut-off program crosses active clamp '{zone.Id}'. "
+ "Regenerate or move the cut-off before posting; this warning is not a repair.")
{ Location = contact, ClampId = zone.Id });
}
}
position = moves[^1].End;
}
catch (Exception exception) when (IsInvalid(exception))
@@ -109,19 +195,48 @@ public static class PostVerificationAnalyzer
plateNumber, partNumber, null, message));
}
cancellationToken.ThrowIfCancellationRequested();
// Leads Plan Cutting accepted as given (locked and drawing-pinned) are checked again here.
var fixedMotions = new Dictionary<int, Func<int, bool>>();
FixedLeadReview.Add(plate, plateNumber, findings, clamps, cancellationToken, fixedMotions);
// Parts nested in cutouts: inner-before-host order and leads across the pair.
NestedPartReview.Add(plate, plateNumber, findings, fixedMotions, cancellationToken);
// Lead-in pierces from any source keep the pierce diameter's zone clear of material.
PierceZoneReview.Add(plate, plateNumber, findings, cancellationToken);
var overlap = PlateOverlapAnalyzer.Analyze(
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty<ClampZone>()).ToArray(),
cancellationToken),
cancellationToken);
foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
{ Location = pair.Marker, Overlap = pair });
foreach (var pair in overlap.ClampFindings)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).")
{ Location = pair.Marker, Overlap = pair });
foreach (var issue in overlap.Issues)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
// Parts that do not overlap may still be closer than the plate's part spacing.
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
return new PostVerificationReport(findings);
}
// Lines are tested exactly; an arc conservatively by its whole supporting extent.
internal static bool CutTouches(ExecutionMotion move, Vector from, Box zone, out Vector contact)
{
if (move.Curve == null || move.Curve.IsLine)
return ClampCutDiagnostics.Intersects(from, move.End, zone, out contact);
var extent = move.Curve.Extent;
contact = new Vector(
System.Math.Clamp(move.Curve.Midpoint.X, zone.Left, zone.Right),
System.Math.Clamp(move.Curve.Midpoint.Y, zone.Bottom, zone.Top));
return !(extent.MinX > zone.Right || extent.MaxX < zone.Left
|| extent.MinY > zone.Top || extent.MaxY < zone.Bottom);
}
private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
@@ -9,6 +9,10 @@ namespace OpenNest.Diagnostics;
internal static class PostVerificationGeometry
{
internal const double Epsilon = 1e-8;
// Matches NominalContourClosure.Tolerance: the largest gap capture admits as a closed ring.
// Ray parity snaps vertex Y comparisons within this band so an admitted seam cannot flip an
// exterior point to interior (see Curve.CrossesRay).
internal const double RaySeamTolerance = 1e-6;
private const double TwoPi = 2 * System.Math.PI;
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
@@ -40,9 +44,37 @@ internal static class PostVerificationGeometry
/// are more than <see cref="ClearMargin"/> apart on some axis, so no native query can count
/// anything in one as touching or entering the other. Everything else must be checked.
/// </summary>
internal bool IsClearOf(Extent other) => IsWellConditioned && other.IsWellConditioned
&& (MaxX + ClearMargin < other.MinX || other.MaxX + ClearMargin < MinX
|| MaxY + ClearMargin < other.MinY || other.MaxY + ClearMargin < MinY);
internal bool IsClearOf(Extent other) => IsClearOf(other, ClearMargin);
/// <summary>
/// The relative rounding allowance of native line/circle intersections. Their discriminant
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
/// small circle needs the full check; a short lead beside a nearby part does not.
/// </summary>
internal const double SpanRounding = 1e-6;
/// <summary>
/// As <see cref="IsClearOf(Extent)"/>, with a gap that also covers the rounding of native
/// line/circle queries over the span of both extents (<see cref="SpanRounding"/>), plus
/// <paramref name="reach"/>. Lead checks skip a material only on this test.
/// </summary>
internal bool IsClearOfQueriesFrom(Extent other, double reach = 0)
{
var union = Union(other);
var span = System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
}
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
private bool IsClearOf(Extent other, double margin) => IsWellConditioned && other.IsWellConditioned
&& (MaxX + margin < other.MinX || other.MaxX + margin < MinX
|| MaxY + margin < other.MinY || other.MaxY + margin < MinY);
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
// (including the empty extent's) fail this test too.
@@ -89,6 +121,30 @@ internal static class PostVerificationGeometry
return inside;
}
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
IReadOnlyList<Curve> curves, CancellationToken token)
{
var length = start.DistanceTo(end);
if (length <= Epsilon)
return Array.Empty<Vector>();
var direction = (end - start) * (1 / length);
var distances = new List<double>();
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
curve.ContactAfterStart(start, direction, length, distances);
}
distances.Sort();
var points = new List<Vector>();
foreach (var distance in distances)
{
var point = start + direction * distance;
if (points.Count == 0 || points[^1].DistanceTo(point) > Epsilon)
points.Add(point);
}
return points.AsReadOnly();
}
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
private static double Normalize(double angle)
@@ -97,6 +153,10 @@ internal static class PostVerificationGeometry
return angle < 0 ? angle + TwoPi : angle;
}
/// <summary>Exact identity of a <see cref="Curve"/>'s defining values (see <see cref="Curve.Key"/>).</summary>
internal readonly record struct CurveKey(long StartX, long StartY, long EndX, long EndY,
bool HasCenter, long CenterX, long CenterY, long Radius, long Sweep);
internal sealed class Curve
{
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
@@ -106,14 +166,20 @@ internal static class PostVerificationGeometry
Center = center;
Radius = radius;
Sweep = sweep;
nativeEntity = new Lazy<Entity>(ToEntity);
}
// Never expose this mutable native wrapper; its geometry is read-only after creation.
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
private readonly Lazy<Entity> nativeEntity;
internal Vector Start { get; }
internal Vector End { get; }
private Vector? Center { get; }
private double Radius { get; }
private double Sweep { get; }
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
internal bool IsLine => !Center.HasValue;
// Native entities are freshly allocated; the immutable curve never exposes state.
internal Entity ToEntity() => Center is { } center
@@ -138,20 +204,41 @@ internal static class PostVerificationGeometry
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
: (Start + End) * 0.5;
internal bool SameSupport(Curve other)
/// <summary>
/// The exact bits of every value a native query reads, so two curves with equal keys
/// give identical query results. Not a geometric tolerance: equal shapes built from
/// different roundoff get different keys.
/// </summary>
internal CurveKey Key => new(Bits(Start.X), Bits(Start.Y), Bits(End.X), Bits(End.Y),
Center.HasValue, Bits(Center?.X ?? 0), Bits(Center?.Y ?? 0), Bits(Radius), Bits(Sweep));
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
internal bool SameSupport(Curve other) => SameSupport(other, Epsilon);
/// <summary>
/// Support agreement with an explicit positional allowance. Used only where an upstream
/// stage has already admitted a larger nominal discrepancy — currently the inclusive
/// <see cref="NominalContourClosure.Tolerance"/> joint that a replayed ring bridges. It is
/// never a collision tolerance: safety queries keep the exact epsilon.
/// </summary>
internal bool SameSupport(Curve other, double positionTolerance)
{
if (Center is { } center)
return other.Center is { } c && center.DistanceTo(c) <= Epsilon
&& System.Math.Abs(Radius - other.Radius) <= Epsilon;
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon)
return other.Center is { } c && center.DistanceTo(c) <= positionTolerance
&& System.Math.Abs(Radius - other.Radius) <= positionTolerance;
if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance)
return false;
var direction = (End - Start) * (1 / Length);
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance
&& System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance;
}
// Directed native arc-length coordinates for contour replay, not collision queries.
internal bool SameDirection(Curve other) => SameSupport(other)
internal bool SameDirection(Curve other) => SameDirection(other, Epsilon);
/// <summary>Directed coverage check with an admitted positional allowance; direction sign stays exact.</summary>
internal bool SameDirection(Curve other, double positionTolerance) => SameSupport(other, positionTolerance)
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
: Dot(End - Start, other.End - other.Start) > 0);
@@ -160,6 +247,25 @@ internal static class PostVerificationGeometry
: Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
: Dot(point - Start, (End - Start) * (1 / Length));
/// <summary>Unit direction of travel at the curve's start or end.</summary>
internal Vector Tangent(bool atEnd)
{
if (Center is not { } center)
return (End - Start) * (1 / Length);
var radial = (atEnd ? End : Start) - center;
var turn = System.Math.Sign(Sweep);
return new Vector(-radial.Y * turn, radial.X * turn) * (1 / Radius);
}
/// <summary>How far the curve reaches from <paramref name="origin"/> along unit <paramref name="direction"/>.</summary>
internal double Reach(Vector origin, Vector direction)
{
var reach = System.Math.Max(Dot(Start - origin, direction), Dot(End - origin, direction));
if (Center is { } center && OnArc(center + direction * Radius))
reach = System.Math.Max(reach, Dot(center - origin, direction) + Radius);
return reach;
}
internal Vector PointAtLength(double distance)
{
if (distance <= 0) return Start;
@@ -170,12 +276,28 @@ internal static class PostVerificationGeometry
: Start + (End - Start) * (distance / Length);
}
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
internal bool Contains(Vector point) => Contains(point, Epsilon);
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
internal bool Contains(Vector point, double positionTolerance)
=> nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= positionTolerance;
/// <summary>
/// Filters supporting-circle contacts against the actual finite arc span. The
/// native angular padding is larger and must not turn an arc extension into a
/// self-intersection at an adjacent nominal joint. This is not a radial test.
/// </summary>
internal bool IsOutsideArcSpan(Vector point) => Center.HasValue && !OnArc(point);
internal bool IsFiniteArc => Center.HasValue && System.Math.Abs(Sweep) < TwoPi;
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
{
var entity = ToEntity();
var candidate = other.ToEntity();
var entity = nativeEntity.Value;
var candidate = other.nativeEntity.Value;
// Native arc filters discard NaN supporting-circle intersections. Inspect
// both unfiltered queries first: roundoff can differ by operand direction.
// Coincident supports have separate overlap handling below.
@@ -203,18 +325,21 @@ internal static class PostVerificationGeometry
points.Clear();
// Coincident circles produce NaNs in the native discrete-contact query.
// Their support overlap is handled separately, without inventing crossings.
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other)
|| InteriorWitness(other.Midpoint, this)
|| InteriorWitness(Start, other) || InteriorWitness(End, other)
|| InteriorWitness(other.Start, this) || InteriorWitness(other.End, this));
// Supports that agree only within Epsilon (arcs of one circle whose centers were
// each derived from their own endpoints) put a witness up to that far off the other
// support. Test those spans at the admitted seam tolerance instead: shared spans
// still refuse, while arcs that only meet at a joint or lie apart are not overlap.
var witness = Center is { } c && other.Center is { } oc
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other, witness)
|| InteriorWitness(other.Midpoint, this, witness)
|| InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
|| InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
if (SameSupport(other))
points.Clear();
foreach (var point in points)
Validate(point);
var nativeContactCount = points.Count;
if (Center is { } c && other.Center is { } oc && SameSupport(other)
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius))
overlap = true; // Nearly coincident supports are uncertain, never clear.
foreach (var point in new[] { Start, End, other.Start, other.End })
if (Contains(point) && other.Contains(point)
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
@@ -227,9 +352,9 @@ internal static class PostVerificationGeometry
throw new NotSupportedException("Native contact query is numerically uncertain.");
return points;
static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point)
static bool InteriorWitness(Vector point, Curve curve, double tolerance) => curve.Contains(point, tolerance)
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
|| (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon));
|| (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
}
/// <summary>
@@ -281,7 +406,8 @@ internal static class PostVerificationGeometry
// The largest distance from the centre that ContactAfterStart can count as contact.
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
List<double> contacts = null)
{
if (Center is { } center)
{
@@ -294,10 +420,13 @@ internal static class PostVerificationGeometry
if (square < -ContactSlack)
return false;
var offset = System.Math.Sqrt(System.Math.Max(0, square));
return Hit(projection - offset) || Hit(projection + offset);
return contacts == null
? Hit(projection - offset) || Hit(projection + offset)
: Hit(projection - offset) | Hit(projection + offset);
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length))
&& Record(distance);
}
var edge = End - Start;
var relativeStart = Start - origin;
@@ -310,19 +439,36 @@ internal static class PostVerificationGeometry
var b = Dot(End - origin, direction);
var low = System.Math.Max(0, System.Math.Min(a, b));
var high = System.Math.Min(length, System.Math.Max(a, b));
return high > Epsilon && low <= high + Epsilon;
return high > Epsilon && low <= high + Epsilon && Record(low) && Record(high);
}
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon);
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon)
&& Record(distanceAlongRapid);
bool Record(double distance)
{
contacts?.Add(System.Math.Clamp(distance, 0, length));
return true;
}
}
internal bool CrossesRay(Vector point)
{
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
// PEP-converted or rotated geometry carries sub-micro-inch vertex jogs, so an exact
// comparison can split one vertex across the ray and report far-away exterior points as
// interior — which made LeadPathValidator refuse leads that could touch nothing.
// Snapping within the admitted seam tolerance keeps the decision consistent on both
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
// still count. Containment of a point that itself lies within the tolerance of a
// boundary edge stays ambiguous by design, exactly as it was before.
var rayY = point.Y + RaySeamTolerance;
if (Center is not { } center)
return (Start.Y > point.Y) != (End.Y > point.Y)
return (Start.Y > rayY) != (End.Y > rayY)
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
@@ -343,7 +489,7 @@ internal static class PostVerificationGeometry
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
if ((ya > point.Y) == (yb > point.Y))
if ((ya > rayY) == (yb > rayY))
continue;
var dy = point.Y - center.Y;
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
@@ -10,12 +11,45 @@ public enum PostVerificationKind
Overlap,
MissingLeadIn,
RapidCrossing,
Incomplete
Incomplete,
/// <summary>
/// A violation inside a fixed emission (a locked program, or a contour cut exactly as its
/// drawing pins it): accepted as given, never repaired, always reported for review.
/// </summary>
FixedLead,
/// <summary>A part inside another part's cutout is cut after that host, whose cutout releases it.</summary>
NestedCutOrder,
/// <summary>
/// A lead of a nested part or of its host contacts or enters the other's material; for a host
/// this is typically an enclosing-hole lead crossing the insert.
/// </summary>
NestedLead,
/// <summary>A lead-in pierce is closer to part material than half the pierce diameter.</summary>
PierceZone,
/// <summary>
/// Two parts are closer than the plate's part spacing without sharing material, judged as
/// Auto Nest's layout check judges them.
/// </summary>
PartSpacing
}
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
int? PartNumber, int? OtherPartNumber, string Message);
int? PartNumber, int? OtherPartNumber, string Message)
{
public Vector? Location { get; init; }
public Vector? RapidStart { get; init; }
public Vector? RapidEnd { get; init; }
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
public PlateOverlapPair Overlap { get; init; }
/// <summary>Active clamp contacted by a cut-off or direct XY rapid, if applicable.</summary>
public string ClampId { get; init; }
}
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
public sealed class PostVerificationReport
@@ -29,14 +63,27 @@ public sealed class PostVerificationReport
public bool HasWarnings => Findings.Count != 0;
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
public string ToDisplayText()
public string ToDisplayText() => ToDisplayText("Pre-post verification");
public string ToDisplayText(string title)
{
var text = new StringBuilder();
text.AppendLine("Pre-post verification");
text.AppendLine(title);
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
Summary(PostVerificationKind.Overlap, "Overlap");
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
Summary(PostVerificationKind.RapidCrossing, "Rapid crossings");
// Only nests with fixed-lead findings gain the line, so other reports read as before.
if (Findings.Any(finding => finding.Kind == PostVerificationKind.FixedLead))
Summary(PostVerificationKind.FixedLead, "Fixed leads");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedCutOrder))
Summary(PostVerificationKind.NestedCutOrder, "Nested part order");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.NestedLead))
Summary(PostVerificationKind.NestedLead, "Nested part leads");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PierceZone))
Summary(PostVerificationKind.PierceZone, "Pierce zones");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
Summary(PostVerificationKind.PartSpacing, "Part spacing");
foreach (var finding in Findings)
{
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
@@ -46,16 +93,16 @@ public sealed class PostVerificationReport
text.Append($", other part {other}");
text.AppendLine($": {finding.Kind}: {finding.Message}");
}
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " +
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " +
"machine program and machine setup. Actual contour gaps are not proof of adequate retention.");
text.AppendLine("These checks cover direct XY rapids in plate/program order; the post may " +
"change order, routing or retracts. Inspect the posted machine program and machine setup " +
"before cutting, and confirm actual contour gaps give adequate retention.");
return text.ToString();
void Summary(PostVerificationKind kind, string label)
{
var count = Findings.Count(finding => finding.Kind == kind);
text.AppendLine($"{label}: {count} warning(s)" +
(incomplete ? "; verification incomplete — do not treat as clear." : "."));
(incomplete ? "; verification incomplete — review before posting." : "."));
}
}
}
+47
View File
@@ -104,14 +104,53 @@ namespace OpenNest
public Color Color { get; set; }
/// <summary>
/// True when someone chose <see cref="Color"/> on purpose. Otherwise the color comes
/// from the active scheme's palette and is reassigned when the scheme changes or the
/// nest is opened.
/// </summary>
public bool HasCustomColor { get; set; }
public bool IsCutOff { get; set; }
/// <summary>
/// Cut this drawing's parts before every other part when automatic sequencing runs,
/// e.g. a test coupon checked before the rest of the sheet is cut.
/// </summary>
public bool CutFirst { get; set; }
public NestConstraints Constraints { get; set; }
public SourceInfo Source { get; set; }
public List<Bend> Bends { get; set; } = new List<Bend>();
/// <summary>
/// The library revision this drawing was copied from, or null when it did not come
/// from a drawing library. Editing the drawing in a nest keeps the link as provenance.
/// </summary>
public DrawingLibraryLink Library { get; set; }
/// <summary>
/// Contours this drawing says must be cut a fixed way, in the source frame. At most one
/// pin per contour is used; <see cref="CNC.CuttingPlanning.CutPinResolver"/> maps them to
/// a placement. Pins do not follow geometry into new drawings (split pieces).
/// </summary>
public List<CNC.CuttingStrategy.ContourCutPin> CutPins { get; set; } = new List<CNC.CuttingStrategy.ContourCutPin>();
/// <summary>
/// Scrap pieces tabbed to the part, in the source frame. <see cref="Program"/> already has
/// them applied; they are kept so an editor can change them from <see cref="SourceEntities"/>.
/// </summary>
public List<Breakoffs.Breakoff> Breakoffs { get; set; } = new List<Breakoffs.Breakoff>();
/// <summary>
/// Holes whose slugs stay attached by tabs, in the source frame. <see cref="Program"/> already
/// has them applied (cut pieces plus <see cref="CNC.LayerType.Tab"/> spans); they are kept so
/// an editor can change them from <see cref="SourceEntities"/>.
/// </summary>
public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new List<TabbedCutouts.TabbedCutout>();
/// <summary>
/// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file.
@@ -124,6 +163,14 @@ namespace OpenNest
/// </summary>
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
/// <summary>
/// Role overrides for entities in <see cref="SourceEntities"/>; entities not listed keep the
/// role their layer implies (see <see cref="Converters.EntityRoles"/>). <see cref="Program"/> already has
/// them applied; display-only entities are not in it and come from
/// <see cref="Converters.EntityRoles.DisplayGeometry"/>.
/// </summary>
public Dictionary<Guid, EntityRole> EntityRoles { get; set; } = new Dictionary<Guid, EntityRole>();
public double Area { get; protected set; }
public void UpdateArea()
+70
View File
@@ -0,0 +1,70 @@
using System;
namespace OpenNest
{
/// <summary>
/// Where a nest drawing came from in a server drawing library: the exact revision that
/// was copied in and a snapshot of its library details at that time. The nest owns its
/// copy; the link never changes the library and the library never changes the copy.
/// Immutable, so drawing copies may share one instance.
/// </summary>
public sealed class DrawingLibraryLink
{
public DrawingLibraryLink(string serverKey, Guid id, int revision, CutType cutType, double thickness,
string materialName, string materialGrade, Units units, string notes, string packageSha256)
{
if (string.IsNullOrWhiteSpace(serverKey))
throw new ArgumentException("A library link needs the server it came from.", nameof(serverKey));
if (id == Guid.Empty)
throw new ArgumentException("A library link needs a drawing id.", nameof(id));
if (revision < 1)
throw new ArgumentOutOfRangeException(nameof(revision), revision, "Revisions start at 1.");
if (!Enum.IsDefined(cutType))
throw new ArgumentOutOfRangeException(nameof(cutType), cutType, "Unsupported cut type.");
if (!double.IsFinite(thickness) || thickness < 0)
throw new ArgumentOutOfRangeException(nameof(thickness), thickness, "Thickness must be finite and not negative.");
if (!Enum.IsDefined(units))
throw new ArgumentOutOfRangeException(nameof(units), units, "Unsupported units.");
ServerKey = serverKey;
Id = id;
Revision = revision;
CutType = cutType;
Thickness = thickness;
MaterialName = materialName ?? "";
MaterialGrade = materialGrade ?? "";
Units = units;
Notes = notes ?? "";
PackageSha256 = packageSha256 ?? "";
}
/// <summary>Normalized base URL of the server holding the library (no trailing slash).</summary>
public string ServerKey { get; }
public Guid Id { get; }
/// <summary>The library revision this drawing is a copy of.</summary>
public int Revision { get; }
public CutType CutType { get; }
/// <summary>Library thickness in <see cref="Units"/>.</summary>
public double Thickness { get; }
public string MaterialName { get; }
public string MaterialGrade { get; }
public Units Units { get; }
public string Notes { get; }
/// <summary>Lower-case hex SHA-256 of the library package for <see cref="Revision"/>.</summary>
public string PackageSha256 { get; }
/// <summary>True when both links name the same revision of the same library drawing.</summary>
public bool IsSameRevision(DrawingLibraryLink other) =>
other != null && Id == other.Id && Revision == other.Revision
&& string.Equals(ServerKey, other.ServerKey, StringComparison.Ordinal);
}
}
+20 -9
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest;
@@ -10,11 +11,11 @@ namespace OpenNest;
/// </summary>
public sealed class DrawingProgramSnapshot
{
private readonly Dictionary<Drawing, string> programs;
private readonly Dictionary<Drawing, Captured> programs;
private readonly Func<Program, string> fingerprint;
private DrawingProgramSnapshot(
Dictionary<Drawing, string> programs,
Dictionary<Drawing, Captured> programs,
Func<Program, string> fingerprint)
{
this.programs = programs;
@@ -32,12 +33,12 @@ public sealed class DrawingProgramSnapshot
{
ArgumentNullException.ThrowIfNull(drawings);
ArgumentNullException.ThrowIfNull(fingerprint);
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
var programs = new Dictionary<Drawing, Captured>(ReferenceEqualityComparer.Instance);
foreach (var drawing in drawings)
{
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
programs.Add(drawing, fingerprint(drawing.Program));
programs.Add(drawing, new Captured(fingerprint(drawing.Program), SourceOffset(drawing)));
}
return new DrawingProgramSnapshot(programs, fingerprint);
@@ -45,20 +46,22 @@ public sealed class DrawingProgramSnapshot
/// <summary>
/// Complete the captured edit by rebuilding changed drawings' parts across all plates.
/// Part.Update preserves the placement and clears obsolete lead-ins, tabs and locks.
/// Part.Update clears obsolete lead-ins, tabs and locks; the placement is anchored to the
/// source frame, so a rebuild that re-origins the program (Source.Offset moved, e.g. a new
/// first pierce) leaves the geometry where it was on the plate.
/// Unchanged and uncaptured drawings' parts retain their program instances and state.
/// Returns the rebuilt parts so a UI can invalidate just their graphics.
/// </summary>
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
{
ArgumentNullException.ThrowIfNull(plates);
var changed = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
var originShifts = new Dictionary<Drawing, Vector>(ReferenceEqualityComparer.Instance);
foreach (var entry in programs)
{
if (!entry.Key.IsCutOff
&& !string.Equals(entry.Value, fingerprint(entry.Key.Program), StringComparison.Ordinal))
changed.Add(entry.Key);
&& !string.Equals(entry.Value.Fingerprint, fingerprint(entry.Key.Program), StringComparison.Ordinal))
originShifts.Add(entry.Key, SourceOffset(entry.Key) - entry.Value.SourceOffset);
}
var updated = new List<Part>();
@@ -66,14 +69,22 @@ public sealed class DrawingProgramSnapshot
{
foreach (var part in plate.Parts)
{
if (!changed.Contains(part.BaseDrawing))
if (!originShifts.TryGetValue(part.BaseDrawing, out var shift))
continue;
// A source point P sat at Location + R(P - old); keep it there with the new origin.
var rotation = part.Rotation;
part.Update();
if (shift != Vector.Zero)
part.Offset(shift.Rotate(rotation));
updated.Add(part);
}
}
return updated;
}
private static Vector SourceOffset(Drawing drawing) => drawing.Source?.Offset ?? Vector.Zero;
private readonly record struct Captured(string Fingerprint, Vector SourceOffset);
}
+47 -11
View File
@@ -80,16 +80,60 @@ namespace OpenNest.Geometry
return Separation(linesA, linesB);
}
/// <summary>Distance from a closed boundary's segments to every segment
/// of an intentional open cutting path. Unlike polygonizing that path,
/// this retains segment interiors and disjoint subpaths.</summary>
internal static ClearanceResult BoundaryDistance(List<Line> closed, List<Line> open)
{
if (closed.Count == 0 || open.Count == 0)
return new ClearanceResult(0, new Vector(1, 0));
return Separation(closed, open);
}
/// <summary>
/// Minimum boundary distance between two non-overlapping rings and the
/// direction that translates <paramref name="linesA"/> away from
/// <paramref name="linesB"/> at the closest contact.
/// </summary>
private static ClearanceResult Separation(List<Line> linesA, List<Line> linesB)
{
var minDist = ClosestPoints(linesA, linesB, out var pa, out var pb);
var dir = pa - pb;
var len = Magnitude(dir);
if (len <= Tolerance.Epsilon)
dir = CentroidAway(linesA, linesB);
else
dir = dir / len;
return new ClearanceResult(minDist, dir);
}
/// <summary>
/// Minimum boundary (edge-to-edge) distance between two rings and the point on each
/// boundary where it occurs. Like <see cref="BoundaryDistance(Polygon, Polygon)"/>, never
/// tests overlap. Infinity, with zero points, when either ring has no edges.
/// </summary>
public static double ClosestPoints(Polygon a, Polygon b, out Vector onA, out Vector onB)
{
var linesA = a.ToLines();
var linesB = b.ToLines();
if (linesA.Count == 0 || linesB.Count == 0)
{
onA = onB = Vector.Zero;
return double.PositiveInfinity;
}
return ClosestPoints(linesA, linesB, out onA, out onB);
}
internal static double ClosestPoints(List<Line> linesA, List<Line> linesB, out Vector pa, out Vector pb)
{
var minDist = double.MaxValue;
var pa = Vector.Zero;
var pb = Vector.Zero;
pa = Vector.Zero;
pb = Vector.Zero;
var boxes = new Box[linesB.Count];
for (var i = 0; i < linesB.Count; i++)
@@ -114,15 +158,7 @@ namespace OpenNest.Geometry
}
}
var dir = pa - pb;
var len = Magnitude(dir);
if (len <= Tolerance.Epsilon)
dir = CentroidAway(linesA, linesB);
else
dir = dir / len;
return new ClearanceResult(minDist, dir);
return minDist;
}
/// <summary>
+7 -5
View File
@@ -5,12 +5,14 @@ using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// Polygon overlap test with hole subtraction. This is the reference implementation
/// for a future GPU kernel, so it deliberately stays hand-rolled instead of using
/// Clipper (which is CPU-only and allocation-heavy; see <see cref="ClipperBridge"/>
/// for the CPU preparation that feeds it).
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair
/// checks. It was written as the reference for a GPU kernel; that port is on hold in
/// favour of Clipper2, starting with the material-overlap diagnostic. Engine callers
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate
/// pass.
/// <para>
/// GPU-port contract. Per-polygon preparation, done once per drawing and rotation,
/// Former GPU-port contract, kept for reference. Per-polygon preparation, done once
/// per drawing and rotation,
/// then cached and uploaded: the spacing offset (<see cref="ClipperBridge"/>),
/// triangulation (<see cref="ConvexDecomposition.Triangulate"/>) of the outline and
/// each hole, and the bounding box of every polygon and triangle. Per-pair work,
+1 -1
View File
@@ -953,7 +953,7 @@ namespace OpenNest.Geometry
OldSampleCount = old.TotalSamples,
DiagnosticIoU = iou,
FailureMessage = (reasons & (AlignmentReasons.FailedConvergence | AlignmentReasons.InsufficientSupport)) != 0
? "Automatic alignment is not trustworthy for this pair; use the manual overlay."
? "Use the manual overlay to align this pair."
: null,
};
}
+161
View File
@@ -0,0 +1,161 @@
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// Tolerance-free test of material against an axis-aligned keep-out rectangle, for
/// rectangles too small for the tolerance-based overlap machinery (validation offsets,
/// Clipper grids, triangulation and vector equality work at 1e-3 to 1e-5 scale and collapse
/// such a rectangle, PM 6859db53). Positive-area overlap only: flush contact is clear, as
/// everywhere else.
/// </summary>
public static class KeepOutOverlap
{
/// <summary>The scale below which tolerance-based overlap tests are not trusted for a
/// keep-out rectangle (the validation outline chord tolerance).</summary>
public const double ToleranceFreeBelow = 0.001;
/// <summary>A keep-out narrower than <see cref="ToleranceFreeBelow"/> on either axis.</summary>
public static bool NeedsToleranceFreeTest(Box keepOut) =>
keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow;
/// <summary>
/// Chord tolerance for flattening real part material against <paramref name="keepOut"/>:
/// a quarter of its smaller side, at most <see cref="ToleranceFreeBelow"/>.
/// </summary>
public static double FlatteningTolerance(Box keepOut) =>
System.Math.Min(ToleranceFreeBelow, System.Math.Min(keepOut.Length, keepOut.Width) / 4);
/// <summary>
/// A part's native contours flattened two ways for a tiny keep-out, never through a
/// grid-rounding offset: lines exact, every arc (perimeter and cutouts) inscribed in the
/// first and circumscribed in the second, at <see cref="FlatteningTolerance"/>. Along a
/// convex arc the circumscribed version holds all real material, along a concave arc
/// (a notch, a round hole) the inscribed one does, so together they hold all of it and
/// testing both finds any real overlap. The price is a possible report within that
/// tolerance of an arc, never a false clear.
/// </summary>
public static IEnumerable<(Polygon Perimeter, List<Polygon> Holes)> ConservativePolygons(
ShapeProfile profile, Box keepOut)
{
var tolerance = FlatteningTolerance(keepOut);
foreach (var circumscribe in new[] { false, true })
{
var perimeter = profile.Perimeter.ToPolygonWithTolerance(tolerance, circumscribe);
var holes = new List<Polygon>(profile.Cutouts.Count);
foreach (var cutout in profile.Cutouts)
holes.Add(cutout.ToPolygonWithTolerance(tolerance, circumscribe));
yield return (perimeter, holes);
}
}
/// <summary>
/// True when either of the <see cref="ConservativePolygons"/>, translated by
/// <paramref name="offset"/>, enters <paramref name="keepOut"/>.
/// </summary>
public static bool ProfileEnters(ShapeProfile profile, Vector offset, Box keepOut)
{
foreach (var (perimeter, holes) in ConservativePolygons(profile, keepOut))
{
perimeter.Offset(offset);
foreach (var hole in holes)
hole.Offset(offset);
if (MaterialEnters(perimeter, holes, keepOut))
return true;
}
return false;
}
/// <summary>
/// True when the material (perimeter minus holes) shares positive area with the open
/// rectangle: some boundary edge runs through its interior, or its interior lies wholly
/// inside the material (tested at its centre).
/// </summary>
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box)
{
if (RingEnters(perimeter, box))
return true;
if (holes != null)
foreach (var hole in holes)
if (RingEnters(hole, box))
return true;
var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
if (!Contains(perimeter, centre))
return false;
if (holes != null)
foreach (var hole in holes)
if (Contains(hole, centre))
return false;
return true;
}
private static bool RingEnters(Polygon ring, Box box)
{
var vertices = ring.Vertices;
var count = vertices.Count;
for (var i = 0; i < count; i++)
{
var next = vertices[(i + 1) % count];
if (SegmentEntersOpenBox(vertices[i], next, box))
return true;
}
return false;
}
/// <summary>Liang-Barsky clip against the open rectangle; a positive-length part of the
/// segment must lie strictly inside it.</summary>
private static bool SegmentEntersOpenBox(Vector p, Vector q, Box box)
{
var dx = q.X - p.X;
var dy = q.Y - p.Y;
var t0 = 0.0;
var t1 = 1.0;
return Clip(-dx, p.X - box.Left, ref t0, ref t1)
&& Clip(dx, box.Right - p.X, ref t0, ref t1)
&& Clip(-dy, p.Y - box.Bottom, ref t0, ref t1)
&& Clip(dy, box.Top - p.Y, ref t0, ref t1)
&& t0 < t1;
}
/// <summary>Restricts [t0, t1] to the parameters where <c>denominator * t &lt; numerator</c>.</summary>
private static bool Clip(double denominator, double numerator, ref double t0, ref double t1)
{
if (denominator == 0)
return numerator > 0;
var r = numerator / denominator;
if (denominator < 0)
{
if (r >= t1)
return false;
if (r > t0)
t0 = r;
}
else
{
if (r <= t0)
return false;
if (r < t1)
t1 = r;
}
return true;
}
/// <summary>Even-odd ray cast; exact for a point that is not on the ring (callers only
/// ask after ruling out any edge through the rectangle containing the point).</summary>
private static bool Contains(Polygon ring, Vector point)
{
var inside = false;
var vertices = ring.Vertices;
var count = vertices.Count;
for (int i = 0, j = count - 1; i < count; j = i++)
{
var a = vertices[i];
var b = vertices[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;
}
}
@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Geometry
{
/// <summary>
/// Smallest circle containing every point (Welzl's algorithm, expected linear time). Points are
/// visited in a fixed shuffled order, so the result is repeatable.
/// </summary>
public static class MinimumEnclosingCircle
{
public static Circle Compute(IEnumerable<Vector> points)
{
var shuffled = points.ToList();
if (shuffled.Count == 0)
throw new ArgumentException("At least one point is required.", nameof(points));
var random = new Random(0);
for (var i = shuffled.Count - 1; i > 0; i--)
{
var j = random.Next(i + 1);
(shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]);
}
var center = shuffled[0];
var radius = 0.0;
for (var i = 1; i < shuffled.Count; i++)
{
if (Contains(center, radius, shuffled[i]))
continue;
center = shuffled[i];
radius = 0;
for (var j = 0; j < i; j++)
{
if (Contains(center, radius, shuffled[j]))
continue;
center = (shuffled[i] + shuffled[j]) * 0.5;
radius = center.DistanceTo(shuffled[i]);
for (var k = 0; k < j; k++)
{
if (Contains(center, radius, shuffled[k]))
continue;
(center, radius) = Circumcircle(shuffled[i], shuffled[j], shuffled[k]);
}
}
}
// Rounding can leave a defining point a hair outside; grow to cover every point exactly.
radius = shuffled.Max(p => p.DistanceTo(center));
return new Circle(center, radius);
}
private static bool Contains(Vector center, double radius, Vector point) =>
point.DistanceTo(center) <= radius * (1 + 1e-12) + 1e-12;
private static (Vector Center, double Radius) Circumcircle(Vector a, Vector b, Vector c)
{
var bx = b.X - a.X;
var by = b.Y - a.Y;
var cx = c.X - a.X;
var cy = c.Y - a.Y;
var d = 2 * (bx * cy - by * cx);
if (System.Math.Abs(d) <= double.Epsilon)
{
// Collinear: the circle on the two farthest-apart points covers the third.
var pairs = new[] { (a, b), (a, c), (b, c) };
var (p, q) = pairs.OrderByDescending(pair => pair.Item1.DistanceTo(pair.Item2)).First();
var mid = (p + q) * 0.5;
return (mid, mid.DistanceTo(p));
}
var b2 = bx * bx + by * by;
var c2 = cx * cx + cy * cy;
var center = new Vector(a.X + (cy * b2 - by * c2) / d, a.Y + (bx * c2 - cx * b2) / d);
return (center, center.DistanceTo(a));
}
}
}
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry;
/// <summary>
/// Strict positive-length entry of an open native line path into polygon material.
/// Boundary-only contact is not entry; holes subtract material. This deliberately
/// does not infer material from a path's bounding box or close the open path.
/// Curved contours are flattened by the caller, which must handle chord uncertainty.
/// </summary>
internal static class OpenPathMaterial
{
public static bool Enters(Polygon outer, IReadOnlyList<Polygon> holes, IReadOnlyList<Line> path)
{
if (outer?.Vertices.Count < 4 || path == null)
return true; // Unresolved geometry is not a certificate of clear material.
foreach (var line in path)
{
var start = line.StartPoint;
var end = line.EndPoint;
var travel = end - start;
var lengthSquared = travel.DotProduct(travel);
if (!double.IsFinite(lengthSquared) || lengthSquared <= 0)
return true;
var cuts = new List<double> { 0, 1 };
CollectCuts(outer, start, travel, lengthSquared, cuts);
if (holes != null)
foreach (var hole in holes)
CollectCuts(hole, start, travel, lengthSquared, cuts);
cuts.Sort();
for (var i = 1; i < cuts.Count; i++)
{
if (cuts[i] <= cuts[i - 1])
continue;
var midpoint = start + travel * ((cuts[i - 1] + cuts[i]) / 2);
if (!StrictlyInside(outer, midpoint))
continue;
var inHole = false;
if (holes != null)
foreach (var hole in holes)
if (InsideOrOnBoundary(hole, midpoint))
{
inHole = true;
break;
}
if (!inHole)
return true;
}
}
return false;
}
private static void CollectCuts(Polygon ring, Vector start, Vector travel,
double lengthSquared, List<double> cuts)
{
if (ring?.Vertices.Count < 4)
{
// A degenerate hole cannot be subtracted reliably. The caller's
// unresolved-topology guard handles this case before this helper.
return;
}
var vertices = ring.Vertices;
for (var i = 1; i < vertices.Count; i++)
{
var a = vertices[i - 1];
var edge = vertices[i] - a;
var difference = a - start;
var denominator = Cross(travel, edge);
if (denominator == 0)
{
if (Cross(difference, travel) != 0)
continue;
AddParameter(difference.DotProduct(travel) / lengthSquared, cuts);
AddParameter((vertices[i] - start).DotProduct(travel) / lengthSquared, cuts);
continue;
}
var t = Cross(difference, edge) / denominator;
var u = Cross(difference, travel) / denominator;
if (u >= -1e-12 && u <= 1 + 1e-12)
AddParameter(t, cuts);
}
}
private static void AddParameter(double t, List<double> cuts)
{
if (double.IsFinite(t) && t >= -1e-12 && t <= 1 + 1e-12)
cuts.Add(System.Math.Clamp(t, 0, 1));
}
private static bool StrictlyInside(Polygon ring, Vector point) =>
Contains(ring, point, boundaryIsInside: false);
private static bool InsideOrOnBoundary(Polygon ring, Vector point) =>
Contains(ring, point, boundaryIsInside: true);
private static bool Contains(Polygon ring, Vector point, bool boundaryIsInside)
{
var vertices = ring.Vertices;
var inside = false;
for (var i = 1; i < vertices.Count; i++)
{
var a = vertices[i - 1];
var b = vertices[i];
var edge = b - a;
var relative = point - a;
if (Cross(edge, relative) == 0 &&
point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X) &&
point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y))
return boundaryIsInside;
if ((a.Y > point.Y) != (b.Y > point.Y) &&
point.X < a.X + (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y))
inside = !inside;
}
return inside;
}
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
}
+104 -18
View File
@@ -2,14 +2,74 @@ using System.Collections.Generic;
namespace OpenNest.Geometry
{
/// <summary>
/// Pole of inaccessibility: the interior point farthest from the outline and holes,
/// found by best-first quadtree refinement to within <c>precision</c>.
/// </summary>
public static class PolyLabel
{
public static Vector Find(
Polygon outer,
IList<Polygon> holes = null,
double precision = 0.5
) => Find(outer, holes, precision, int.MaxValue, out _);
/// <summary>
/// Bounded search for callers that cannot afford refinement along a long thin shape:
/// stops after about <paramref name="maxCells"/> distance evaluations and returns the best
/// cell centre so far. When the starting grid would exceed a quarter of the budget it is
/// coarsened so the whole shape is sampled first. The result can lie outside a shape
/// thinner than the remaining refinement, so callers must check it.
/// </summary>
public static Vector Find(Polygon outer, IList<Polygon> holes, double precision, int maxCells) =>
Find(outer, holes, precision, maxCells, out _);
/// <summary>
/// A precision proportional to the shape's mean material width (twice the net area over
/// the total outline length, holes included), so small or slender shapes refine further
/// than large solid ones. A fixed precision near the shape's size stops at the first
/// interior cell.
/// </summary>
public static double ThicknessPrecision(Polygon outer, IList<Polygon> holes = null, double fraction = 0.05)
{
var area = outer.Area();
var length = ClosedLength(outer);
if (holes != null)
{
for (var i = 0; i < holes.Count; i++)
{
area -= holes[i].Area();
length += ClosedLength(holes[i]);
}
}
if (!(area > 0) || !(length > 0))
return 1e-9;
return System.Math.Max(fraction * 2 * area / length, 1e-9);
}
private static double ClosedLength(Polygon polygon)
{
var verts = polygon.Vertices;
var sum = 0.0;
for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
sum += verts[j].DistanceTo(verts[i]);
return sum;
}
internal static Vector Find(
Polygon outer,
IList<Polygon> holes,
double precision,
int maxCells,
out int evaluatedCells
)
{
evaluatedCells = 0;
if (outer.Vertices.Count < 3)
return outer.Vertices.Count > 0 ? outer.Vertices[0] : new Vector();
@@ -38,29 +98,53 @@ namespace OpenNest.Geometry
if (cellSize == 0)
return new Vector((minX + maxX) / 2, (minY + maxY) / 2);
if (maxCells < int.MaxValue)
{
var gridLimit = System.Math.Max(1, maxCells / 4);
while (System.Math.Ceiling(width / cellSize) * System.Math.Ceiling(height / cellSize) > gridLimit)
cellSize *= 2;
}
var halfCell = cellSize / 2;
var queue = new List<Cell>();
var initial = new List<Cell>();
for (var x = minX; x < maxX; x += cellSize)
for (var y = minY; y < maxY; y += cellSize)
queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
{
for (var y = minY; y < maxY; y += cellSize)
{
initial.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
// Far from the origin a step smaller than the coordinate's precision
// no longer advances; the grid would never end.
if (y + cellSize == y)
break;
}
if (x + cellSize == x)
break;
}
queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
initial.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
var bestCell = GetCentroidCell(outer, holes);
evaluatedCells = initial.Count + 1;
for (var i = 0; i < queue.Count; i++)
if (queue[i].Dist > bestCell.Dist)
for (var i = 0; i < initial.Count; i++)
if (initial[i].Dist > bestCell.Dist)
{
bestCell = queue[i];
bestCell = initial[i];
break;
}
// Highest MaxDist first. Equal bounds pop newest first, and the initial cells in
// sorted order, exactly as the former sorted list did (insertion ahead of equals).
var queue = new PriorityQueue<Cell, (double, long)>();
for (var i = 0; i < initial.Count; i++)
queue.Enqueue(initial[i], (-initial[i].MaxDist, i - (long)initial.Count));
var sequence = (long)initial.Count;
while (queue.Count > 0)
{
var cell = queue[0];
queue.RemoveAt(0);
var cell = queue.Dequeue();
if (cell.Dist > bestCell.Dist)
bestCell = cell;
@@ -68,8 +152,17 @@ namespace OpenNest.Geometry
if (cell.MaxDist - bestCell.Dist <= precision)
continue;
if (evaluatedCells > maxCells - 4)
break;
halfCell = cell.HalfSize / 2;
// Far from the origin a half-step may no longer move the centre; such a cell
// cannot be split, and its identical children would multiply without end.
if (cell.X - halfCell == cell.X || cell.X + halfCell == cell.X
|| cell.Y - halfCell == cell.Y || cell.Y + halfCell == cell.Y)
continue;
var newCells = new[]
{
new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes),
@@ -77,25 +170,18 @@ namespace OpenNest.Geometry
new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes),
new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes),
};
evaluatedCells += newCells.Length;
for (var i = 0; i < newCells.Length; i++)
{
if (newCells[i].MaxDist > bestCell.Dist + precision)
InsertSorted(queue, newCells[i]);
queue.Enqueue(newCells[i], (-newCells[i].MaxDist, -++sequence));
}
}
return new Vector(bestCell.X, bestCell.Y);
}
private static void InsertSorted(List<Cell> list, Cell cell)
{
var idx = 0;
while (idx < list.Count && list[idx].MaxDist > cell.MaxDist)
idx++;
list.Insert(idx, cell);
}
private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes)
{
var area = 0.0;
@@ -97,5 +97,35 @@ public readonly struct PolygonAreaMoments
return true;
}
/// <summary>
/// Net moments of an outline minus nonoverlapping holes that lie inside it, all given as
/// positive moments (for example from <see cref="TryCompute"/>). Moments are taken about
/// the outline's centroid. Fails for an invalid input or a nonpositive or nonfinite result.
/// </summary>
public static bool TrySubtract(PolygonAreaMoments outline, IEnumerable<PolygonAreaMoments> holes,
out PolygonAreaMoments moments)
{
moments = default;
if (holes == null || !double.IsFinite(outline.Area) || outline.Area <= 0 || !IsFinite(outline.Centroid))
return false;
var area = outline.Area;
var firstMoment = Vector.Zero;
foreach (var hole in holes)
{
if (!double.IsFinite(hole.Area) || hole.Area <= 0 || !IsFinite(hole.Centroid))
return false;
firstMoment -= (hole.Centroid - outline.Centroid) * hole.Area;
area -= hole.Area;
}
if (!double.IsFinite(area) || area <= 0 || !IsFinite(firstMoment))
return false;
var centroid = outline.Centroid + firstMoment / area;
if (!IsFinite(centroid))
return false;
moments = new PolygonAreaMoments(area, centroid);
return true;
}
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
}
+9 -2
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Geometry
@@ -76,8 +76,15 @@ namespace OpenNest.Geometry
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
{
var cloned = entities.CloneAll();
// Break cuts are open and their direction says which side is scrap: never re-chain or
// re-wind them. They follow the contours, in their original order.
var breakCuts = cloned.Where(e => e.Layer == SpecialLayers.Break).ToList();
if (breakCuts.Count > 0)
cloned.RemoveAll(e => e.Layer == SpecialLayers.Break);
var profile = new ShapeProfile(cloned);
return profile.ToNormalizedEntities();
var result = profile.ToNormalizedEntities();
result.AddRange(breakCuts);
return result;
}
private static void EnsureWinding(Shape shape, RotationType desired)
+105
View File
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
/// <summary>
/// A part's material as world-space polygons for part-spacing checks: the perimeter and its
/// cutouts, optionally grown by a clearance (perimeter offset outward, cutouts inward, in one
/// Clipper region offset). Auto Nest's layout check and the pre-post review both judge
/// spacing with this outline and <see cref="InflationFor"/>, so they cannot disagree.
/// </summary>
public sealed class SpacingOutline
{
/// <summary>Arc chord tolerance of the outline. Perimeter arcs are circumscribed and
/// cutout arcs inscribed, so flattening never hides material.</summary>
public const double ChordTolerance = 0.001;
/// <summary>How far under the part spacing a gap may fall and still pass. Layouts placed
/// exactly at the spacing land up to ~1e-4 short once rotated, rounded coordinates
/// (e.g. PEP's 4-decimal exports) meet the Clipper grid; 0.0005 covers that with margin
/// and is far below anything a cutting machine can resolve.</summary>
public const double SpacingSlack = 0.0005;
private bool prepared;
private TriangulatedRegion triangles;
public SpacingOutline(Polygon perimeter, List<Polygon> holes)
{
Perimeter = perimeter ?? throw new ArgumentNullException(nameof(perimeter));
Holes = holes ?? new List<Polygon>();
}
public Polygon Perimeter { get; }
public List<Polygon> Holes { get; }
/// <summary>Prepared on the first overlap test; null preparation is cached too.</summary>
public TriangulatedRegion Triangles
{
get
{
if (!prepared)
{
triangles = TriangulatedRegion.Build(Perimeter, Holes);
prepared = true;
}
return triangles;
}
}
/// <summary>Inflation that enforces <paramref name="spacing"/> less <see cref="SpacingSlack"/>.
/// Inflating one part of a pair by it and testing against the other's raw outline covers
/// both sides: a part inside the other's (shrunk) cutout, or the inflated outline inside
/// the other's raw cutout.</summary>
public static double InflationFor(double spacing) =>
System.Math.Max(0, spacing - SpacingSlack);
/// <summary>
/// Outlines <paramref name="profile"/> (already rotated) at <paramref name="location"/>,
/// grown by <paramref name="inflateBy"/>. A cutout that closes up under the offset is
/// dropped, which treats it as solid: conservative, since it has no room for another part
/// at the required spacing anyway. Null when the offset leaves no perimeter.
/// </summary>
public static SpacingOutline Build(ShapeProfile profile, Vector location, double inflateBy)
{
ArgumentNullException.ThrowIfNull(profile);
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 segments per
// arc): arc-heavy real parts otherwise produce thousands of vertices.
var region = ClipperBridge.OffsetForValidation(
profile,
inflateBy > Tolerance.Epsilon ? inflateBy : 0,
ChordTolerance
);
var perimeter = region.LargestOuter();
if (perimeter == null)
return null;
ToWorld(perimeter, location);
foreach (var hole in region.Holes)
ToWorld(hole, location);
return new SpacingOutline(perimeter, region.Holes);
}
/// <summary>True when the two outlines share material (holes subtracted).</summary>
public bool Overlaps(SpacingOutline other)
{
ArgumentNullException.ThrowIfNull(other);
var at = Triangles;
var bt = other.Triangles;
// Keeping translations at zero preserves the reference's floating-point operation order.
return (at != null && bt != null ? at.Overlaps(bt, 0, 0, 0, 0) : null)
?? Collision.HasOverlap(Perimeter, other.Perimeter, Holes, other.Holes);
}
private static void ToWorld(Polygon polygon, Vector location)
{
polygon.Offset(location);
polygon.UpdateBounds();
}
}
}
+67 -35
View File
@@ -264,6 +264,9 @@ namespace OpenNest.Geometry
[ThreadStatic]
private static double[]? s_tmpB;
[ThreadStatic]
private static double[]? s_tmpC;
private static double[] AcquireBuffer()
{
var pool = s_pool ??= new List<double[]>();
@@ -282,11 +285,12 @@ namespace OpenNest.Geometry
pool.Add(buf);
}
private static (double[] Tmp, double[] Inside) ScratchPair()
private static (double[] Tmp, double[] Inside, double[] Spare) Scratch()
{
s_tmpA ??= new double[MaxClipVertices * 2];
s_tmpB ??= new double[MaxClipVertices * 2];
return (s_tmpA, s_tmpB);
s_tmpC ??= new double[MaxClipVertices * 2];
return (s_tmpA, s_tmpB, s_tmpC);
}
private (bool hasHoles, bool undecided, bool survived) SubtractAllHoles(
@@ -309,9 +313,21 @@ namespace OpenNest.Geometry
return (false, false, false);
// pieces[0] is the caller's own buffer - never release it back to the pool.
var pieces = new List<(double[] Buf, int Count)> { (piece, count) };
// A piece's buffer never changes once listed, so its bbox is measured once.
var pieces = new List<Piece> { Measure(piece, count) };
var spareList = new List<Piece>();
var owned = new HashSet<double[]>();
// Built-in uses <=: touching skips subtraction, and a hole triangle no piece
// reaches passes every piece on unchanged, so it is skipped without rebuilding.
bool AnyReaches(double hMinX, double hMinY, double hMaxX, double hMaxY)
{
foreach (var p in pieces)
if (!(p.MaxX <= hMinX || hMaxX <= p.MinX || p.MaxY <= hMinY || hMaxY <= p.MinY))
return true;
return false;
}
bool SubtractOwner(TriangulatedRegion owner, double odx, double ody)
{
if (owner.Holes == null)
@@ -321,41 +337,32 @@ namespace OpenNest.Geometry
var hole = owner.Holes[h];
if (hole == null)
continue; // untriangulatable hole: nothing to subtract
// Every triangle's bbox lies within the hole's, translated alike.
if (!AnyReaches(hole.MinX + odx, hole.MinY + ody, hole.MaxX + odx, hole.MaxY + ody))
continue;
for (var t = 0; t < hole._ia.Length && pieces.Count > 0; t++)
{
var hMinX = hole._tMinX[t] + odx;
var hMaxX = hole._tMaxX[t] + odx;
var hMinY = hole._tMinY[t] + ody;
var hMaxY = hole._tMaxY[t] + ody;
if (!AnyReaches(hMinX, hMinY, hMaxX, hMaxY))
continue;
var next = new List<(double[], int)>();
var next = spareList;
next.Clear();
for (var p = 0; p < pieces.Count; p++)
{
var (buf, pc) = pieces[p];
var current = pieces[p];
var buf = current.Buf;
var pc = current.Count;
// Piece bbox (built-in uses <=: touching skips subtraction).
var pMinX = double.MaxValue;
var pMinY = double.MaxValue;
var pMaxX = double.MinValue;
var pMaxY = double.MinValue;
for (var v = 0; v < pc; v++)
{
var px = buf[v * 2];
var py = buf[v * 2 + 1];
if (px < pMinX)
pMinX = px;
if (px > pMaxX)
pMaxX = px;
if (py < pMinY)
pMinY = py;
if (py > pMaxY)
pMaxY = py;
}
if (pMaxX <= hMinX || hMaxX <= pMinX || pMaxY <= hMinY || hMaxY <= pMinY)
if (current.MaxX <= hMinX || hMaxX <= current.MinX
|| current.MaxY <= hMinY || hMaxY <= current.MinY)
{
if (next.Count >= MaxPieces)
return false;
next.Add((buf, pc));
next.Add(current);
continue;
}
@@ -363,11 +370,11 @@ namespace OpenNest.Geometry
// outside of each edge survives as its own piece; the inside
// remainder continues into the next edge. The remainder inside
// all three edges is consumed (the hole ate it).
var rem = AcquireBuffer();
owned.Add(rem);
Array.Copy(buf, rem, pc * 2);
// The remainder reads the piece itself first, then alternates
// between two scratch buffers; it is never kept.
var rem = buf;
var remCount = pc;
var (tmp, insideBuf) = ScratchPair();
var (tmp, insideBuf, spareBuf) = Scratch();
for (var e = 0; e < 3 && remCount >= 3; e++)
{
var ei = e == 0 ? hole._ia[t] : e == 1 ? hole._ib[t] : hole._ic[t];
@@ -388,20 +395,20 @@ namespace OpenNest.Geometry
var keep = AcquireBuffer();
owned.Add(keep);
Array.Copy(tmp, keep, outCount * 2);
next.Add((keep, outCount));
next.Add(Measure(keep, outCount));
}
remCount =
ClipHalfSpace(rem, remCount, sx, sy, ex, ey, true, insideBuf);
if (remCount >= MaxClipVertices)
return false; // undecided
Array.Copy(insideBuf, rem, remCount * 2);
rem = insideBuf;
(insideBuf, spareBuf) = (spareBuf, insideBuf);
}
// The inside-all-edges remainder is consumed by the hole: drop it.
owned.Remove(rem);
ReleaseBuffer(rem);
if (owned.Remove(buf))
ReleaseBuffer(buf);
}
spareList = pieces;
pieces = next;
}
}
@@ -413,8 +420,8 @@ namespace OpenNest.Geometry
if (!SubtractOwner(this, adx, ady) || !SubtractOwner(other, bdx, bdy))
return (true, true, false);
foreach (var (buf, pc) in pieces)
if (pc >= 3 && TwiceArea(buf, pc) > areaFloor)
foreach (var p in pieces)
if (p.Count >= 3 && TwiceArea(p.Buf, p.Count) > areaFloor)
return (true, false, true);
return (true, false, false);
}
@@ -425,6 +432,31 @@ namespace OpenNest.Geometry
}
}
private readonly record struct Piece(double[] Buf, int Count, double MinX, double MinY,
double MaxX, double MaxY);
private static Piece Measure(double[] buf, int count)
{
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
for (var v = 0; v < count; v++)
{
var px = buf[v * 2];
var py = buf[v * 2 + 1];
if (px < minX)
minX = px;
if (px > maxX)
maxX = px;
if (py < minY)
minY = py;
if (py > maxY)
maxY = py;
}
return new Piece(buf, count, minX, minY, maxX, maxY);
}
/// <summary>Clip this' triangle against other's triangle; returns count into piece.</summary>
private int ClipTriangle(
int ta,
+20
View File
@@ -0,0 +1,20 @@
namespace OpenNest;
/// <summary>
/// A machine's motion limits for cut-time estimates, in the units of the
/// <see cref="CutParameters"/> it is attached to (inches or millimeters, per second squared).
/// </summary>
public class MotionProfile
{
/// <summary>Acceleration while cutting or scribing, and the centripetal limit on arcs.</summary>
public double CutAcceleration { get; set; }
/// <summary>Acceleration for head-up travel.</summary>
public double RapidAcceleration { get; set; }
/// <summary>
/// Junction deviation: how far the path may round a corner, which sets how fast the head
/// may take it. Zero stops at every corner.
/// </summary>
public double CornerDeviation { get; set; }
}
+25 -1
View File
@@ -65,6 +65,21 @@ namespace OpenNest
return plate;
}
/// <summary>
/// Makes <paramref name="rail"/> this nest's machine clamp rail: new plates follow it
/// (<see cref="PlateSettings.ClampRail"/>) and every existing plate re-resolves its
/// zones from it. Null, or a rail without clamps, removes the rail and every plate's
/// zones; the rail is nest-wide, so per-plate zones are replaced either way.
/// </summary>
public void SetClampRail(ClampRail rail)
{
if (rail != null && rail.Clamps.Count == 0)
rail = null;
PlateDefaults.ClampRail = rail;
foreach (var plate in Plates)
plate.SetClampRail(rail);
}
public void UpdateDrawingQuantities()
{
foreach (var drawing in Drawings)
@@ -114,6 +129,12 @@ namespace OpenNest
set { plate.PartSpacing = value; }
}
/// <summary>
/// The machine clamp rail new plates follow, or null for none. Set through
/// <see cref="Nest.SetClampRail"/> to also update existing plates.
/// </summary>
public ClampRail ClampRail { get; set; }
public void SetFromExisting(Plate plate)
{
Quadrant = plate.Quadrant;
@@ -124,7 +145,7 @@ namespace OpenNest
public Plate CreateNew()
{
return new Plate()
var created = new Plate()
{
Size = Size,
EdgeSpacing = EdgeSpacing,
@@ -132,6 +153,9 @@ namespace OpenNest
Quadrant = Quadrant,
Quantity = 1,
};
if (ClampRail != null)
created.SetClampRail(ClampRail);
return created;
}
}
}
-1
View File
@@ -10,6 +10,5 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Clipper2" Version="2.0.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
</ItemGroup>
</Project>
+12 -1
View File
@@ -64,6 +64,15 @@ namespace OpenNest
public bool LeadInsLocked { get; set; }
/// <summary>
/// True when this placement ignores its drawing's cut pins and is cut with the nest's
/// settings instead. Saved with the nest; never changes the drawing or its library.
/// </summary>
public bool DrawingPinsOff { get; set; }
/// <summary>True when the drawing has cut pins and this placement has not switched them off.</summary>
public bool HasActiveDrawingPins => !DrawingPinsOff && BaseDrawing?.CutPins is { Count: > 0 };
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
public void ApplyLeadIns(
@@ -343,7 +352,7 @@ namespace OpenNest
PerfCounters.CountOverlapPolygonPreparation();
return ConvertProgram
.ToGeometry(program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ForNesting()
.ToList();
}
@@ -426,11 +435,13 @@ namespace OpenNest
/// Copies the lead-in state that goes with a copied program. Without it a copy of a
/// lead-in part reads its rotation from the rebuilt program (zero), and lead-in
/// assignment does not know to strip the copied lead-ins before adding new ones.
/// A copy of a placement with drawing pins off keeps them off.
/// </summary>
private void CopyLeadInStateFrom(Part source)
{
HasManualLeadIns = source.HasManualLeadIns;
LeadInsLocked = source.LeadInsLocked;
DrawingPinsOff = source.DrawingPinsOff;
CuttingParameters = source.CuttingParameters;
preLeadInRotation = source.preLeadInRotation;
}
+6 -6
View File
@@ -11,7 +11,7 @@ namespace OpenNest
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(
entities.Where(e => SpecialLayers.IsMaterial(e.Layer))
entities.ForNesting()
);
var lines = new List<Line>();
@@ -57,7 +57,7 @@ namespace OpenNest
PerfCounters.CountOffsetPerimeterEntities();
var geoEntities = ConvertProgram.ToGeometry(program);
var profile = new ShapeProfile(
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
geoEntities.ForNesting().ToList()
);
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
@@ -75,7 +75,7 @@ namespace OpenNest
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
geoEntities.ForNesting().ToList()
);
var entities = new List<Entity>();
@@ -108,7 +108,7 @@ namespace OpenNest
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
geoEntities.ForNesting().ToList()
);
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
@@ -122,7 +122,7 @@ namespace OpenNest
{
var geoEntities = ConvertProgram.ToGeometry(part.Program);
var profile = new ShapeProfile(
geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList()
geoEntities.ForNesting().ToList()
);
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
@@ -167,7 +167,7 @@ namespace OpenNest
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(
entities.Where(e => SpecialLayers.IsMaterial(e.Layer))
entities.ForNesting()
);
var lines = new List<Line>();
+220 -44
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using OpenNest.Collections;
using OpenNest.Geometry;
@@ -55,6 +56,10 @@ namespace OpenNest
Parts.ItemAdded += Parts_PartAdded;
Parts.ItemRemoved += Parts_PartRemoved;
CutOffs = new ObservableList<CutOff>();
ClampZones = new ObservableList<ClampZone>();
ClampZones.ItemAdded += (_, _) => OnClampZonesChanged();
ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged();
ClampZones.ItemChanged += (_, _) => OnClampZonesChanged();
Quadrant = 1;
}
@@ -81,9 +86,20 @@ namespace OpenNest
public Spacing EdgeSpacing;
/// <summary>
/// The size of the plate.
/// The size of the plate. A plate following a <see cref="ClampRail"/> re-resolves
/// its clamp zones for the new size.
/// </summary>
public Size Size { get; set; }
public Size Size
{
get { return size; }
set
{
size = value;
ResolveClampRail();
}
}
private Size size;
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
@@ -102,6 +118,145 @@ namespace OpenNest
/// </summary>
public ObservableList<CutOff> CutOffs { get; set; }
/// <summary>
/// Fixed rectangular machine-clamp keep-outs in plate coordinates
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
/// cut sequence and CNC output; placement rules and display consume them. Mutate
/// through this list; <see cref="ClampZonesChanged"/> fires for every change.
/// </summary>
public ObservableList<ClampZone> ClampZones { get; }
/// <summary>
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
/// the zone overlay. Carries no geometry arguments.
/// </summary>
public event EventHandler? ClampZonesChanged;
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
/// <summary>
/// The machine clamp rail this plate follows, or null when its
/// <see cref="ClampZones"/> are explicit (engine sheets, files without a rail). While
/// set, the zones are derived: re-resolved whenever <see cref="Size"/> or
/// <see cref="Quadrant"/> changes, because the clamps are fixed to the machine.
/// </summary>
public ClampRail ClampRail { get; private set; }
private static readonly IReadOnlySet<string> NoRemovedClamps = ImmutableHashSet<string>.Empty;
/// <summary>
/// Rail clamp ids taken off this sheet, by Auto Nest as a last resort (D8) so a part
/// that could not be nested anywhere with every clamp in place still fits, or by hand
/// (<see cref="SetClampRemoved"/>). Empty unless
/// <see cref="ClampRail"/> is set; <see cref="ClampZones"/> already excludes them, so
/// nothing else needs to consult this list to avoid them. Ids stay listed across a
/// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it
/// re-appears removed if a later resize brings it back onto the sheet (C13).
/// </summary>
public IReadOnlySet<string> RemovedClamps { get; private set; } = NoRemovedClamps;
/// <summary>
/// Minutes this sheet's cut-time estimate gains from <see cref="RemovedClamps"/> (D9):
/// each listed clamp the rail places on this sheet adds
/// <see cref="OpenNest.ClampRail.RemovalMinutes"/> once; a listed clamp that is beyond
/// the sheet (or unknown to the rail) is not pulled and costs nothing. Zero when there
/// is no rail (removal is meaningless without one to remove clamps from).
/// </summary>
public double ClampRemovalMinutes =>
ClampRail == null || RemovedClamps.Count == 0
? 0
: ClampRail.ResolveFor(Size, Quadrant).Count(zone => RemovedClamps.Contains(zone.Id))
* ClampRail.RemovalMinutes;
/// <summary>
/// Follows <paramref name="rail"/> and replaces the zones with its resolution for
/// this sheet; null detaches and removes every zone. Unchanged zones raise no events.
/// </summary>
public void SetClampRail(ClampRail rail) => SetClampRail(rail, null);
/// <summary>
/// Follows <paramref name="rail"/> with the clamps named in <paramref name="removedClamps"/>
/// taken off this sheet (D8): zones become the rail's resolution for this sheet minus
/// those ids. Null <paramref name="rail"/> detaches, clears every zone and forgets the
/// removed set (removal only means something while following a rail).
/// </summary>
public void SetClampRail(ClampRail rail, IEnumerable<string> removedClamps)
{
ClampRail = rail;
RemovedClamps = rail == null || removedClamps == null
? NoRemovedClamps
: removedClamps.ToImmutableHashSet(StringComparer.Ordinal);
ReplaceClampZones(ResolveRailZones());
}
/// <summary>
/// The rail clamp this sheet places at <paramref name="point"/> (the parts' placement
/// frame, see <see cref="ClampZoneFrame"/>), whether in place or removed, grown by
/// <paramref name="tolerance"/> on every side; null without a rail or a clamp there.
/// Returned bounds are in the placement frame.
/// </summary>
public ClampZone RailClampAt(Vector point, double tolerance = 0)
{
if (ClampRail == null)
return null;
var left = Quadrant is 2 or 3 ? -Size.Length : 0;
var bottom = Quadrant is 3 or 4 ? -Size.Width : 0;
foreach (var zone in ClampRail.ResolveFor(Size, Quadrant))
{
var box = zone.Bounds.Translate(left, bottom);
if (box.Offset(tolerance).Contains(point))
return new ClampZone(zone.Id, box);
}
return null;
}
/// <summary>
/// Takes the rail clamp <paramref name="id"/> off this sheet, or puts it back, as a
/// manual counterpart to Auto Nest's last-resort removal (D8): the zones and
/// <see cref="ClampRemovalMinutes"/> follow. False when nothing changed: no rail, a clamp
/// the rail does not place on this sheet, or one already in the requested state.
/// </summary>
public bool SetClampRemoved(string id, bool removed)
{
if (ClampRail == null || id == null || RemovedClamps.Contains(id) == removed)
return false;
if (removed && !ClampRail.ResolveFor(Size, Quadrant).Any(zone => zone.Id == id))
return false;
var ids = removed ? RemovedClamps.Append(id) : RemovedClamps.Where(x => x != id);
SetClampRail(ClampRail, ids);
return true;
}
private void ResolveClampRail()
{
// Called from the constructor's Size assignment before the zone list exists.
if (ClampRail != null)
ReplaceClampZones(ResolveRailZones());
}
private IReadOnlyList<ClampZone> ResolveRailZones()
{
if (ClampRail == null)
return Array.Empty<ClampZone>();
var resolved = ClampRail.ResolveFor(Size, Quadrant);
if (RemovedClamps.Count == 0)
return resolved;
var kept = new List<ClampZone>(resolved.Count);
foreach (var zone in resolved)
if (!RemovedClamps.Contains(zone.Id))
kept.Add(zone);
return kept;
}
private void ReplaceClampZones(IReadOnlyList<ClampZone> zones)
{
if (ClampRail.SameZones(ClampZones, zones))
return;
ClampZones.Clear();
foreach (var zone in zones)
ClampZones.Add(zone);
}
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
@@ -175,52 +330,61 @@ namespace OpenNest
if (part.BaseDrawing.IsCutOff)
continue;
Geometry.Entity perimeter = null;
try
{
var entities = Converters
.ConvertProgram.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
if (entities.Count > 0)
{
// Leads are cutting paths in scrap, not boundary edges. Chaining them
// into the outline can make a closed part appear open and replace its
// recesses with a convex hull. Keep the full geometry for the existing
// conservative fallback when the material contour really is open.
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
e.Layer != SpecialLayers.Leadout).ToList();
var profile = new Geometry.ShapeProfile(outline);
if (profile.Perimeter.IsClosed())
{
perimeter = profile.Perimeter;
perimeter.Offset(part.Location);
}
else
{
var points = entities.CollectPoints();
if (points.Count >= 3)
{
var hull = Geometry.ConvexHull.Compute(points);
hull.Offset(part.Location);
perimeter = hull;
}
}
}
}
catch
{
perimeter = null;
}
cache[part] = perimeter;
cache[part] = BuildPartPerimeter(part);
}
return cache;
}
/// <summary>
/// Converts one part's own program into its placed perimeter entity (closed
/// profile, or a convex hull fallback for a genuinely open contour). This is
/// the same conversion <see cref="BuildPerimeterCache"/> runs per part; callers
/// without a pre-built cache (e.g. a direct, uncached <see cref="CutOff.Regenerate"/>
/// call) use it to get the same conservative, converted-material envelope instead
/// of trusting the part's cached bounding box alone.
/// </summary>
public static Geometry.Entity BuildPartPerimeter(Part part)
{
try
{
var entities = Converters
.ConvertProgram.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
if (entities.Count == 0)
return null;
// Leads are cutting paths in scrap, not boundary edges. Chaining them
// into the outline can make a closed part appear open and replace its
// recesses with a convex hull. Keep the full geometry for the existing
// conservative fallback when the material contour really is open.
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
e.Layer != SpecialLayers.Leadout).ToList();
var profile = new Geometry.ShapeProfile(outline);
if (profile.Perimeter.IsClosed())
{
var perimeter = profile.Perimeter;
perimeter.Offset(part.Location);
return perimeter;
}
var points = entities.CollectPoints();
if (points.Count < 3)
return null;
var hull = Geometry.ConvexHull.Compute(points);
hull.Offset(part.Location);
return hull;
}
catch
{
return null;
}
}
/// <summary>
/// The number of times to cut the plate.
/// </summary>
@@ -236,7 +400,11 @@ namespace OpenNest
public int Quadrant
{
get { return quadrant; }
set { quadrant = value <= 4 && value > 0 ? value : 1; }
set
{
quadrant = value <= 4 && value > 0 ? value : 1;
ResolveClampRail();
}
}
/// <summary>
@@ -542,6 +710,14 @@ namespace OpenNest
return box;
}
/// <summary>
/// The offset that centers the parts' combined bounds in the <see cref="WorkArea"/>,
/// so uneven edge spacing (such as a wider clamp-side margin) shifts them with it.
/// Zero when the plate has no parts.
/// </summary>
public Vector OffsetToCenterPartsInWorkArea() =>
Parts.Count == 0 ? new Vector() : WorkArea().Center - Parts.GetBoundingBox().Center;
/// <summary>
/// Automatically sizes the plate to fit the parts.
/// </summary>
+12 -5
View File
@@ -48,17 +48,24 @@ namespace OpenNest
return plate != null && plate.Parts.Count == 0;
}
/// <summary>Header text for the plate at <paramref name="storageIndex"/>.</summary>
public static string FormatHeader(IList<Plate> plates, int storageIndex, string plateSizeText)
/// <summary>
/// Header text for the plate at <paramref name="storageIndex"/>. A quantity above one is
/// appended as "×N"; a single sheet shows no quantity.
/// </summary>
public static string FormatHeader(IList<Plate> plates, int storageIndex, string plateSizeText, int quantity = 1)
{
var displayed = DisplayedPlateNumber(plates, storageIndex);
if (displayed == null)
return IsTrailingSentinel(plates, storageIndex)
? "New plate (empty)"
: "No plates";
return string.IsNullOrEmpty(plateSizeText)
? string.Format("Plate {0} of {1}", displayed.Value, DisplayedPlateCount(plates))
: string.Format("Plate {0} of {1} | {2}", displayed.Value, DisplayedPlateCount(plates), plateSizeText);
var text = string.Format("Plate {0} of {1}", displayed.Value, DisplayedPlateCount(plates));
if (!string.IsNullOrEmpty(plateSizeText))
text += " | " + plateSizeText;
if (quantity > 1)
text += " | ×" + quantity;
return text;
}
}
}
+92
View File
@@ -119,6 +119,50 @@ namespace OpenNest
SubscribeToTailPlates();
}
/// <summary>
/// Applies a nest-defaults edit, moving only an untouched trailing empty sheet
/// that matched the old defaults to the new defaults.
/// </summary>
public void ChangePlateDefaults(Action changeDefaults)
{
ArgumentNullException.ThrowIfNull(changeDefaults);
var defaults = nest.PlateDefaults;
var trailing = Count > 0 ? nest.Plates[^1] : null;
// Exact configuration equality, not geometric tolerance: even a small edit
// makes this configured stock rather than the untouched workspace sentinel.
var followsDefaults = trailing != null
&& trailing.Parts.Count == 0
&& trailing.CutOffs.Count == 0
&& trailing.Size.Width == defaults.Size.Width
&& trailing.Size.Length == defaults.Size.Length
&& trailing.Quadrant == defaults.Quadrant
&& trailing.EdgeSpacing.Left == defaults.EdgeSpacing.Left
&& trailing.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom
&& trailing.EdgeSpacing.Right == defaults.EdgeSpacing.Right
&& trailing.EdgeSpacing.Top == defaults.EdgeSpacing.Top
&& trailing.PartSpacing == defaults.PartSpacing
&& trailing.RemovedClamps.Count == 0
&& Equals(trailing.ClampRail, defaults.ClampRail)
&& (defaults.ClampRail == null
? trailing.ClampZones.Count == 0
: defaults.ClampRail.Matches(trailing));
changeDefaults();
if (!followsDefaults)
return;
defaults = nest.PlateDefaults;
trailing.Size = defaults.Size;
trailing.Quadrant = defaults.Quadrant;
trailing.EdgeSpacing = defaults.EdgeSpacing;
trailing.PartSpacing = defaults.PartSpacing;
trailing.SetClampRail(defaults.ClampRail);
PlateListChanged?.Invoke(this, EventArgs.Empty);
if (ReferenceEquals(CurrentPlate, trailing))
FireCurrentPlateChanged();
}
public void BeginBatch()
{
batching = true;
@@ -151,6 +195,54 @@ namespace OpenNest
nest.Plates.RemoveAt(CurrentIndex);
}
/// <summary>
/// Whether <see cref="ClearAll"/> would change anything: more than one plate, or the
/// remaining plate still holds parts or cut-off definitions.
/// </summary>
public bool CanClearAll
{
get
{
if (Count == 0)
return false;
var remaining = nest.Plates[0];
return Count > 1
|| remaining.Parts.Count > 0
|| remaining.CutOffs.Count > 0
|| remaining.Quantity > 1;
}
}
/// <summary>
/// Clears the nest: removes every plate after the first and empties the remaining
/// plate (parts and cut-off definitions), resetting its repeat quantity. Nest metadata
/// and plate defaults are preserved; the first plate's configuration stays as the
/// workspace sentinel — the same result as removing every other plate one at a time.
/// The plate count never reaches zero, so observers never see a null current plate.
/// </summary>
public void ClearAll()
{
if (Count == 0)
return;
BeginBatch();
try
{
while (nest.Plates.Count > 1)
nest.Plates.RemoveAt(nest.Plates.Count - 1);
var remaining = nest.Plates[0];
remaining.CutOffs.Clear();
remaining.Parts.Clear();
remaining.Quantity = 1;
}
finally
{
EndBatch();
}
}
private void SubscribeToTailPlates()
{
UnsubscribeFromTailPlates();
@@ -0,0 +1,29 @@
using System;
using System.Linq;
namespace OpenNest.Sequencing
{
/// <summary>Moves one existing part to a one-based cutting sequence position.</summary>
public static class ManualPartSequencing
{
public static bool Move(Plate plate, Part part, int sequenceNumber)
{
ArgumentNullException.ThrowIfNull(plate);
ArgumentNullException.ThrowIfNull(part);
var order = plate.Parts.ToList();
if (sequenceNumber < 1 || sequenceNumber > order.Count)
return false;
var oldIndex = order.FindIndex(p => ReferenceEquals(p, part));
if (oldIndex < 0)
return false;
if (oldIndex == sequenceNumber - 1)
return true;
order.RemoveAt(oldIndex);
order.Insert(sequenceNumber - 1, part);
plate.Parts.Reorder(order);
return true;
}
}
}
+10 -3
View File
@@ -1,4 +1,4 @@
using System.Drawing;
using System.Drawing;
using OpenNest.Geometry;
namespace OpenNest
@@ -19,11 +19,18 @@ namespace OpenNest
public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta };
public static readonly Layer Break = new Layer("BREAK") { Color = Color.Orange };
/// <summary>A tabbed cutout's uncut tabs: material boundary that is never cut.</summary>
public static readonly Layer Tab = new Layer("TAB") { Color = Color.Orange };
/// <summary>
/// True when an entity converted from a part program describes part material. Rapids
/// and scribe/etch marks are excluded: marks are only on the surface, so they never
/// bound material and must not affect nesting, collision, area, or validation.
/// bound material and must not affect nesting, collision, area, or validation. Break
/// cuts are excluded too: they run through material that stays attached at tabs. Tabs are
/// material: they close a tabbed cutout's boundary.
/// </summary>
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe;
public static bool IsMaterial(Layer layer) => layer != Rapid && layer != Scribe && layer != Break;
}
}
+13 -9
View File
@@ -128,10 +128,14 @@ public static class DrawingSplitter
var pgm = ConvertGeometry.ToProgram(entities);
var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm);
piece.Color = source.Color;
piece.HasCustomColor = source.HasCustomColor;
piece.Priority = source.Priority;
piece.CutFirst = source.CutFirst;
piece.Material = source.Material;
piece.Constraints = source.Constraints;
piece.Customer = source.Customer;
// A piece is new geometry, not the library revision the source was copied from.
piece.Library = null;
piece.Source = source.Source;
piece.Quantity.Required = source.Quantity.Required;
@@ -269,15 +273,15 @@ public static class DrawingSplitter
var regions = new List<Box>();
for (var yi = 0; yi < yEdges.Count - 1; yi++)
for (var xi = 0; xi < xEdges.Count - 1; xi++)
regions.Add(
new Box(
xEdges[xi],
yEdges[yi],
xEdges[xi + 1] - xEdges[xi],
yEdges[yi + 1] - yEdges[yi]
)
);
for (var xi = 0; xi < xEdges.Count - 1; xi++)
regions.Add(
new Box(
xEdges[xi],
yEdges[yi],
xEdges[xi + 1] - xEdges[xi],
yEdges[yi + 1] - yEdges[yi]
)
);
return regions;
}
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest
{
public static class NestingMaterial
{
/// <summary>
/// The material entities nesting and layout validation place parts against: those
/// <see cref="SpecialLayers.IsMaterial"/> keeps, except that a tabbed cutout's slug stays
/// attached while the sheet is cut, so a closed hole with <see cref="SpecialLayers.Tab"/>
/// spans is dropped and its slug counts as part. Area, overlap diagnostics and cutting
/// keep the hole. Entities without tab spans pass through unchanged.
/// </summary>
public static List<Entity> ForNesting(this IEnumerable<Entity> entities)
{
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
if (!material.Exists(e => e.Layer == SpecialLayers.Tab))
return material;
var kept = new List<Entity>();
foreach (var shape in ShapeBuilder.GetShapes(material))
if (!shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab))
kept.AddRange(shape.Entities);
return kept;
}
}
}
@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
namespace OpenNest.TabbedCutouts
{
/// <summary>
/// A cutout (a closed hole in a part) whose slug stays attached by uncut tabs. It is identified
/// by the source entities of the hole's contour. Building the drawing cuts the contour as open
/// pieces between the tabs, each entered from the slug with no lead-out, and keeps the tabs on
/// the <see cref="SpecialLayers.Tab"/> layer so the hole still bounds material. All values are
/// in the source frame.
/// </summary>
public sealed class TabbedCutout
{
public const double DefaultTabWidth = 0.0625;
public const int DefaultTabCount = 2;
public const int MinTabCount = 1;
/// <summary>Source entity IDs of the hole's closed contour.</summary>
public List<Guid> EdgeIds { get; set; } = new();
/// <summary>Uncut length of each tab along the contour.</summary>
public double TabWidth { get; set; } = DefaultTabWidth;
/// <summary>
/// Tab centers as distances along the contour, counterclockwise from its lowest-left
/// vertex (the smallest X, then Y), ascending. Empty means <see cref="DefaultTabCount"/>
/// evenly spaced.
/// </summary>
public List<double> TabCenters { get; set; } = new();
public TabbedCutout Clone() =>
new()
{
EdgeIds = new List<Guid>(EdgeIds),
TabWidth = TabWidth,
TabCenters = new List<double>(TabCenters),
};
}
}
@@ -0,0 +1,279 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Breakoffs;
using OpenNest.Geometry;
namespace OpenNest.TabbedCutouts
{
/// <summary>
/// Resolves and applies <see cref="TabbedCutout"/>s: the hole's contour is replaced by open
/// pieces between tabs (on its own layer) and the tabs themselves (on the tab layer), in
/// contour order, so the hole stays closed as material while only the pieces are cut.
/// </summary>
public static class TabbedCutoutBuilder
{
/// <summary>How close the hole's entities must meet to form one contour.</summary>
public const double JoinTolerance = BreakoffBuilder.JoinTolerance;
private const double ArcTolerance = 0.0005;
/// <summary>
/// <paramref name="entities"/> with every valid tabbed cutout applied. Inputs are not
/// modified; generated entities are new. Cutouts that cannot be resolved are skipped and
/// described in <paramref name="errors"/>.
/// </summary>
public static List<Entity> Apply(IReadOnlyList<Entity> entities, IReadOnlyList<TabbedCutout> cutouts,
out List<string> errors)
{
errors = new List<string>();
var working = entities.ToList();
if (cutouts == null || cutouts.Count == 0)
return working;
for (var i = 0; i < cutouts.Count; i++)
{
var layout = Layout(working, cutouts[i]);
if (!layout.IsValid)
{
errors.Add($"Tabbed cutout {i + 1}: {layout.Error}");
continue;
}
var replaced = new HashSet<Entity>(layout.ReplacedEntities, ReferenceEqualityComparer.Instance);
var at = working.FindIndex(replaced.Contains);
working.RemoveAll(replaced.Contains);
var generated = new List<Entity>();
for (var p = 0; p < layout.Pieces.Count; p++)
{
generated.AddRange(layout.Pieces[p].Select(e => e.Clone()));
generated.AddRange(layout.Tabs[p].Select(e => e.Clone()));
}
working.InsertRange(System.Math.Clamp(at, 0, working.Count), generated);
}
return working;
}
/// <summary>Resolves <paramref name="cutout"/> against <paramref name="entities"/> (the entities that will be cut).</summary>
public static TabbedCutoutLayout Layout(IReadOnlyList<Entity> entities, TabbedCutout cutout)
{
ArgumentNullException.ThrowIfNull(entities);
ArgumentNullException.ThrowIfNull(cutout);
if (!(cutout.TabWidth > 0) || !double.IsFinite(cutout.TabWidth))
return Fail("The tab width must be greater than zero.");
var edgeIds = cutout.EdgeIds.ToHashSet();
var sources = entities.Where(e => edgeIds.Contains(e.Id)).ToList();
if (edgeIds.Count == 0 || sources.Count != edgeIds.Count)
return Fail("Its contour is hidden or missing.");
var chain = ChainClosed(sources);
if (chain == null)
return Fail("Its contour must be one closed loop of lines and arcs, or a circle.");
var loop = BreakoffBuilder.CloseExactly(chain);
var points = BreakoffPath.ToPoints(loop, ArcTolerance);
if (BreakoffPath.SignedArea(points) < 0)
loop = BreakoffPath.Reversed(loop);
loop = StartAtLowestLeft(loop);
points = BreakoffPath.ToPoints(loop, ArcTolerance);
if (!InsideAnOutline(entities, edgeIds, points))
return Fail("It must be a hole inside the part's outline.");
var length = BreakoffPath.Length(loop);
var tabWidth = cutout.TabWidth;
var centers = cutout.TabCenters.Count == 0
? EvenTabCenters(length, TabbedCutout.DefaultTabCount, loop[0].Length / 2)
: cutout.TabCenters.Select(c => Wrap(c, length)).OrderBy(c => c).ToList();
if (centers.Count < TabbedCutout.MinTabCount || length <= 2 * tabWidth * centers.Count)
return Fail("The contour is too short for its tabs.");
if (centers.Any(c => !double.IsFinite(c)))
return Fail("A tab is off the contour; space the tabs evenly again.");
for (var i = 0; i < centers.Count; i++)
{
var next = i + 1 < centers.Count ? centers[i + 1] : centers[0] + length;
if (centers.Count > 1 && next - centers[i] <= tabWidth + JoinTolerance)
return Fail("Two tabs overlap; space the tabs evenly again.");
}
// Walk a doubled loop from the end of the first tab, so every span is one increasing range.
var doubled = loop.Concat(loop).ToList();
var origin = centers[0] + tabWidth / 2;
if (origin >= length)
origin -= length;
var pieces = new List<IReadOnlyList<Entity>>();
var tabs = new List<IReadOnlyList<Entity>>();
for (var i = 0; i < centers.Count; i++)
{
var nextCenter = i + 1 < centers.Count ? centers[i + 1] : centers[0] + length;
var from = centers[i] + tabWidth / 2;
var cutTo = nextCenter - tabWidth / 2;
var tabTo = nextCenter + tabWidth / 2;
pieces.Add(Span(doubled, Unwrap(from), Unwrap(cutTo), null));
tabs.Add(Span(doubled, Unwrap(cutTo), Unwrap(tabTo), SpecialLayers.Tab));
}
return new TabbedCutoutLayout
{
Loop = loop,
Length = length,
Pieces = pieces,
Tabs = tabs,
TabCenters = centers,
TabWidth = tabWidth,
ReplacedEntities = sources,
};
// A distance at or after the walk's origin, in the doubled loop.
double Unwrap(double distance)
{
while (distance < origin - 1e-12)
distance += length;
return distance;
}
}
/// <summary>
/// <paramref name="count"/> tab centers spaced evenly around a contour of
/// <paramref name="length"/>, one of them at <paramref name="firstCenter"/>. The builder
/// centers one on the middle of the contour's first entity, so tabs avoid its start corner.
/// </summary>
public static List<double> EvenTabCenters(double length, int count, double firstCenter)
{
count = System.Math.Max(count, TabbedCutout.MinTabCount);
var spacing = length / count;
var start = Wrap(firstCenter, spacing);
return Enumerable.Range(0, count).Select(i => start + spacing * i).ToList();
}
/// <summary>
/// The visible lines and arcs forming the closed contour through <paramref name="picked"/>,
/// or the picked circle alone. Empty when <paramref name="picked"/> is on no closed contour
/// that continues as a single path.
/// </summary>
public static List<Entity> FindHole(IReadOnlyList<Entity> entities, Entity picked)
{
var pool = entities.Where(e => e is Line or Arc or Circle && e.IsVisible && e.Layer?.IsVisible != false
&& !BreakoffBuilder.IsMark(e) && e.Layer != SpecialLayers.Break).ToList();
if (!pool.Contains(picked))
return new List<Entity>();
if (picked is Circle)
return new List<Entity> { picked };
var lines = pool.Where(e => e is Line or Arc).ToList();
var chain = new List<Entity> { picked };
var start = BreakoffPath.Start(picked);
var tip = BreakoffPath.End(picked);
while (tip.DistanceTo(start) > JoinTolerance || chain.Count == 1)
{
var touching = lines.Where(e => !chain.Contains(e)
&& (BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance)).ToList();
if (touching.Count != 1)
return new List<Entity>();
var next = touching[0];
chain.Add(next);
tip = BreakoffPath.Start(next).DistanceTo(tip) <= JoinTolerance
? BreakoffPath.End(next)
: BreakoffPath.Start(next);
}
return chain;
}
private static TabbedCutoutLayout Fail(string error) => new(error);
private static double Wrap(double distance, double length)
{
var wrapped = distance % length;
return wrapped < 0 ? wrapped + length : wrapped;
}
// Copies of the span from `from` to `to`, with new IDs and, for tabs, the tab layer.
private static List<Entity> Span(IReadOnlyList<Entity> path, double from, double to, Layer layer)
{
var span = BreakoffPath.Sub(path, from, to);
foreach (var e in span)
{
e.Id = Guid.NewGuid();
if (layer != null)
e.Layer = layer;
}
return span;
}
/// <summary>
/// <paramref name="edges"/> copied into one closed chain in travel order, a circle as two
/// half arcs; null when they are not exactly one closed loop.
/// </summary>
private static List<Entity> ChainClosed(IReadOnlyList<Entity> edges)
{
if (edges.Count == 1 && edges[0] is Circle circle)
{
var reversed = circle.Rotation == RotationType.CW;
Arc Half(double from, double to) => new(circle.Center, circle.Radius, from, to, reversed)
{
Layer = circle.Layer,
Color = circle.Color,
LineTypeName = circle.LineTypeName,
};
return new List<Entity> { Half(System.Math.PI, 0), Half(0, System.Math.PI) };
}
if (edges.Count == 0 || edges.Any(e => e is not (Line or Arc)))
return null;
var remaining = edges.ToList();
var chain = new List<Entity> { BreakoffPath.Copy(remaining[0]) };
remaining.RemoveAt(0);
while (remaining.Count > 0)
{
var tip = BreakoffPath.End(chain[^1]);
var matches = remaining.Where(e => BreakoffPath.Start(e).DistanceTo(tip) <= JoinTolerance
|| BreakoffPath.End(e).DistanceTo(tip) <= JoinTolerance).ToList();
if (matches.Count != 1)
return null;
var next = matches[0];
var copy = BreakoffPath.Copy(next);
if (BreakoffPath.Start(next).DistanceTo(tip) > JoinTolerance)
copy.Reverse();
remaining.Remove(next);
chain.Add(copy);
}
return BreakoffPath.Start(chain[0]).DistanceTo(BreakoffPath.End(chain[^1])) <= JoinTolerance ? chain : null;
}
// Rotates a closed loop to start at its vertex with the smallest X (then Y), so tab
// positions survive re-chaining.
private static List<Entity> StartAtLowestLeft(List<Entity> loop)
{
var best = 0;
for (var i = 1; i < loop.Count; i++)
{
var p = BreakoffPath.Start(loop[i]);
var q = BreakoffPath.Start(loop[best]);
if (p.X < q.X - JoinTolerance || (System.Math.Abs(p.X - q.X) <= JoinTolerance && p.Y < q.Y))
best = i;
}
return loop.Skip(best).Concat(loop.Take(best)).ToList();
}
// Whether the loop lies inside some other closed outline drawn with it.
private static bool InsideAnOutline(IReadOnlyList<Entity> entities, HashSet<Guid> loopIds,
IReadOnlyList<Vector> loop)
{
var others = entities.Where(e => !loopIds.Contains(e.Id) && e is Line or Arc or Circle
&& !BreakoffBuilder.IsMark(e) && e.Layer != SpecialLayers.Break && e.Layer != SpecialLayers.Tab).ToList();
var loopArea = System.Math.Abs(BreakoffPath.SignedArea(loop));
foreach (var shape in ShapeBuilder.GetShapes(others))
{
if (!shape.IsClosed())
continue;
var outline = shape.ToPolygonWithTolerance(ArcTolerance).Vertices;
if (System.Math.Abs(BreakoffPath.SignedArea(outline)) > loopArea
&& loop.All(p => BreakoffPath.Contains(outline, p)))
return true;
}
return false;
}
}
}
@@ -0,0 +1,97 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Breakoffs;
using OpenNest.Geometry;
namespace OpenNest.TabbedCutouts
{
/// <summary>
/// A <see cref="TabbedCutout"/> resolved against a drawing's entities. When <see cref="Error"/>
/// is set nothing else is meaningful and the cutout is not applied.
/// </summary>
public sealed class TabbedCutoutLayout
{
internal TabbedCutoutLayout(string error) => Error = error;
internal TabbedCutoutLayout() { }
public string Error { get; }
public bool IsValid => Error == null;
/// <summary>
/// The hole's contour, counterclockwise from its lowest-left vertex with its joints made
/// exact. It is also the slug's boundary.
/// </summary>
public IReadOnlyList<Entity> Loop { get; internal set; }
public double Length { get; internal set; }
/// <summary>
/// One open cut per span of the contour between tabs, in contour order, travelling
/// counterclockwise (the slug on the left).
/// </summary>
public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; }
/// <summary>The uncut tabs on the tab layer, each following the piece before it.</summary>
public IReadOnlyList<IReadOnlyList<Entity>> Tabs { get; internal set; }
/// <summary>Tab centers used, as distances along <see cref="Loop"/>.</summary>
public IReadOnlyList<double> TabCenters { get; internal set; }
public double TabWidth { get; internal set; }
/// <summary>Where each piece is entered (its start) and the unit direction into the slug there.</summary>
public IReadOnlyList<(Vector Entry, Vector SlugDirection)> Entries => Pieces
.Select(piece =>
{
var tangent = BreakoffPath.TangentAt(piece, 0);
return (BreakoffPath.Start(piece[0]), new Vector(-tangent.Y, tangent.X));
})
.ToList();
/// <summary>Entities the pieces and tabs replace: the hole's original contour.</summary>
internal IReadOnlyList<Entity> ReplacedEntities { get; set; }
/// <summary><paramref name="count"/> evenly spaced tab centers, as the builder places them by default.</summary>
public List<double> EvenTabCenters(int count) =>
TabbedCutoutBuilder.EvenTabCenters(Length, count, Loop[0].Length / 2);
/// <summary>The point at <paramref name="distance"/> along the contour, wrapping around it.</summary>
public Vector PointAt(double distance) => BreakoffPath.PointAt(Loop, Wrap(distance));
/// <summary>The distance along the contour closest to <paramref name="point"/>.</summary>
public double DistanceOf(Vector point) => BreakoffPath.DistanceOf(Loop, point);
/// <summary>
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a
/// gap from its neighbours around the contour and does not pass the contour's start
/// (which would reorder the tabs).
/// </summary>
public double ClampTabCenter(int index, double center)
{
var count = TabCenters.Count;
if (count == 1)
return Wrap(center);
var spacing = TabWidth + MinGap;
var min = index == 0 ? System.Math.Max(0, TabCenters[^1] - Length + spacing) : TabCenters[index - 1] + spacing;
var max = index == count - 1
? System.Math.Min(Length - Tolerance, TabCenters[0] + Length - spacing)
: TabCenters[index + 1] - spacing;
return max < min ? TabCenters[index] : System.Math.Clamp(center, min, max);
}
internal double Wrap(double distance)
{
if (!(Length > 0))
return 0;
var wrapped = distance % Length;
return wrapped < 0 ? wrapped + Length : wrapped;
}
// Wider than the builder's overlap tolerance, so a clamped drag always resolves.
internal double MinGap => System.Math.Max(TabWidth / 2, 2 * TabbedCutoutBuilder.JoinTolerance);
private const double Tolerance = 1e-9;
}
}
+23 -2
View File
@@ -1,6 +1,5 @@
using System;
using System;
using System.Linq;
using OpenNest.Api;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
@@ -43,6 +42,10 @@ namespace OpenNest
pos = endpt;
}
// D9: once per physical sheet, before the quantity multiply below scales it with
// everything else cut on that sheet.
info.ClampRemovalMinutes = plate.ClampRemovalMinutes;
info *= plate.Quantity;
return info;
@@ -72,6 +75,21 @@ namespace OpenNest
info.TravelDistance += entity.Length;
}
/// <summary>
/// A plate's cut time over its quantity: its <see cref="CutSimulation"/> timeline (hole and
/// outside feeds, skipped etch, and acceleration when <see cref="CutParameters.Motion"/> is
/// set) plus clamp removal.
/// </summary>
public static TimeSpan CalculateTime(Plate plate, CutParameters cutParams)
{
var sheet = CutSimulation.Build(plate, cutParams).TotalTime
+ TimeSpan.FromMinutes(plate.ClampRemovalMinutes);
return sheet * plate.Quantity;
}
public static TimeSpan CalculateTime(Nest nest, CutParameters cutParams) =>
nest.Plates.Aggregate(TimeSpan.Zero, (total, plate) => total + CalculateTime(plate, cutParams));
public static TimeSpan CalculateTime(TimingInfo info, CutParameters cutParams)
{
var time = new TimeSpan();
@@ -91,6 +109,9 @@ namespace OpenNest
time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks);
// D9: a flat per-removed-clamp time, already in minutes regardless of cutParams.Units.
time += TimeSpan.FromMinutes(info.ClampRemovalMinutes);
return time;
}
}
+11
View File
@@ -10,6 +10,13 @@ namespace OpenNest
public double CutDistance;
/// <summary>
/// Minutes added by last-resort clamp removal (D9): once per physical sheet, for
/// every clamp <see cref="Plate.RemovedClamps"/> lists. Zero for a plate with no
/// removal, so existing timing is unaffected.
/// </summary>
public double ClampRemovalMinutes;
public static TimingInfo operator +(TimingInfo info1, TimingInfo info2)
{
return new TimingInfo
@@ -18,6 +25,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount + info2.IntersectionCount,
TravelDistance = info1.TravelDistance + info2.TravelDistance,
PierceCount = info1.PierceCount + info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes + info2.ClampRemovalMinutes,
};
}
@@ -29,6 +37,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount - info2.IntersectionCount,
TravelDistance = info1.TravelDistance - info2.TravelDistance,
PierceCount = info1.PierceCount - info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes - info2.ClampRemovalMinutes,
};
}
@@ -40,6 +49,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount * info2.IntersectionCount,
TravelDistance = info1.TravelDistance * info2.TravelDistance,
PierceCount = info1.PierceCount * info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes * info2.ClampRemovalMinutes,
};
}
@@ -51,6 +61,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount * factor,
TravelDistance = info1.TravelDistance * factor,
PierceCount = info1.PierceCount * factor,
ClampRemovalMinutes = info1.ClampRemovalMinutes * factor,
};
}
}
+142
View File
@@ -0,0 +1,142 @@
namespace OpenNest.Data;
/// <summary>
/// Decision logic behind the desktop Clamps dialog: edits one nest's clamp rail in that
/// nest's units. Three explicit boundaries: <see cref="LoadMachineDefaults"/> only fills
/// the editor, <see cref="ApplyTo"/> changes the nest (the dialog's OK), and
/// <see cref="ToMachineDefaults"/> is what "Save as Machine Defaults" writes immediately.
/// </summary>
public sealed class ClampRailEditor
{
private readonly List<RailClamp> _clamps = new();
/// <summary>Starts from the nest's rail, or the default clamp size with no clamps.</summary>
public ClampRailEditor(ClampRail? rail, Units units, bool applyToNewNests)
{
Units = units;
ApplyToNewNests = applyToNewNests;
var initial = ClampSettings.FromRail(rail, units, applyToNewNests);
Width = initial.Width;
Depth = initial.Depth;
RemovalMinutes = initial.RemovalMinutes;
_clamps.AddRange(initial.Clamps.Order());
}
/// <summary>The nest's units; every value is in these units.</summary>
public Units Units { get; }
/// <summary>Clamp extent along its edge.</summary>
public double Width { get; set; }
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
public double Depth { get; set; }
/// <summary>Minutes to remove one clamp and put it back after cutting (D9); not a
/// length, so it is never converted between units.</summary>
public double RemovalMinutes { get; set; }
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
public bool ApplyToNewNests { get; set; }
/// <summary>Clamps ordered by edge, then center from the plate origin; distinct.</summary>
public IReadOnlyList<RailClamp> Clamps => _clamps;
/// <summary>Origin-side clamp centers along X from the plate origin, ascending.</summary>
public IReadOnlyList<double> Centers =>
_clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToList();
/// <summary>Adds an origin-side clamp; see <see cref="TryAddClamp"/>.</summary>
public bool TryAddCenter(double center, out string? error) =>
TryAddClamp(new RailClamp(ClampEdge.OriginSide, center), out error);
/// <summary>
/// Adds a clamp, keeping the list sorted. Refuses (with an operator-facing reason)
/// centers that are not finite, negative, or already listed on that edge.
/// </summary>
public bool TryAddClamp(RailClamp clamp, out string? error)
{
if (!double.IsFinite(clamp.Center) || clamp.Center < 0)
{
error = "Enter a clamp center measured from the plate origin (zero or more).";
return false;
}
var index = _clamps.BinarySearch(clamp);
if (index >= 0)
{
error = $"A clamp is already centered at {clamp.Center:0.####} on that edge.";
return false;
}
_clamps.Insert(~index, clamp);
error = null;
return true;
}
/// <summary>Removes the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
public void RemoveCenterAt(int index)
{
if (index >= 0 && index < _clamps.Count)
_clamps.RemoveAt(index);
}
/// <summary>
/// Replaces the editor's values with the machine defaults converted to this nest's
/// units. Nothing is applied or saved.
/// </summary>
public void LoadMachineDefaults(ClampSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
var converted = settings.ConvertedTo(Units);
Width = converted.Width;
Depth = converted.Depth;
RemovalMinutes = converted.RemovalMinutes;
ApplyToNewNests = settings.Enabled;
_clamps.Clear();
_clamps.AddRange(converted.Clamps.Distinct().Order());
}
/// <summary>
/// The edited rail, or null when no clamp is listed. Throws
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
/// negative/non-finite removal time.
/// </summary>
public ClampRail? BuildRail() =>
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes);
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
public void ApplyTo(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
nest.SetClampRail(BuildRail());
}
/// <summary>
/// The machine settings "Save as Machine Defaults" writes: these values in this nest's
/// units, enabled for new nests only when requested and at least one clamp is listed.
/// </summary>
public ClampSettings ToMachineDefaults()
{
// Validate before anything is persisted.
if (!double.IsFinite(Width) || Width <= 0 || !double.IsFinite(Depth) || Depth <= 0)
throw new ArgumentOutOfRangeException(
nameof(Width),
"Clamp width and depth must be positive."
);
if (!double.IsFinite(RemovalMinutes) || RemovalMinutes < 0)
throw new ArgumentOutOfRangeException(
nameof(RemovalMinutes),
"Clamp removal time must be finite and zero or more."
);
var settings = new ClampSettings
{
Enabled = ApplyToNewNests && _clamps.Count > 0,
Units = Units,
Width = Width,
Depth = Depth,
RemovalMinutes = RemovalMinutes,
};
settings.SetClamps(_clamps);
return settings;
}
}
+398
View File
@@ -0,0 +1,398 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>Outcome of <see cref="ClampSettings.Load(string, out ClampSettingsStatus)"/>.</summary>
public enum ClampSettingsStatus
{
/// <summary>Every value present in the file was valid.</summary>
Ok,
/// <summary>No file exists at the path; clamps stay off.</summary>
Missing,
/// <summary>
/// The file loaded, but some values were invalid and were ignored (an invalid clamp
/// center is dropped, other fields fall back individually). Callers should warn: the
/// resulting rail may lack a physical clamp.
/// </summary>
Partial,
/// <summary>The file exists but could not be read or parsed; clamps stay off.</summary>
Invalid,
}
/// <summary>
/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default
/// %APPDATA%\OpenNest\clamps.json). Off by default: until <see cref="Enabled"/> is set
/// with at least one center, new nests get no clamps and behave exactly as before.
/// Loading never throws; values are validated field by field.
/// </summary>
public sealed class ClampSettings
{
public const int CurrentVersion = 1;
/// <summary>Default clamp extent along the edge, in <see cref="Units"/> (inches).</summary>
public const double DefaultWidth = 1.5;
/// <summary>Default clamp intrusion into the sheet, in <see cref="Units"/> (inches).</summary>
public const double DefaultDepth = 0.625;
private const double MillimetersPerInch = 25.4;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
/// <summary>Whether new nests receive this rail. Default off.</summary>
public bool Enabled { get; set; }
/// <summary>Units of <see cref="Width"/>, <see cref="Depth"/> and <see cref="Centers"/>.</summary>
public Units Units { get; set; } = Units.Inches;
public double Width { get; set; } = DefaultWidth;
public double Depth { get; set; } = DefaultDepth;
/// <summary>
/// Minutes to remove one clamp and put it back after cutting (D9), stored and edited in
/// the machine clamp settings alongside the rail's physical size.
/// </summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
/// <summary>Far-side clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> FarSideCenters { get; set; } = Array.Empty<double>();
/// <summary>Origin-end clamp centers along Y, measured from the plate origin.</summary>
public IReadOnlyList<double> OriginEndCenters { get; set; } = Array.Empty<double>();
/// <summary>Far-end clamp centers along Y, measured from the plate origin.</summary>
public IReadOnlyList<double> FarEndCenters { get; set; } = Array.Empty<double>();
/// <summary>Every configured clamp, by edge then center as listed.</summary>
public IEnumerable<RailClamp> Clamps =>
Centers.Select(c => new RailClamp(ClampEdge.OriginSide, c))
.Concat(FarSideCenters.Select(c => new RailClamp(ClampEdge.FarSide, c)))
.Concat(OriginEndCenters.Select(c => new RailClamp(ClampEdge.OriginEnd, c)))
.Concat(FarEndCenters.Select(c => new RailClamp(ClampEdge.FarEnd, c)));
/// <summary>The number of configured clamps on every edge.</summary>
public int ClampCount =>
Centers.Count + FarSideCenters.Count + OriginEndCenters.Count + FarEndCenters.Count;
/// <summary>The centers listed for <paramref name="edge"/>.</summary>
public IReadOnlyList<double> CentersOn(ClampEdge edge) =>
edge switch
{
ClampEdge.FarSide => FarSideCenters,
ClampEdge.OriginEnd => OriginEndCenters,
ClampEdge.FarEnd => FarEndCenters,
_ => Centers,
};
/// <summary>Lists <paramref name="clamps"/> under their edges, replacing every edge's centers.</summary>
public void SetClamps(IEnumerable<RailClamp> clamps)
{
var list = clamps.ToList();
double[] On(ClampEdge edge) => list.Where(c => c.Edge == edge).Select(c => c.Center).ToArray();
Centers = On(ClampEdge.OriginSide);
FarSideCenters = On(ClampEdge.FarSide);
OriginEndCenters = On(ClampEdge.OriginEnd);
FarEndCenters = On(ClampEdge.FarEnd);
}
/// <summary>The built-in settings used for a missing or unreadable file: clamps off.</summary>
public static ClampSettings Fallback => new();
/// <summary>%APPDATA%\OpenNest\clamps.json.</summary>
public static string DefaultPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest",
"clamps.json"
);
/// <summary>
/// Captures a nest's rail (or the default clamp size and no centers when it has none)
/// in that nest's units, e.g. for "save as machine defaults".
/// </summary>
public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled)
{
var settings = new ClampSettings
{
Enabled = enabled,
Units = units,
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
};
if (rail != null)
settings.SetClamps(rail.Clamps);
return settings;
}
/// <summary>
/// The rail a new nest in <paramref name="units"/> receives, or null when clamps are
/// disabled or no center is configured. Values are converted between inches and
/// millimeters as needed; <see cref="RemovalMinutes"/> is a time, so it is never scaled.
/// </summary>
public ClampRail? RailFor(Units units)
{
if (!Enabled || ClampCount == 0)
return null;
var converted = ConvertedTo(units);
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes);
}
/// <summary>
/// A copy with width, depth and centers expressed in <paramref name="units"/>
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
/// </summary>
public ClampSettings ConvertedTo(Units units)
{
var scale = Scale(Units, units);
return new ClampSettings
{
Enabled = Enabled,
Units = units,
Width = Width * scale,
Depth = Depth * scale,
RemovalMinutes = RemovalMinutes,
Centers = Centers.Select(c => c * scale).ToArray(),
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
FarEndCenters = FarEndCenters.Select(c => c * scale).ToArray(),
};
}
/// <summary>
/// Gives a newly created nest this machine rail when enabled (after the nest's units are
/// set); otherwise leaves it untouched.
/// </summary>
public void ApplyToNewNest(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
var rail = RailFor(nest.Units);
if (rail != null)
nest.SetClampRail(rail);
}
public static ClampSettings Load(string path) => Load(path, out _);
/// <summary>
/// Loads the settings, never throwing. A missing or unreadable file yields
/// <see cref="Fallback"/> (clamps off); a readable file merges valid values over it and
/// reports <see cref="ClampSettingsStatus.Partial"/> if anything was ignored.
/// </summary>
public static ClampSettings Load(string path, out ClampSettingsStatus status)
{
var settings = Fallback;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = ClampSettingsStatus.Missing;
return settings;
}
// Read as a raw document so a missing field (fallback, Ok) is distinguishable from a
// present but unusable one, including an explicit null (fallback, Partial).
Dictionary<string, JsonElement> values;
try
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
status = ClampSettingsStatus.Invalid;
return settings;
}
values = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var property in document.RootElement.EnumerateObject())
values[property.Name] = property.Value.Clone();
}
catch (Exception ex) when (IsUnreadableFile(ex))
{
status = ClampSettingsStatus.Invalid;
return settings;
}
var ignored = false;
if (values.TryGetValue("enabled", out var enabled))
{
if (enabled.ValueKind is JsonValueKind.True or JsonValueKind.False)
settings.Enabled = enabled.GetBoolean();
else
ignored = true;
}
if (values.TryGetValue("units", out var units))
{
if (TryParseUnits(units, out var parsed))
settings.Units = parsed;
else
ignored = true;
}
// Fallback sizes are the physical default clamp, expressed in the file's units.
settings.Width = ConvertDefault(DefaultWidth, settings.Units);
settings.Depth = ConvertDefault(DefaultDepth, settings.Units);
if (values.TryGetValue("width", out var width))
{
if (TryLength(width, out var value) && value > 0)
settings.Width = value;
else
ignored = true;
}
if (values.TryGetValue("depth", out var depth))
{
if (TryLength(depth, out var value) && value > 0)
settings.Depth = value;
else
ignored = true;
}
if (values.TryGetValue("removalMinutes", out var removalMinutes))
{
if (TryLength(removalMinutes, out var value) && value >= 0)
settings.RemovalMinutes = value;
else
ignored = true;
}
if (values.TryGetValue("centers", out var centers))
settings.Centers = ReadCenters(centers, ref ignored);
if (values.TryGetValue("farSideCenters", out var farSide))
settings.FarSideCenters = ReadCenters(farSide, ref ignored);
if (values.TryGetValue("originEndCenters", out var originEnd))
settings.OriginEndCenters = ReadCenters(originEnd, ref ignored);
if (values.TryGetValue("farEndCenters", out var farEnd))
settings.FarEndCenters = ReadCenters(farEnd, ref ignored);
status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok;
return settings;
}
/// <summary>Valid centers of a JSON array, distinct and ascending; flags anything dropped.</summary>
private static double[] ReadCenters(JsonElement centers, ref bool ignored)
{
if (centers.ValueKind != JsonValueKind.Array)
{
ignored = true;
return Array.Empty<double>();
}
var valid = new List<double>();
foreach (var center in centers.EnumerateArray())
{
if (TryLength(center, out var x) && x >= 0)
valid.Add(x);
else
ignored = true;
}
return valid.Distinct().OrderBy(x => x).ToArray();
}
/// <summary>
/// Writes the file (creating the parent directory), retrying briefly on IO collisions
/// the same way <see cref="LocalJsonProvider"/> does.
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var dto = new Dictionary<string, object>
{
["version"] = CurrentVersion,
["enabled"] = Enabled,
["units"] = Units.ToString().ToLowerInvariant(),
["width"] = Width,
["depth"] = Depth,
["removalMinutes"] = RemovalMinutes,
["centers"] = Centers,
};
// Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters;
if (OriginEndCenters.Count > 0)
dto["originEndCenters"] = OriginEndCenters;
if (FarEndCenters.Count > 0)
dto["farEndCenters"] = FarEndCenters;
var json = JsonSerializer.Serialize(dto, JsonOptions);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
}
private static double Scale(Units from, Units to) =>
from == to ? 1
: from == Units.Inches ? MillimetersPerInch
: 1 / MillimetersPerInch;
private static double ConvertDefault(double inches, Units units) =>
inches * Scale(Units.Inches, units);
/// <summary>
/// A finite JSON number that stays finite in either unit, so applying it to an inch or a
/// millimeter nest can never overflow.
/// </summary>
private static bool TryLength(JsonElement element, out double value)
{
value = 0;
return element.ValueKind == JsonValueKind.Number
&& element.TryGetDouble(out value)
&& double.IsFinite(value)
&& double.IsFinite(value * MillimetersPerInch);
}
/// <summary>
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
/// numeric strings and comma-joined names.
/// </summary>
private static bool TryParseUnits(JsonElement element, out Units units)
{
units = default;
if (element.ValueKind != JsonValueKind.String)
return false;
var text = element.GetString();
foreach (var candidate in Enum.GetValues<Units>())
{
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
{
units = candidate;
return true;
}
}
return false;
}
private static bool IsUnreadableFile(Exception ex) =>
ex
is JsonException
or IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException;
}
@@ -0,0 +1,230 @@
using System.Globalization;
using OpenNest;
namespace OpenNest.Data;
/// <summary>
/// Browse state for Add from Drawing Library: the requested search, sort, page and
/// nest filter, and the last page applied. By default only drawings with the nest's
/// material, grade and thickness are listed; <see cref="SetShowAllAsync"/> lifts that
/// filter. Every change issues one bounded server query. A response that a later request
/// superseded is discarded (and the earlier request is cancelled), so an older result can
/// never replace a newer one. Call from one thread (the UI thread).
/// </summary>
public sealed class DrawingLibraryBrowseSession : IDisposable
{
private readonly IDrawingLibrary _library;
private CancellationTokenSource? _pending;
private int _generation;
private bool _disposed;
public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(nest);
if (pageSize < 1 || pageSize > DrawingLibraryQuery.MaxLimit)
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingLibraryQuery.MaxLimit}.");
_library = library;
PageSize = pageSize;
MaterialName = (nest.Material?.Name ?? "").Trim();
MaterialGrade = (nest.Material?.Grade ?? "").Trim();
// A nest without a thickness has nothing to match; it filters by material only.
Thickness = double.IsFinite(nest.Thickness) && nest.Thickness > 0
? DrawingLibraryRules.NormalizeThickness(nest.Thickness)
: null;
}
public int PageSize { get; }
/// <summary>The nest's material name, applied unless <see cref="ShowAll"/>; blank means any.</summary>
public string MaterialName { get; }
/// <summary>The nest's grade, applied unless <see cref="ShowAll"/>; blank means any.</summary>
public string MaterialGrade { get; }
/// <summary>The nest's thickness, applied unless <see cref="ShowAll"/>; null means any.</summary>
public double? Thickness { get; }
/// <summary>True when the nest filter is lifted and every active drawing is listed.</summary>
public bool ShowAll { get; private set; }
/// <summary>True when the latest request narrows by the nest's material, grade or thickness.</summary>
public bool IsFilteredToNest =>
!ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null);
/// <summary>Trimmed search text of the latest request.</summary>
public string Search { get; private set; } = "";
public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt;
public bool Descending { get; private set; } = true;
/// <summary>Offset of the latest request.</summary>
public int Offset { get; private set; }
/// <summary>The latest applied page; null before the first result and after a failed request.</summary>
public DrawingLibraryPage? Page { get; private set; }
/// <summary>True while the latest request is outstanding; superseded requests do not count.</summary>
public bool IsLoading { get; private set; }
public bool CanGoPrevious => Offset > 0;
public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total;
/// <summary>Operator-facing summary of <see cref="Page"/>, such as "Showing 101-200 of 1,234 drawings".</summary>
public string Summary
{
get
{
if (Page is not { } page)
return "";
if (page.Total == 0)
{
if (Search.Length > 0)
return "No drawings match the search.";
return IsFilteredToNest
? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing."
: "No drawings in the library.";
}
if (page.Items.Count == 0)
return $"No drawings on this page ({Count(page.Total)} in total).";
return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} drawings";
}
}
/// <summary>Applies new search text and returns to the first page.</summary>
public Task<bool> SetSearchAsync(string? search)
{
Search = (search ?? "").Trim();
Offset = 0;
return RunAsync();
}
/// <summary>Lifts or restores the nest filter and returns to the first page.</summary>
public Task<bool> SetShowAllAsync(bool showAll)
{
ShowAll = showAll;
Offset = 0;
return RunAsync();
}
/// <summary>Sorts by a column: a new column starts ascending, the same column reverses.</summary>
public Task<bool> SortByAsync(DrawingSortField field)
{
Descending = field == Sort && !Descending;
Sort = field;
Offset = 0;
return RunAsync();
}
public Task<bool> NextPageAsync()
{
if (!CanGoNext)
return Task.FromResult(false);
Offset = Page!.Offset + PageSize;
return RunAsync();
}
public Task<bool> PreviousPageAsync()
{
if (!CanGoPrevious)
return Task.FromResult(false);
Offset = System.Math.Max(0, Offset - PageSize);
return RunAsync();
}
/// <summary>Re-reads the current page; steps back to the last page if it is now past the end.</summary>
public Task<bool> RefreshAsync() => RunAsync();
/// <summary>
/// Sends the current request. Returns true when its page was applied, false when a later
/// request superseded it. Failures of the latest request clear <see cref="Page"/> and propagate;
/// failures of superseded requests are ignored.
/// </summary>
private async Task<bool> RunAsync()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled
// must already see itself as superseded. Each source is released by its own request.
var previous = _pending;
using var cancellation = new CancellationTokenSource();
_pending = cancellation;
var generation = ++_generation;
IsLoading = true;
try
{
previous?.Cancel();
var page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
// An archive or another PC can leave the requested page past the end.
if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0)
{
Offset = (page.Total - 1) / PageSize * PageSize;
page = await QueryAsync(cancellation.Token);
if (generation != _generation)
return false;
}
Page = page;
return true;
}
catch (Exception) when (generation != _generation)
{
return false;
}
catch
{
Page = null;
throw;
}
finally
{
if (ReferenceEquals(_pending, cancellation))
_pending = null;
if (generation == _generation)
IsLoading = false;
}
}
private Task<DrawingLibraryPage> QueryAsync(CancellationToken cancellationToken) =>
_library.QueryAsync(
new DrawingLibraryQuery
{
Search = Search,
MaterialName = ShowAll ? "" : MaterialName,
MaterialGrade = ShowAll ? "" : MaterialGrade,
Thickness = ShowAll ? null : Thickness,
Sort = Sort,
Descending = Descending,
Offset = Offset,
Limit = PageSize,
},
cancellationToken);
private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture);
/// <summary>
/// Cancels any request in flight (which releases its own source when it ends);
/// the library client is not owned and stays open.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_generation++;
IsLoading = false;
var pending = _pending;
_pending = null;
pending?.Cancel();
}
}
Loaded 100 of 657 files, more files were not shown because too many files have changed in this diff. Show more