169 Commits
Author SHA1 Message Date
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 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
289 changed files with 31351 additions and 2894 deletions

No files matched your search

+1
View File
@@ -62,3 +62,4 @@ Read the relevant contract before changing its behavior:
- [Pre-post verification](docs/post-verification.md) - [Pre-post verification](docs/post-verification.md)
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md) - [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal. - [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
+3
View File
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
/// <summary>One explicit physical-stock type for a whole nesting job.</summary> /// <summary>One explicit physical-stock type for a whole nesting job.</summary>
public class NestRequestPlate 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 string Id { get; init; }
public Size Size { get; init; } public Size Size { get; init; }
+8 -2
View File
@@ -16,7 +16,8 @@ public sealed record NestPartFulfillment(string PartId, int Requested, int Place
/// <summary>Physical-sheet usage for one stock identity.</summary> /// <summary>Physical-sheet usage for one stock identity.</summary>
public sealed record NestStockUsage(string StockId, int Used, int? Remaining); 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); public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary> /// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
@@ -24,10 +25,11 @@ public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
public class NestResponse public class NestResponse
{ {
public const int CurrentSchemaVersion = 3; public const int CurrentSchemaVersion = 4;
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary> /// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
public int SchemaVersion { get; init; } = CurrentSchemaVersion; public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Physical sheets: sum of Plate.Quantity, not materialized layout entries.</summary>
public int SheetCount { get; init; } public int SheetCount { get; init; }
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary> /// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
@@ -44,6 +46,7 @@ public class NestResponse
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = []; public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = []; public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = []; public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
public NestCostSummary Costs { get; init; }
public Nest Nest { get; init; } public Nest Nest { get; init; }
public NestRequest Request { get; init; } public NestRequest Request { get; init; }
@@ -86,6 +89,7 @@ public class NestResponse
Fulfillment = Fulfillment is null Fulfillment = Fulfillment is null
? [] ? []
: new List<NestPartFulfillment>(Fulfillment), : new List<NestPartFulfillment>(Fulfillment),
Costs = Costs,
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage), StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
PlateStockMappings = PlateStockMappings is null PlateStockMappings = PlateStockMappings is null
? [] ? []
@@ -171,6 +175,7 @@ public class NestResponse
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [], Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [], StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [], PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
Costs = archive.Costs,
Nest = nest, Nest = nest,
Request = request, Request = request,
}; };
@@ -178,6 +183,7 @@ public class NestResponse
private sealed class NestResponseArchiveDto private sealed class NestResponseArchiveDto
{ {
public NestCostSummary Costs { get; init; }
public int SchemaVersion { get; init; } public int SchemaVersion { get; init; }
public int SheetCount { get; init; } public int SheetCount { get; init; }
public double Utilization { get; init; } public double Utilization { get; init; }
+15 -9
View File
@@ -28,6 +28,7 @@ public static class NestRunner
if (requestParts.Count == 0) if (requestParts.Count == 0)
throw new ArgumentException("Request must contain at least one part.", nameof(request)); throw new ArgumentException("Request must contain at least one part.", nameof(request));
var stock = CreateStock(request);
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var parts = IdentifyParts(requestParts); var parts = IdentifyParts(requestParts);
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal); 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 engineName = request.Engine ?? ResolvePlacementStrategy(request);
var jobProgress = progress == null ? null : new JobProgressBridge(progress); var jobProgress = progress == null ? null : new JobProgressBridge(progress);
var result = NestPipeline.Run(new NestPipelineRequest( 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. // 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. // Invalid but representable proposals retain every pose and carry explicit validation status.
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) }; 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) foreach (var item in items)
nest.Drawings.Add(item.Drawing); nest.Drawings.Add(item.Drawing);
foreach (var proposed in result.Plates) foreach (var proposed in result.Plates)
@@ -100,7 +103,7 @@ public static class NestRunner
PartSpacing = proposed.Stock.PartSpacing, PartSpacing = proposed.Stock.PartSpacing,
EdgeSpacing = proposed.Stock.EdgeSpacing, EdgeSpacing = proposed.Stock.EdgeSpacing,
Quadrant = proposed.Stock.Quadrant, Quadrant = proposed.Stock.Quadrant,
Quantity = 1, Quantity = proposed.Quantity,
}; };
plate.Parts.AddRange(proposed.Parts); plate.Parts.AddRange(proposed.Parts);
nest.Plates.Add(plate); nest.Plates.Add(plate);
@@ -108,8 +111,8 @@ public static class NestRunner
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the // Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
// returned, bound placements and the original request, never plug-in summaries. // returned, bound placements and the original request, never plug-in summaries.
var counts = result.Plates.SelectMany(p => p.Parts) var counts = result.Plates.SelectMany(p => p.Parts.Select(part => (part.BaseDrawing, p.Quantity)))
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count()); .GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity));
var fulfillment = parts.Select((part, i) => var fulfillment = parts.Select((part, i) =>
{ {
var placed = counts.GetValueOrDefault(items[i].Drawing); var placed = counts.GetValueOrDefault(items[i].Drawing);
@@ -118,7 +121,7 @@ public static class NestRunner
}).ToArray(); }).ToArray();
var usage = stock.Select(s => 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); return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
}).ToArray(); }).ToArray();
var complete = fulfillment.All(f => f.Placed == f.Requested); var complete = fulfillment.All(f => f.Placed == f.Requested);
@@ -133,7 +136,7 @@ public static class NestRunner
return Task.FromResult( return Task.FromResult(
new NestResponse new NestResponse
{ {
SheetCount = nest.Plates.Count, SheetCount = nest.Plates.Sum(p => p.Quantity),
Utilization = CalculateUtilization(nest), Utilization = CalculateUtilization(nest),
CutTime = cutTime, CutTime = cutTime,
Elapsed = sw.Elapsed, Elapsed = sw.Elapsed,
@@ -144,6 +147,7 @@ public static class NestRunner
Violations = result.Violations, Violations = result.Violations,
Fulfillment = fulfillment, Fulfillment = fulfillment,
StockUsage = usage, StockUsage = usage,
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
PlateStockMappings = result.Plates.Select((value, index) => PlateStockMappings = result.Plates.Select((value, index) =>
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(), new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
Nest = nest, Nest = nest,
@@ -209,11 +213,13 @@ public static class NestRunner
plate.Quantity, plate.Quantity,
plate.PartSpacing, plate.PartSpacing,
plate.EdgeSpacing, plate.EdgeSpacing,
plate.Quadrant plate.Quadrant,
plate.Cost
) )
); );
} }
NestJobValidator.ValidateStockCosts(stock);
return stock; return stock;
} }
@@ -242,11 +248,11 @@ public static class NestRunner
private static double CalculateUtilization(Nest nest) 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) if (sheetArea == 0)
return 0; return 0;
var placedArea = nest.Plates.Sum(plate => 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; return placedArea / sheetArea;
} }
+2 -2
View File
@@ -514,9 +514,9 @@ static class NestConsole
foreach (var violation in result.Violations) foreach (var violation in result.Violations)
Console.Error.WriteLine($"Violation: {violation}"); Console.Error.WriteLine($"Violation: {violation}");
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid)) if (!result.CanKeep || result.Raw.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
{ {
Console.Error.WriteLine(result.Plates.Count > 1 Console.Error.WriteLine(result.Raw.Plates.Count > 1
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved." ? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
: !result.CanKeep : !result.CanKeep
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved." ? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
+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. /// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
/// </summary> /// </summary>
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates, 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) AutomaticCutOffOptions options, CutOffSettings settings)
{ {
var plans = Create(plates, options, settings); var plans = Create(plates, options, settings);
if (plans.Any(plan => plan.HasBlockingDiagnostics)) if (plans.Any(plan => plan.HasBlockingDiagnostics))
return plans; return new AutomaticCutOffStaging(plans, []);
var restoreActions = new List<(int PlateNumber, Action Restore)>(); var restoreActions = new List<(int PlateNumber, Action Restore)>();
try try
@@ -66,21 +74,7 @@ public static class AutomaticCutOffBatch
} }
catch (Exception applyError) catch (Exception applyError)
{ {
var rollbackErrors = new List<Exception>(); var rollbackErrors = RestoreAll(restoreActions);
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));
}
}
if (rollbackErrors.Count > 0) if (rollbackErrors.Count > 0)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: " $"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
@@ -91,7 +85,27 @@ public static class AutomaticCutOffBatch
throw new InvalidOperationException( throw new InvalidOperationException(
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError); $"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) 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));
}
}
+178 -9
View File
@@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions
/// The desktop default is 12 inches (304.8 mm). /// The desktop default is 12 inches (304.8 mm).
/// </summary> /// </summary>
public double MinimumTailLength { get; set; } 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 public enum AutomaticCutOffDiagnosticCode
@@ -29,8 +35,10 @@ public enum AutomaticCutOffDiagnosticCode
LimitedCutOffConflict, LimitedCutOffConflict,
EmptyCut, EmptyCut,
SegmentedCut, SegmentedCut,
ClampBlockedCut,
NoSafeTailSeparator, NoSafeTailSeparator,
TailBelowMinimum, TailBelowMinimum,
ShiftedCut,
} }
public sealed record AutomaticCutOffDiagnostic( 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> /// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue; 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); 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 GeometryTolerance = Tolerance.Epsilon;
public const double MinimumSpacing = 2 * GeometryTolerance; public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000; public const int MaximumCandidateCount = 10000;
public const int MaximumShiftCandidateCount = 20000;
/// <summary> /// <summary>
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs. /// 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(options);
ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(settings);
ValidateInputs(plate, options, 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); var bounds = plate.BoundingBox(false);
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel), Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
"Physical sheet bounds and overtravel must be finite.", nameof(plate)); "Physical sheet bounds and overtravel must be finite.", nameof(plate));
@@ -130,14 +147,57 @@ public static class AutomaticCutOffPlanner
var diagnostics = new List<AutomaticCutOffDiagnostic>(); var diagnostics = new List<AutomaticCutOffDiagnostic>();
var separated = false; var separated = false;
var separatorX = (double?)null; 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. if (shifted)
for (var index = 1; index <= (int)candidateCount; index++)
{ {
var distance = index * options.Spacing; // Full-span definitions fix the strip boundaries, even when their emitted
if (distance >= usedSpan - GeometryTolerance) // programs are segmented. Partial cuts remain obstacles, not boundaries.
break; var boundaries = plate.CutOffs
AddCandidate(sign * distance, false); .Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings))
.Select(c => sign * c.Position.X)
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
.Distinct().OrderBy(distance => distance).Append(usedSpan);
var gaps = new List<(double Start, double End, int Count)>();
var start = 0.0;
var nominalCount = 0;
foreach (var end in boundaries)
{
var count = 0;
for (var index = 1; index <= (int)candidateCount; index++)
{
if (start + index * options.Spacing >= end - GeometryTolerance)
break;
count++;
}
gaps.Add((start, end, count));
nominalCount += count;
start = end;
}
// The origin-based lattice alone fits within 20,000 positions, but its
// off-lattice nominal lines need reserved slots too. Budget both before
// constructing any row so a valid, dense grid is never refused halfway.
Require(nominalCount < MaximumShiftCandidateCount,
"Too many nominal strip lines for the shift search.", nameof(options));
var step = System.Math.Max(options.Spacing / 700,
usedSpan / (MaximumShiftCandidateCount - nominalCount));
foreach (var gap in gaps)
AddShiftedGap(gap.Start, gap.End, gap.Count, step);
}
else
{
// Keep the legacy loop and certification unchanged when shifting is disabled.
for (var index = 1; index <= (int)candidateCount; index++)
{
var distance = index * options.Spacing;
if (distance >= usedSpan - GeometryTolerance)
break;
AddCandidate(sign * distance, false);
}
} }
if (hasTailCandidate) if (hasTailCandidate)
{ {
@@ -155,9 +215,14 @@ public static class AutomaticCutOffPlanner
{ {
// A partial proposal must not accidentally be accepted around a manual conflict. // A partial proposal must not accidentally be accepted around a manual conflict.
definitions.Clear(); definitions.Clear();
stripCutLength = 0;
separated = false; separated = false;
separatorX = null; 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 return new AutomaticCutOffPlan
{ {
@@ -168,12 +233,74 @@ public static class AutomaticCutOffPlanner
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length, UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0, TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
TailSeparatorX = separatorX, 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) void AddCandidate(double x, bool isSeparator)
{ {
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical && var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
Near(c.Position.X, x)).ToList(); Near(c.Position.X, x)).ToList();
if (matches.Any(c => !FullSpanLimits(c, bounds, settings))) if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
{ {
diagnostics.Add(new AutomaticCutOffDiagnostic( diagnostics.Add(new AutomaticCutOffDiagnostic(
@@ -185,6 +312,24 @@ public static class AutomaticCutOffPlanner
return; 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 // 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. // drawing can be stale, and a tolerance-close line can intersect a part at the tail.
var existing = matches.FirstOrDefault(); var existing = matches.FirstOrDefault();
@@ -202,7 +347,7 @@ public static class AutomaticCutOffPlanner
if (!usable) if (!usable)
diagnostics.Add(new AutomaticCutOffDiagnostic( diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.EmptyCut, 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)); X: x));
if (isSeparator) if (isSeparator)
@@ -218,16 +363,33 @@ public static class AutomaticCutOffPlanner
else if (usable && !IsFullSpanProgram(program, bounds)) else if (usable && !IsFullSpanProgram(program, bounds))
diagnostics.Add(new AutomaticCutOffDiagnostic( diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.SegmentedCut, 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)); X: x));
if (usable && existing == null) if (usable && existing == null)
{
definitions.Add(candidate); 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( void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator, 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)); X: x));
} }
@@ -235,6 +397,10 @@ public static class AutomaticCutOffPlanner
{ {
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing, Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options)); $"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), Require(Nonnegative(options.MinimumTailLength),
"Minimum tail length must be finite and nonnegative.", nameof(options)); "Minimum tail length must be finite and nonnegative.", nameof(options));
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 && Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
@@ -242,6 +408,9 @@ public static class AutomaticCutOffPlanner
"Sheet length and width must be positive and finite.", nameof(plate)); "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(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(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) && Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings)); Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", 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;
}
}
@@ -55,11 +55,13 @@ internal static class ContourProgramVerifier
var ringIndex = -1; var ringIndex = -1;
var entityIndex = -1; var entityIndex = -1;
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++) 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++) for (var e = 0; e < material.Rings[r].Length; e++)
{ {
Query(token, ref budget); Query(token, ref budget);
var curve = material.Rings[r][e]; 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) && curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{ {
ringIndex = r; ringIndex = r;
@@ -67,28 +69,39 @@ internal static class ContourProgramVerifier
break; break;
} }
} }
}
if (ringIndex < 0 || !visited.Add(ringIndex)) return false; if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
var ring = material.Rings[ringIndex]; 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. Direction, coverage order and retrace accounting stay at the epsilon.
var tolerance = AdmittedClosureSlack(ring);
var nominalLength = ring.Sum(c => c.Length); var nominalLength = ring.Sum(c => c.Length);
var cutLength = run.Curves.Sum(c => c.Length); var cutLength = run.Curves.Sum(c => c.Length);
var missing = nominalLength - cutLength; var missing = nominalLength - cutLength;
if (missing < -Epsilon) return false; // Never a second circuit/retrace. if (missing < -Epsilon) return false; // Never a second circuit/retrace.
if (missing > Epsilon) if (missing > tolerance)
{ {
// Fixed legacy payloads have no certified selected tab/entry metadata. // Fixed legacy payloads have no certified selected tab/entry metadata.
// A merely open contour or a caller's current tab switch proves nothing. // A merely open contour or a caller's current tab switch proves nothing.
if (selected == null || ringIndex != 0 || selected.TabSize <= 0 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; return false;
// SameEmission has already bound this exact terminal gap to the owned // SameEmission has already bound this exact terminal gap to the owned
// settings' independently emitted, rounded/clamped selected geometry. // settings' independently emitted, rounded/clamped selected geometry.
} }
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start), 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; return visited.Count == material.Rings.Count;
} }
// 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, private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
CancellationToken token, ref int budget) CancellationToken token, ref int budget)
{ {
@@ -107,15 +120,17 @@ internal static class ContourProgramVerifier
if (a.End.DistanceTo(b.End) > Epsilon) return false; 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 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; return true;
} }
// Consume directed native arc-length from both streams. Subdivision, merged // Consume directed native arc-length from both streams. Subdivision, merged
// collinear moves and a cyclic ring's reindexing do not change coverage. // collinear moves and a cyclic ring's reindexing do not change coverage.
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
// unless the nominal ring closes only within the nominal closure tolerance).
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected, private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
int index, double offset, bool cyclic, CancellationToken token, ref int budget) int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
{ {
foreach (var curve in actual) foreach (var curve in actual)
{ {
@@ -137,13 +152,13 @@ internal static class ContourProgramVerifier
continue; continue;
} }
var length = System.Math.Min(remaining, curve.Length - consumed); var length = System.Math.Min(remaining, curve.Length - consumed);
if (!nominal.SameDirection(curve) if (!nominal.SameDirection(curve, tolerance)
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon || nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > tolerance
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon) || nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > tolerance)
return false; return false;
consumed += length; consumed += length;
offset += length; offset += length;
if (offset >= nominal.Length - Epsilon) if (offset >= nominal.Length - tolerance)
{ {
index++; index++;
offset = 0; offset = 0;
@@ -151,7 +166,7 @@ internal static class ContourProgramVerifier
} }
} }
return cyclic || index == expected.Count || index == expected.Count - 1 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) private static List<Run> Runs(OwnedExecution execution)
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Diagnostics; using OpenNest.Diagnostics;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
@@ -46,7 +47,7 @@ public sealed class LeadMaterialSnapshot
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon) if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contour is discontinuous."); throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve); chain.Add(curve);
if (PostVerificationGeometry.Closed(chain)) if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish(); Finish();
} }
Finish(); Finish();
@@ -66,11 +67,23 @@ public sealed class LeadMaterialSnapshot
var b = rings[s][j]; var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap); var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1)); 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 if (overlap || contacts.Any(p => !adjacent
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon || !(p.DistanceTo(a.End) <= Tolerance.Epsilon
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon) && p.DistanceTo(b.Start) <= Tolerance.Epsilon)
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon))) && p.DistanceTo(b.End) <= Tolerance.Epsilon)))
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
} }
var outer = -1; var outer = -1;
@@ -97,8 +110,13 @@ public sealed class LeadMaterialSnapshot
{ {
if (chain.Count == 0) if (chain.Count == 0)
return; return;
if (!PostVerificationGeometry.Closed(chain)) if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly."); {
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()); rings.Add(chain.ToArray());
chain.Clear(); chain.Clear();
} }
@@ -113,7 +113,8 @@ public static class LeadPathValidator
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null; return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
} }
} }
// Missing leads are the ReleasedContourState check's responsibility. // 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); return new(true, true, null);
} }
catch (Exception ex) when (ex is ArgumentException or NotSupportedException) catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
@@ -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;
}
}
@@ -74,7 +74,7 @@ public sealed class PreparedContours
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon) if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Discontinuous native contour."); throw new ArgumentException("Discontinuous native contour.");
current.Entities.Add(entity); current.Entities.Add(entity);
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon) if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour(); FinishContour();
} }
if (current.Entities.Count != 0 || contours.Count == 0) if (current.Entities.Count != 0 || contours.Count == 0)
@@ -254,12 +254,11 @@ public sealed class PreparedContours
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner)) .Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
// Back INTO each incident edge from the corner: the edge ending at the // 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 // corner retreats against its own travel, the edge starting at the corner
// advances along its own travel. // advances along its own travel.
AddInset(corner.EntityOrdinal, cornerPoint, inward: false); AddInset(corner.EntityOrdinal, inward: false);
AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true); AddInset((corner.EntityOrdinal + 1) % count, inward: true);
} }
} }
@@ -276,7 +275,7 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
return fallbacks; 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 // `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 // point retreats against travel. Both ways move BACK INTO the edge from the
@@ -285,7 +284,9 @@ public sealed class PreparedContours
return; return;
var direction = (line.EndPoint - line.StartPoint).Normalize(); var direction = (line.EndPoint - line.StartPoint).Normalize();
var offset = direction * (2 * lead); 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 // Strictly inside the edge by projection parameter (distance alone loses the
// sign when a short edge is overshoot): never the corner, never the far // sign when a short edge is overshoot): never the corner, never the far
// endpoint (where a reflex vertex might sit). Short edges omit the point. // endpoint (where a reflex vertex might sit). Short edges omit the point.
@@ -510,6 +511,17 @@ public sealed class PreparedContours
return centres; 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 Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint(); private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
@@ -11,10 +11,20 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class ReleasedContourState public sealed class ReleasedContourState
{ {
private readonly List<Obstacle> obstacles = new(); 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() public ReleasedContourState Copy()
{ {
var copy = new ReleasedContourState(); var copy = new ReleasedContourState(reportMissingLeadIns);
copy.obstacles.AddRange(obstacles); copy.obstacles.AddRange(obstacles);
return copy; return copy;
} }
@@ -68,7 +78,13 @@ public sealed class ReleasedContourState
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + $"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)
});
} }
} }
else if (move.Layer == LayerType.Leadin) else if (move.Layer == LayerType.Leadin)
@@ -92,9 +108,10 @@ public sealed class ReleasedContourState
if (contour.Count == 0) if (contour.Count == 0)
{ {
contourNumber++; contourNumber++;
if (!cutoff && !hasLead) if (reportMissingLeadIns && !cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")); $"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
{ Location = move.Curve.Start });
hasLead = false; hasLead = false;
// A rapid can pause/reposition without leaving any material gap. // A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles // Retain already-cut fragments, but do not turn them into obstacles
@@ -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);
}
+20 -8
View File
@@ -31,16 +31,16 @@ namespace OpenNest.CNC
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results) private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
{ {
// Draw the rapid from the previous tool position to the program's first // Draw the approach to the first rapid destination, or to the program
// pierce point. The walk then starts at the program origin (basePos), not // origin when it starts cutting without a rapid. Only an initial rapid
// the pierce: the skipped first rapid still advances pos, so starting at // (or call arrival) is already represented by this approach; a later
// the pierce would apply a nonzero Incremental first delta twice (as in // first rapid must remain visible. Walk starts at the program origin,
// lead-in programs) and shift every later rapid by it. // so an incremental initial rapid is not applied twice.
var firstPierce = FirstPiercePoint(pgm, basePos); var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce)); results.Add(new Segment(startPos, firstPierce));
var pos = basePos; 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. // The last rapid ends at a pierce, not necessarily the final tool position.
return pos; return pos;
} }
@@ -53,11 +53,23 @@ namespace OpenNest.CNC
return FirstPiercePoint(call.Program, basePos + call.Offset); return FirstPiercePoint(call.Program, basePos + call.Offset);
if (pgm[i] is Motion motion) if (pgm[i] is Motion motion)
return motion.EndPoint + basePos; return motion is RapidMove ? motion.EndPoint + basePos : basePos;
} }
return 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( private static void Walk(
Program pgm, Program pgm,
Vector basePos, Vector basePos,
@@ -83,7 +95,7 @@ namespace OpenNest.CNC
results.Add(new Segment(pos, firstPierce)); results.Add(new Segment(pos, firstPierce));
var subPos = holeBase; var subPos = holeBase;
Walk(program, holeBase, ref subPos, skipFirst: true, results); Walk(program, holeBase, ref subPos, skipFirst: StartsWithRapidOrCall(program), results);
pos = subPos; pos = subPos;
} }
else if (code is Motion motion) else if (code is Motion motion)
+183
View File
@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// The machine's fixed clamp rail: identical rectangular clamps whose X centers are
/// measured from the plate origin along the origin-side (operator) 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;
public ClampRail(
double width,
double depth,
IEnumerable<double> centers,
double removalMinutes = DefaultRemovalMinutes
)
{
ArgumentNullException.ThrowIfNull(centers);
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<double>();
foreach (var center in centers)
{
if (!double.IsFinite(center) || center < 0)
throw new ArgumentOutOfRangeException(
nameof(centers),
"Clamp centers must be finite and measured from the origin (zero or more)."
);
sorted.Add(center);
}
Width = width;
Depth = depth;
RemovalMinutes = removalMinutes;
Centers = sorted.ToArray();
}
/// <summary>Clamp extent along the edge (X).</summary>
public double Width { get; }
/// <summary>Clamp intrusion into the sheet from the origin-side edge (Y).</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 clamp centers along X from the origin, ascending.</summary>
public IReadOnlyList<double> Centers { get; }
/// <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 length = size.Length;
var sheetWidth = size.Width;
if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth))
return Array.Empty<ClampZone>();
var extendsLeft = quadrant is 2 or 3;
var extendsDown = quadrant is 3 or 4;
// The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y.
var y = extendsDown ? sheetWidth - Depth : 0;
var half = Width / 2;
var zones = new List<ClampZone>(Centers.Count);
for (var i = 0; i < Centers.Count; i++)
{
// Distances from the origin along the edge.
var near = Centers[i] - half;
var far = Centers[i] + half;
if (near >= length)
continue; // wholly beyond this sheet
// 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;
zones.Add(new ClampZone($"clamp-{i + 1}", new Box(x, y, Width, Depth)));
}
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)
&& Centers.SequenceEqual(other.Centers);
public override bool Equals(object obj) => Equals(obj as ClampRail);
public override int GetHashCode()
{
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Centers.Count);
foreach (var center in Centers)
hash = HashCode.Combine(hash, center);
return hash;
}
public override string ToString() =>
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Centers.Select(c => c.ToString("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;
}
}
@@ -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,
};
}
+91 -2
View File
@@ -39,6 +39,72 @@ namespace OpenNest
Drawing.Program = program; 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() private string GetName()
{ {
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H"; var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
@@ -134,8 +200,22 @@ namespace OpenNest
double clearance double clearance
) )
{ {
var bb = part.BoundingBox; var cached = part.BoundingBox;
var roundoff = GetBoundsRoundoff(part); 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 (partMin, partMax) = AxisBounds(bb, clearance);
var (partStart, partEnd) = CrossAxisBounds(bb, clearance); var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
// Match the planner's representational allowance in BOTH pruning and // Match the planner's representational allowance in BOTH pruning and
@@ -150,6 +230,8 @@ namespace OpenNest
if (perimeter != null) if (perimeter != null)
{ {
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
return new List<(double Start, double End)> { (partStart, partEnd) };
var perimeterExclusions = IntersectPerimeter( var perimeterExclusions = IntersectPerimeter(
perimeter, perimeter,
cutPosition, cutPosition,
@@ -180,7 +262,14 @@ namespace OpenNest
magnitude = System.Math.Max(magnitude, magnitude = System.Math.Max(magnitude,
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y))); System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
var step = System.Math.BitIncrement(magnitude) - magnitude; 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.
var arcFitRoundoff = System.Math.Min(4096 * step, 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( private List<(double Start, double End)> IntersectPerimeter(
@@ -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);
}
@@ -11,20 +11,71 @@ public sealed class OverlapGeometryStamp
{ {
private readonly Plate plate; private readonly Plate plate;
private readonly Entry[] entries; 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) private OverlapGeometryStamp(Plate plate)
{ {
this.plate = plate; this.plate = plate;
sheet = SheetState(plate);
entries = new Entry[plate.Parts.Count]; entries = new Entry[plate.Parts.Count];
for (var i = 0; i < entries.Length; i++) for (var i = 0; i < entries.Length; i++)
entries[i] = new Entry(plate.Parts[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); 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) 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; return false;
for (var i = 0; i < entries.Length; i++) for (var i = 0; i < entries.Length; i++)
if (!entries[i].Matches(current.Parts[i])) if (!entries[i].Matches(current.Parts[i]))
@@ -9,12 +9,19 @@ namespace OpenNest.Diagnostics;
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary> /// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
public static class OverlapPairPresentation 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) public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
{ {
var units = UnitsHelper.GetShortString(capturedUnits); 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" + $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}"; + $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
} }
@@ -21,14 +21,22 @@ public sealed class OverlapReportState
public PlateOverlapReport Report { get; private set; } public PlateOverlapReport Report { get; private set; }
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary> /// <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<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 bool IsRunning => Status == OverlapCheckStatus.Checking;
public string Message => Status switch public string Message => Status switch
{ {
OverlapCheckStatus.Checking => "Checking overlaps…", OverlapCheckStatus.Checking => "Checking overlaps…",
OverlapCheckStatus.Current => Report.Pairs.Count == 0 OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs", ? (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; " 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", + $"{uncheckedPartCount} parts could not be checked",
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again", OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
OverlapCheckStatus.Canceled => "Overlap check canceled", OverlapCheckStatus.Canceled => "Overlap check canceled",
@@ -55,7 +63,9 @@ public sealed class OverlapReportState
if (!CanComplete(generation, plate)) if (!CanComplete(generation, plate))
return false; return false;
Report = report; Report = report;
DisplayPairs = report.Pairs; DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
DisplayCutOffClampFindings = report.CutOffClampFindings;
displayStamp = stamp; displayStamp = stamp;
observedDisplayStamp = stamp; observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues); uncheckedPartCount = CountUncheckedParts(report.Issues);
@@ -106,10 +116,14 @@ public sealed class OverlapReportState
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true) if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
return; return;
var unchanged = displayStamp.UnchangedSlots(plate); 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) if (retained.Count != DisplayPairs.Count)
DisplayPairs = retained.AsReadOnly(); DisplayPairs = retained.AsReadOnly();
if (DisplayPairs.Count == 0) if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
ClearDisplayPairs(); ClearDisplayPairs();
else else
observedDisplayStamp = OverlapGeometryStamp.Capture(plate); observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
@@ -118,6 +132,7 @@ public sealed class OverlapReportState
private void ClearDisplayPairs() private void ClearDisplayPairs()
{ {
DisplayPairs = Array.Empty<PlateOverlapPair>(); DisplayPairs = Array.Empty<PlateOverlapPair>();
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
displayStamp = null; displayStamp = null;
observedDisplayStamp = null; observedDisplayStamp = null;
} }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -16,13 +17,41 @@ public static class PlateOverlapAnalyzer
{ {
public const double ChordTolerance = 0.001; 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> /// <summary>
/// Captures poses and converts each distinct clean source program to owned entities once. /// 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. /// Inputs must not change during capture. Later analysis never reads live domain objects.
/// </summary> /// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
CancellationToken cancellationToken = default) => 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> /// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted /// 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"/>). /// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
/// </summary> /// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache, 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) CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(parts); ArgumentNullException.ThrowIfNull(parts);
@@ -63,8 +98,21 @@ public static class PlateOverlapAnalyzer
issues.Add(new PlateOverlapIssue(id, null, exception.Message)); 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(); cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapSnapshot(captured, issues); return new PlateOverlapSnapshot(captured, issues, clamps);
} }
private static OverlapSource CaptureSource(Program program) private static OverlapSource CaptureSource(Program program)
@@ -73,8 +121,9 @@ public static class PlateOverlapAnalyzer
{ {
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false); ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
// Conversion creates fresh geometry, including expanded shared hole calls; // Conversion creates fresh geometry, including expanded shared hole calls;
// no cloning/rotation of a live program or subprogram is necessary. // no cloning/rotation of a live program or subprogram is necessary. A baked-in
return new OverlapSource(program, ConvertProgram.ToGeometry(program) // 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) .Where(entity => SpecialLayers.IsMaterial(entity.Layer)
&& entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadin
&& entity.Layer != SpecialLayers.Leadout).ToList(), null); && entity.Layer != SpecialLayers.Leadout).ToList(), null);
@@ -192,9 +241,120 @@ public static class PlateOverlapAnalyzer
} }
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId) return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.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);
var result = Collision.Check(local.Outer, rect, local.Holes, null);
if (!result.Overlaps)
continue;
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("Clamp 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 clamp overlap area moments are invalid.");
var centroid = combined.Centroid + origin;
if (!OverlapMaterial.IsFinite(centroid))
throw new ArithmeticException("Clamp overlap centroid is not finite in world coordinates.");
findings.Add(new PlateOverlapPair(part.Input.Id,
ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
$"Clamp '{clamp.Id}'", regions, centroid));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message));
}
}
}
return findings;
}
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),
};
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);
}
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> /// <summary>
@@ -8,11 +8,14 @@ namespace OpenNest.Diagnostics;
public sealed class PlateOverlapReport public sealed class PlateOverlapReport
{ {
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues, internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
PlateOverlapSnapshot snapshot) PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
List<CutOffClampFinding> cutOffClampFindings = null)
{ {
Pairs = pairs.AsReadOnly(); Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly(); Issues = issues.AsReadOnly();
Snapshot = snapshot; 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> /// <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<PlateOverlapPair> Pairs { get; }
public IReadOnlyList<PlateOverlapIssue> Issues { 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 bool IsComplete => Issues.Count == 0;
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance; public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
} }
@@ -102,15 +115,32 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
/// </summary> /// </summary>
public sealed class PlateOverlapSnapshot 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(); Parts = parts.AsReadOnly();
Issues = issues.AsReadOnly(); Issues = issues.AsReadOnly();
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
} }
internal IReadOnlyList<CapturedOverlapPart> Parts { get; } internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
internal IReadOnlyList<PlateOverlapIssue> Issues { 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, internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
double Rotation, Vector Location); 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 materialParts = new List<Part>();
var indices = new List<int>(); 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(); var obstacles = new ReleasedContourState();
Vector? position = startPoint; Vector? position = startPoint;
for (var index = 0; index < plate.Parts.Count; index++) for (var index = 0; index < plate.Parts.Count; index++)
@@ -95,6 +140,46 @@ public static class PostVerificationAnalyzer
&& move.Curve.Length > PostVerificationGeometry.Epsilon)) && move.Curve.Length > PostVerificationGeometry.Epsilon))
Incomplete("Placed program has no cutting contour motions for this drawing."); 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);
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; position = moves[^1].End;
} }
catch (Exception exception) when (IsInvalid(exception)) catch (Exception exception) when (IsInvalid(exception))
@@ -110,10 +195,17 @@ public static class PostVerificationAnalyzer
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var overlap = PlateOverlapAnalyzer.Analyze( 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) foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId], 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.Centroid, 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.Centroid, Overlap = pair });
foreach (var issue in overlap.Issues) foreach (var issue in overlap.Issues)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId], findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message)); issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
@@ -122,6 +214,19 @@ public static class PostVerificationAnalyzer
return new PostVerificationReport(findings); return new PostVerificationReport(findings);
} }
// Lines are tested exactly; an arc conservatively by its whole supporting extent.
private 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 private static bool IsInvalid(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException; ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
@@ -9,6 +9,10 @@ namespace OpenNest.Diagnostics;
internal static class PostVerificationGeometry internal static class PostVerificationGeometry
{ {
internal const double Epsilon = 1e-8; 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; private const double TwoPi = 2 * System.Math.PI;
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary> /// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
@@ -89,6 +93,30 @@ internal static class PostVerificationGeometry
return inside; 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 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 Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
private static double Normalize(double angle) private static double Normalize(double angle)
@@ -106,14 +134,20 @@ internal static class PostVerificationGeometry
Center = center; Center = center;
Radius = radius; Radius = radius;
Sweep = sweep; 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 Start { get; }
internal Vector End { get; } internal Vector End { get; }
private Vector? Center { get; } private Vector? Center { get; }
private double Radius { get; } private double Radius { get; }
private double Sweep { get; } private double Sweep { get; }
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End); 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. // Native entities are freshly allocated; the immutable curve never exposes state.
internal Entity ToEntity() => Center is { } center internal Entity ToEntity() => Center is { } center
@@ -138,20 +172,31 @@ internal static class PostVerificationGeometry
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2)) center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
: (Start + End) * 0.5; : (Start + End) * 0.5;
internal bool SameSupport(Curve other) 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) if (Center is { } center)
return other.Center is { } c && center.DistanceTo(c) <= Epsilon return other.Center is { } c && center.DistanceTo(c) <= positionTolerance
&& System.Math.Abs(Radius - other.Radius) <= Epsilon; && System.Math.Abs(Radius - other.Radius) <= positionTolerance;
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon) if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance)
return false; return false;
var direction = (End - Start) * (1 / Length); var direction = (End - Start) * (1 / Length);
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon; && System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance;
} }
// Directed native arc-length coordinates for contour replay, not collision queries. // 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) && (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
: Dot(End - Start, other.End - other.Start) > 0); : Dot(End - Start, other.End - other.Start) > 0);
@@ -170,12 +215,25 @@ internal static class PostVerificationGeometry
: Start + (End - Start) * (distance / Length); : Start + (End - Start) * (distance / Length);
} }
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon; 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) internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
{ {
var entity = ToEntity(); var entity = nativeEntity.Value;
var candidate = other.ToEntity(); var candidate = other.nativeEntity.Value;
// Native arc filters discard NaN supporting-circle intersections. Inspect // Native arc filters discard NaN supporting-circle intersections. Inspect
// both unfiltered queries first: roundoff can differ by operand direction. // both unfiltered queries first: roundoff can differ by operand direction.
// Coincident supports have separate overlap handling below. // Coincident supports have separate overlap handling below.
@@ -203,18 +261,21 @@ internal static class PostVerificationGeometry
points.Clear(); points.Clear();
// Coincident circles produce NaNs in the native discrete-contact query. // Coincident circles produce NaNs in the native discrete-contact query.
// Their support overlap is handled separately, without inventing crossings. // Their support overlap is handled separately, without inventing crossings.
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other) // Supports that agree only within Epsilon (arcs of one circle whose centers were
|| InteriorWitness(other.Midpoint, this) // each derived from their own endpoints) put a witness up to that far off the other
|| InteriorWitness(Start, other) || InteriorWitness(End, other) // support. Test those spans at the admitted seam tolerance instead: shared spans
|| InteriorWitness(other.Start, this) || InteriorWitness(other.End, this)); // 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)) if (SameSupport(other))
points.Clear(); points.Clear();
foreach (var point in points) foreach (var point in points)
Validate(point); Validate(point);
var nativeContactCount = points.Count; 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 }) foreach (var point in new[] { Start, End, other.Start, other.End })
if (Contains(point) && other.Contains(point) if (Contains(point) && other.Contains(point)
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon)) && !points.Exists(p => p.DistanceTo(point) <= Epsilon))
@@ -227,9 +288,9 @@ internal static class PostVerificationGeometry
throw new NotSupportedException("Native contact query is numerically uncertain."); throw new NotSupportedException("Native contact query is numerically uncertain.");
return points; 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 && (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> /// <summary>
@@ -281,7 +342,8 @@ internal static class PostVerificationGeometry
// The largest distance from the centre that ContactAfterStart can count as contact. // The largest distance from the centre that ContactAfterStart can count as contact.
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack); 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) if (Center is { } center)
{ {
@@ -294,10 +356,13 @@ internal static class PostVerificationGeometry
if (square < -ContactSlack) if (square < -ContactSlack)
return false; return false;
var offset = System.Math.Sqrt(System.Math.Max(0, square)); 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 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 edge = End - Start;
var relativeStart = Start - origin; var relativeStart = Start - origin;
@@ -310,19 +375,36 @@ internal static class PostVerificationGeometry
var b = Dot(End - origin, direction); var b = Dot(End - origin, direction);
var low = System.Math.Max(0, System.Math.Min(a, b)); var low = System.Math.Max(0, System.Math.Min(a, b));
var high = System.Math.Min(length, System.Math.Max(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 distanceAlongRapid = Cross(relativeStart, edge) / denominator;
var fractionAlongEdge = Cross(relativeStart, direction) / denominator; var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
&& fractionAlongEdge >= -Epsilon / System.Math.Max(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) 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) 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; && 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 // Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
@@ -343,7 +425,7 @@ internal static class PostVerificationGeometry
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i]; var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a); 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); 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; continue;
var dy = point.Y - center.Y; var dy = point.Y - center.Y;
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy)); 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.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics; namespace OpenNest.Diagnostics;
@@ -15,7 +16,17 @@ public enum PostVerificationKind
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary> /// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber, 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> /// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
public sealed class PostVerificationReport public sealed class PostVerificationReport
@@ -29,10 +40,12 @@ public sealed class PostVerificationReport
public bool HasWarnings => Findings.Count != 0; public bool HasWarnings => Findings.Count != 0;
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged; 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(); var text = new StringBuilder();
text.AppendLine("Pre-post verification"); text.AppendLine(title);
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete); var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
Summary(PostVerificationKind.Overlap, "Overlap"); Summary(PostVerificationKind.Overlap, "Overlap");
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins"); Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
@@ -46,16 +59,16 @@ public sealed class PostVerificationReport
text.Append($", other part {other}"); text.Append($", other part {other}");
text.AppendLine($": {finding.Kind}: {finding.Message}"); text.AppendLine($": {finding.Kind}: {finding.Message}");
} }
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " + text.AppendLine("These checks cover direct XY rapids in plate/program order; the post may " +
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " + "change order, routing or retracts. Inspect the posted machine program and machine setup " +
"machine program and machine setup. Actual contour gaps are not proof of adequate retention."); "before cutting, and confirm actual contour gaps give adequate retention.");
return text.ToString(); return text.ToString();
void Summary(PostVerificationKind kind, string label) void Summary(PostVerificationKind kind, string label)
{ {
var count = Findings.Count(finding => finding.Kind == kind); var count = Findings.Count(finding => finding.Kind == kind);
text.AppendLine($"{label}: {count} warning(s)" + text.AppendLine($"{label}: {count} warning(s)" +
(incomplete ? "; verification incomplete — do not treat as clear." : ".")); (incomplete ? "; verification incomplete — review before posting." : "."));
} }
} }
} }
+10
View File
@@ -80,6 +80,16 @@ namespace OpenNest.Geometry
return Separation(linesA, linesB); 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> /// <summary>
/// Minimum boundary distance between two non-overlapping rings and the /// Minimum boundary distance between two non-overlapping rings and the
/// direction that translates <paramref name="linesA"/> away from /// direction that translates <paramref name="linesA"/> away from
+1 -1
View File
@@ -953,7 +953,7 @@ namespace OpenNest.Geometry
OldSampleCount = old.TotalSamples, OldSampleCount = old.TotalSamples,
DiagnosticIoU = iou, DiagnosticIoU = iou,
FailureMessage = (reasons & (AlignmentReasons.FailedConvergence | AlignmentReasons.InsufficientSupport)) != 0 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, : 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;
}
}
+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;
}
+25 -1
View File
@@ -65,6 +65,21 @@ namespace OpenNest
return plate; 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.Centers.Count == 0)
rail = null;
PlateDefaults.ClampRail = rail;
foreach (var plate in Plates)
plate.SetClampRail(rail);
}
public void UpdateDrawingQuantities() public void UpdateDrawingQuantities()
{ {
foreach (var drawing in Drawings) foreach (var drawing in Drawings)
@@ -114,6 +129,12 @@ namespace OpenNest
set { plate.PartSpacing = value; } 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) public void SetFromExisting(Plate plate)
{ {
Quadrant = plate.Quadrant; Quadrant = plate.Quadrant;
@@ -124,7 +145,7 @@ namespace OpenNest
public Plate CreateNew() public Plate CreateNew()
{ {
return new Plate() var created = new Plate()
{ {
Size = Size, Size = Size,
EdgeSpacing = EdgeSpacing, EdgeSpacing = EdgeSpacing,
@@ -132,6 +153,9 @@ namespace OpenNest
Quadrant = Quadrant, Quadrant = Quadrant,
Quantity = 1, Quantity = 1,
}; };
if (ClampRail != null)
created.SetClampRail(ClampRail);
return created;
} }
} }
} }
+123 -3
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -55,6 +56,10 @@ namespace OpenNest
Parts.ItemAdded += Parts_PartAdded; Parts.ItemAdded += Parts_PartAdded;
Parts.ItemRemoved += Parts_PartRemoved; Parts.ItemRemoved += Parts_PartRemoved;
CutOffs = new ObservableList<CutOff>(); CutOffs = new ObservableList<CutOff>();
ClampZones = new ObservableList<ClampZone>();
ClampZones.ItemAdded += (_, _) => OnClampZonesChanged();
ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged();
ClampZones.ItemChanged += (_, _) => OnClampZonesChanged();
Quadrant = 1; Quadrant = 1;
} }
@@ -81,9 +86,20 @@ namespace OpenNest
public Spacing EdgeSpacing; public Spacing EdgeSpacing;
/// <summary> /// <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> /// </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; } public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
@@ -102,6 +118,106 @@ namespace OpenNest
/// </summary> /// </summary>
public ObservableList<CutOff> CutOffs { get; set; } 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 as a last resort (D8) so a part that could not
/// be nested anywhere with every clamp in place still fits. 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());
}
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> /// <summary>
/// Regenerates all cut-off drawings and materializes them as parts. /// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is /// Existing cut-off parts are removed first, then each cut-off is
@@ -236,7 +352,11 @@ namespace OpenNest
public int Quadrant public int Quadrant
{ {
get { return quadrant; } get { return quadrant; }
set { quadrant = value <= 4 && value > 0 ? value : 1; } set
{
quadrant = value <= 4 && value > 0 ? value : 1;
ResolveClampRail();
}
} }
/// <summary> /// <summary>
+44
View File
@@ -119,6 +119,50 @@ namespace OpenNest
SubscribeToTailPlates(); 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() public void BeginBatch()
{ {
batching = true; batching = true;
@@ -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;
}
}
}
+8 -1
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.Linq; using System.Linq;
using OpenNest.Api; using OpenNest.Api;
using OpenNest.CNC; using OpenNest.CNC;
@@ -43,6 +43,10 @@ namespace OpenNest
pos = endpt; 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; info *= plate.Quantity;
return info; return info;
@@ -91,6 +95,9 @@ namespace OpenNest
time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks); 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; return time;
} }
} }
+11
View File
@@ -10,6 +10,13 @@ namespace OpenNest
public double CutDistance; 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) public static TimingInfo operator +(TimingInfo info1, TimingInfo info2)
{ {
return new TimingInfo return new TimingInfo
@@ -18,6 +25,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount + info2.IntersectionCount, IntersectionCount = info1.IntersectionCount + info2.IntersectionCount,
TravelDistance = info1.TravelDistance + info2.TravelDistance, TravelDistance = info1.TravelDistance + info2.TravelDistance,
PierceCount = info1.PierceCount + info2.PierceCount, PierceCount = info1.PierceCount + info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes + info2.ClampRemovalMinutes,
}; };
} }
@@ -29,6 +37,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount - info2.IntersectionCount, IntersectionCount = info1.IntersectionCount - info2.IntersectionCount,
TravelDistance = info1.TravelDistance - info2.TravelDistance, TravelDistance = info1.TravelDistance - info2.TravelDistance,
PierceCount = info1.PierceCount - info2.PierceCount, PierceCount = info1.PierceCount - info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes - info2.ClampRemovalMinutes,
}; };
} }
@@ -40,6 +49,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount * info2.IntersectionCount, IntersectionCount = info1.IntersectionCount * info2.IntersectionCount,
TravelDistance = info1.TravelDistance * info2.TravelDistance, TravelDistance = info1.TravelDistance * info2.TravelDistance,
PierceCount = info1.PierceCount * info2.PierceCount, PierceCount = info1.PierceCount * info2.PierceCount,
ClampRemovalMinutes = info1.ClampRemovalMinutes * info2.ClampRemovalMinutes,
}; };
} }
@@ -51,6 +61,7 @@ namespace OpenNest
IntersectionCount = info1.IntersectionCount * factor, IntersectionCount = info1.IntersectionCount * factor,
TravelDistance = info1.TravelDistance * factor, TravelDistance = info1.TravelDistance * factor,
PierceCount = info1.PierceCount * factor, PierceCount = info1.PierceCount * factor,
ClampRemovalMinutes = info1.ClampRemovalMinutes * factor,
}; };
} }
} }
+132
View File
@@ -0,0 +1,132 @@
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<double> _centers = 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;
_centers.AddRange(initial.Centers);
}
/// <summary>The nest's units; every value is in these units.</summary>
public Units Units { get; }
/// <summary>Clamp extent along the edge (X).</summary>
public double Width { get; set; }
/// <summary>Clamp intrusion into the sheet (Y).</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>Clamp centers along X from the plate origin, ascending and distinct.</summary>
public IReadOnlyList<double> Centers => _centers;
/// <summary>
/// Adds a clamp center, keeping the list sorted. Refuses (with an operator-facing
/// reason) values that are not finite, negative, or already listed.
/// </summary>
public bool TryAddCenter(double center, out string? error)
{
if (!double.IsFinite(center) || center < 0)
{
error = "Enter a clamp center measured from the plate origin (zero or more).";
return false;
}
var index = _centers.BinarySearch(center);
if (index >= 0)
{
error = $"A clamp is already centered at {center:0.####}.";
return false;
}
_centers.Insert(~index, center);
error = null;
return true;
}
public void RemoveCenterAt(int index)
{
if (index >= 0 && index < _centers.Count)
_centers.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;
_centers.Clear();
_centers.AddRange(converted.Centers.Distinct().OrderBy(c => c));
}
/// <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() =>
_centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, 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."
);
return new ClampSettings
{
Enabled = ApplyToNewNests && _centers.Count > 0,
Units = Units,
Width = Width,
Depth = Depth,
RemovalMinutes = RemovalMinutes,
Centers = _centers.ToArray(),
};
}
}
+334
View File
@@ -0,0 +1,334 @@
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>Clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
/// <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) =>
new()
{
Enabled = enabled,
Units = units,
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
Centers = rail?.Centers.ToArray() ?? Array.Empty<double>(),
};
/// <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 || Centers.Count == 0)
return null;
var converted = ConvertedTo(units);
return new ClampRail(converted.Width, converted.Depth, converted.Centers, 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(),
};
}
/// <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))
{
if (centers.ValueKind == JsonValueKind.Array)
{
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;
}
settings.Centers = valid.Distinct().OrderBy(x => x).ToArray();
}
else
{
ignored = true;
}
}
status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok;
return settings;
}
/// <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
{
version = CurrentVersion,
enabled = Enabled,
units = Units.ToString().ToLowerInvariant(),
width = Width,
depth = Depth,
removalMinutes = RemovalMinutes,
centers = Centers,
};
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;
}
+46
View File
@@ -34,4 +34,50 @@ public interface INestRepository
Guid id, NestRecord record, CancellationToken cancellationToken = default); Guid id, NestRecord record, CancellationToken cancellationToken = default);
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>Reads bounded immutable history; unsupported backends fail closed.</summary>
Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support nest version history.");
Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support nest version history.");
Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded metadata edits.");
Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nests.");
Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nest history.");
Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support deleted nest history.");
Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded deletion.");
Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded restoration.");
Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded recovery.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not support guarded saves. Upgrade the nest server and reload.");
} }
+24 -10
View File
@@ -11,18 +11,32 @@ public class MachineConfig
public UnitSystem Units { get; set; } = UnitSystem.Inches; public UnitSystem Units { get; set; } = UnitSystem.Inches;
public List<MaterialConfig> Materials { get; set; } = new(); public List<MaterialConfig> Materials { get; set; } = new();
public ThicknessConfig? GetParameters(string material, double thickness) public ThicknessConfig? GetParameters(string material, double thickness) =>
GetParameters(material, null, thickness);
public ThicknessConfig? GetParameters(string material, string? grade, double thickness)
{ {
var mat = GetMaterial(material); var mat = grade is null ? GetMaterial(material) : GetMaterial(material, grade);
if (mat is null) return mat?.Thicknesses.FirstOrDefault(t => t.Value.IsEqualTo(thickness));
return null;
return mat.Thicknesses.FirstOrDefault(t => t.Value.IsEqualTo(thickness));
} }
public MaterialConfig? GetMaterial(string name) /// <summary>
{ /// Returns the first material matching <paramref name="name"/>, regardless of
return Materials.FirstOrDefault(m => /// grade. Use <see cref="GetMaterial(string, string)"/> when the material may
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase) /// have more than one grade on file.
/// </summary>
public MaterialConfig? GetMaterial(string name) =>
Materials.FirstOrDefault(m => SharedListNames.Key(m.Name) == SharedListNames.Key(name));
public MaterialConfig? GetMaterial(string name, string grade) =>
Materials.FirstOrDefault(m =>
SharedListNames.Key(m.Name) == SharedListNames.Key(name)
&& SharedListNames.Key(m.Grade) == SharedListNames.Key(grade)
); );
}
/// <summary>
/// Returns every grade on file for the given material name.
/// </summary>
public IEnumerable<MaterialConfig> GetGrades(string name) =>
Materials.Where(m => SharedListNames.Key(m.Name) == SharedListNames.Key(name));
} }
+4 -1
View File
@@ -15,7 +15,8 @@ public sealed class NestBrowseSession : IDisposable
private int _generation; private int _generation;
private bool _disposed; private bool _disposed;
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit) public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit,
NestSortField sort = NestSortField.SavedAt, bool descending = true)
{ {
ArgumentNullException.ThrowIfNull(repository); ArgumentNullException.ThrowIfNull(repository);
if (pageSize < 1 || pageSize > NestQuery.MaxLimit) if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
@@ -23,6 +24,8 @@ public sealed class NestBrowseSession : IDisposable
_repository = repository; _repository = repository;
PageSize = pageSize; PageSize = pageSize;
Sort = Enum.IsDefined(sort) ? sort : NestSortField.SavedAt;
Descending = descending;
} }
public int PageSize { get; } public int PageSize { get; }
+182
View File
@@ -0,0 +1,182 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// URL-bound, bounded history browsing. Historical reads never bind an editable document.
/// A selection change cancels and invalidates previous reads even if a backend ignores cancellation.
/// Mutations use the displayed current revision; an uncertain failure requires an explicit refresh.
/// </summary>
public sealed class NestHistorySession : IDisposable
{
private readonly INestRepository repository;
private CancellationTokenSource? pending;
private int generation;
private bool disposed;
public string ServerUrl { get; }
public Guid NestId { get; private set; }
public bool Deleted { get; private set; }
public NestRecord? Current { get; private set; }
public NestRevisionPage? Page { get; private set; }
public bool IsMutating { get; private set; }
public const int PageSize = 50;
public NestHistorySession(INestRepository repository, string serverUrl)
{
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
ServerUrl = Normalize(serverUrl);
}
public async Task<bool> LoadAsync(Guid id, bool deleted = false, int offset = 0,
CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
if (id == Guid.Empty || offset < 0) throw new ArgumentOutOfRangeException(nameof(id));
pending?.Cancel();
var run = ++generation;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
pending = cts;
NestId = id;
Deleted = deleted;
Current = null;
Page = null;
try
{
var record = deleted ? await repository.GetDeletedMetadataAsync(id, cts.Token)
: await repository.GetMetadataAsync(id, cts.Token);
if (record == null) throw new FileNotFoundException("This nest is no longer in this view. Refresh the list.");
ValidateRecord(record, id, deleted);
if (record.Revision < 1) throw new NotSupportedException("Upgrade the nest server to browse or change version history. Current nests can still be opened read-only.");
var page = deleted ? await repository.GetDeletedRevisionsAsync(id, offset, PageSize, cts.Token)
: await repository.GetRevisionsAsync(id, offset, PageSize, cts.Token);
if (page.Offset != offset || page.Items.Count > PageSize || page.Total < offset + page.Items.Count
|| page.Items.Any(r => r.Id != id || r.Revision < 1))
throw new InvalidDataException("The server returned invalid nest history.");
if (page.Items.Any(r => r.Revision > record.Revision))
throw new IOException("The current version changed while loading history. Refresh to review the new current version.");
if (run != generation || cts.IsCancellationRequested || disposed) return false;
Current = record;
Page = page;
return true;
}
catch (Exception) when (run != generation || cts.IsCancellationRequested || disposed) { return false; }
finally { if (ReferenceEquals(pending, cts)) pending = null; }
}
public async Task<byte[]?> DownloadAsync(int revision, string serverUrl, CancellationToken ct = default)
{
EnsureReady(serverUrl);
var id = NestId;
var run = generation;
var snapshot = Page!.Items.SingleOrDefault(r => r.Revision == revision)
?? throw new InvalidOperationException("Select a revision on the displayed page.");
var file = Deleted ? await repository.GetDeletedRevisionFileAsync(id, revision, ct)
: await repository.GetRevisionFileAsync(id, revision, ct);
ct.ThrowIfCancellationRequested();
if (disposed || run != generation) return null;
if (file == null || file.LongLength != snapshot.FileSize)
throw new InvalidDataException("The revision archive is missing or does not match its metadata. Refresh before trying again.");
return file;
}
public async Task<NestRecord> RestoreAsync(int sourceRevision, string serverUrl, CancellationToken ct = default)
{
EnsureReady(serverUrl);
if (Deleted) throw new InvalidOperationException("Recover the deleted nest before restoring an earlier version.");
if (!Page!.Items.Any(r => r.Revision == sourceRevision)) throw new InvalidOperationException("Select a displayed revision.");
return await MutateAsync(() => repository.RestoreAsync(NestId, sourceRevision, Current!.Revision, ct), false, ct);
}
public async Task<NestRecord> RecoverAsync(string serverUrl, CancellationToken ct = default)
{
EnsureReady(serverUrl);
if (!Deleted) throw new InvalidOperationException("This nest is not deleted.");
return await MutateAsync(() => repository.RecoverAsync(NestId, Current!.Revision, ct), false, ct);
}
public async Task<NestRecord> DeleteAsync(string serverUrl, CancellationToken ct = default)
{
EnsureReady(serverUrl);
if (Deleted) throw new InvalidOperationException("This nest is already deleted.");
return await MutateAsync(() => repository.DeleteAsync(NestId, Current!.Revision, ct), true, ct);
}
private async Task<NestRecord> MutateAsync(Func<Task<NestRecord>> write, bool deleted, CancellationToken ct)
{
IsMutating = true;
var expected = Current!.Revision;
try
{
var result = await write();
ValidateRecord(result, NestId, deleted);
if (result.Revision != expected + 1) throw new InvalidDataException("Invalid mutation revision. Refresh to reconcile.");
// Verify the exact target after the write; do not declare success from the response alone.
var verified = deleted ? await repository.GetDeletedMetadataAsync(NestId, ct)
: await repository.GetMetadataAsync(NestId, ct);
if (verified == null || verified.Id != NestId || verified.Revision < result.Revision || verified.Deleted != deleted)
throw new InvalidDataException("Could not verify the new current revision. Refresh to reconcile.");
ct.ThrowIfCancellationRequested();
if (disposed) throw new OperationCanceledException("The history browser was closed. Reopen to reconcile.");
Current = verified;
Deleted = deleted;
Page = null;
return result;
}
catch { Current = null; throw; }
finally { IsMutating = false; }
}
private void EnsureReady(string url)
{
ObjectDisposedException.ThrowIf(disposed, this);
if (Normalize(url) != ServerUrl) throw new InvalidOperationException("The nest server changed. Close this browser and reopen it on the selected server.");
if (Current == null || IsMutating) throw new InvalidOperationException("Refresh the current version before continuing.");
}
private static void ValidateRecord(NestRecord record, Guid id, bool deleted)
{
if (record.Id != id || record.Deleted != deleted) throw new InvalidDataException("The server returned a different nest identity or visibility.");
}
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
/// <summary>Read an immutable current snapshot and ensure metadata did not change during download.</summary>
public static async Task<NestCurrentSnapshot> ReadCurrentAsync(INestRepository repository, Guid id,
string serverUrl, CancellationToken ct = default)
{
var before = await repository.GetMetadataAsync(id, ct)
?? throw new FileNotFoundException("This nest no longer exists on the server.");
ValidateRecord(before, id, false);
var beforeMetadata = JsonSerializer.Serialize(before);
var file = before.Revision > 0 ? await repository.GetRevisionFileAsync(id, before.Revision, ct)
: await repository.GetFileAsync(id, ct);
var after = await repository.GetMetadataAsync(id, ct);
ct.ThrowIfCancellationRequested();
if (after == null || JsonSerializer.Serialize(after) != beforeMetadata
|| file == null || file.LongLength != before.FileSize)
throw new InvalidDataException("The current nest changed or its archive does not match its metadata. Refresh and open it again.");
return new NestCurrentSnapshot(before, file, Normalize(serverUrl));
}
public void Clear()
{
if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
++generation;
pending?.Cancel();
NestId = Guid.Empty;
Current = null;
Page = null;
}
public void Dispose()
{
disposed = true;
++generation;
pending?.Cancel();
Current = null;
Page = null;
}
}
public sealed record NestCurrentSnapshot(NestRecord Metadata, byte[] Archive, string ServerUrl);
+8
View File
@@ -11,6 +11,14 @@ public sealed class NestRecord
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
/// <summary>Server revision; zero means the server does not support history.</summary>
public int Revision { get; set; }
public bool Deleted { get; set; }
/// <summary>Selected historical revision promoted by an explicit Restore.</summary>
public int? RestoredFromRevision { get; set; }
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string Customer { get; set; } = ""; public string Customer { get; set; } = "";
+12
View File
@@ -0,0 +1,12 @@
namespace OpenNest.Data;
/// <summary>A bounded page of immutable metadata snapshots, newest first.</summary>
public sealed class NestRevisionPage
{
public const int DefaultLimit = 100;
public const int MaxLimit = 200;
public IReadOnlyList<NestRecord> Items { get; init; } = Array.Empty<NestRecord>();
public int Total { get; init; }
public int Offset { get; init; }
public int Limit { get; init; }
}
+87 -7
View File
@@ -9,6 +9,21 @@ public sealed class NestSaveSession
{ {
public Guid RemoteId { get; private set; } public Guid RemoteId { get; private set; }
public string ServerUrl { get; private set; } = ""; public string ServerUrl { get; private set; } = "";
public int Revision { get; private set; }
public bool RequiresReload { get; private set; }
public Guid PendingCreateId { get; private set; }
public bool PendingCreateWasCopy => PendingCreateId != Guid.Empty && pendingCreateWasCopy;
private string pendingCreateUrl = "";
private byte[]? pendingCreateArchive;
private NestRecord? pendingCreateMetadata;
private bool pendingCreateWasCopy;
public void Bind(Guid id, string serverUrl, int revision)
{
if (revision < 0) throw new ArgumentOutOfRangeException(nameof(revision));
Bind(id, serverUrl);
Revision = revision;
}
public void Bind(Guid id, string serverUrl) public void Bind(Guid id, string serverUrl)
{ {
@@ -16,6 +31,36 @@ public sealed class NestSaveSession
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id)); throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
RemoteId = id; RemoteId = id;
ServerUrl = Normalize(serverUrl); ServerUrl = Normalize(serverUrl);
Revision = 0; // Legacy reads remain viewable, but must not become guarded saves.
RequiresReload = false;
PendingCreateId = Guid.Empty;
pendingCreateArchive = null;
pendingCreateMetadata = null;
pendingCreateWasCopy = false;
pendingCreateUrl = "";
}
/// <summary>Explicitly reconcile an upload whose reply was lost before any retry.</summary>
public async Task<NestRecord?> ReconcilePendingCreateAsync(INestRepository repository,
string serverUrl, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(repository);
if (PendingCreateId == Guid.Empty || Normalize(serverUrl) != pendingCreateUrl)
throw new InvalidOperationException("No pending upload on this server to reconcile.");
var record = await repository.GetMetadataAsync(PendingCreateId, cancellationToken);
if (record is null)
{
// A deliberate subsequent Save may retry this SAME ID, never create a second ID.
RequiresReload = false;
return null;
}
var bytes = await repository.GetFileAsync(PendingCreateId, cancellationToken);
if (record.Id != PendingCreateId || record.Revision < 1 || bytes is null
|| !bytes.AsSpan().SequenceEqual(pendingCreateArchive)
|| !SameEditableMetadata(record, pendingCreateMetadata!))
throw new IOException("The pending upload differs from the server record. Reopen the server nest before editing.");
Bind(record.Id, pendingCreateUrl, record.Revision);
return record;
} }
public async Task<NestRecord> SaveAsync( public async Task<NestRecord> SaveAsync(
@@ -30,17 +75,52 @@ public sealed class NestSaveSession
if (normalizedUrl.Length == 0) if (normalizedUrl.Length == 0)
throw new ArgumentException("A database server URL is required.", nameof(serverUrl)); throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
if (PendingCreateId != Guid.Empty && (RequiresReload || normalizedUrl != pendingCreateUrl
|| saveCopy != pendingCreateWasCopy))
throw new IOException("Reconcile the pending upload on its original server, then retry with the same Save or Save As action.");
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty; var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
var record = NestRecordFactory.FromNest(nest, id, archive.LongLength); var creating = PendingCreateId != Guid.Empty || id == Guid.Empty;
var saved = id == Guid.Empty var uploadId = creating ? (PendingCreateId == Guid.Empty ? Guid.NewGuid() : PendingCreateId) : id;
? await repository.UploadAsync(archive, record, cancellationToken) var record = NestRecordFactory.FromNest(nest, uploadId, archive.LongLength);
: await repository.UpdateFileAsync(id, archive, record, cancellationToken); if (!creating && (Revision < 1 || RequiresReload))
if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id)) throw new IOException("Reload the current nest revision before saving, or Save As a new nest. Upgrade the nest server if version history is unavailable.");
throw new InvalidDataException("Server returned an invalid nest id."); if (creating)
{
PendingCreateId = uploadId;
pendingCreateUrl = normalizedUrl;
pendingCreateArchive = archive.ToArray();
pendingCreateMetadata = record;
pendingCreateWasCopy = saveCopy;
}
NestRecord saved;
try
{
saved = creating
? await repository.UploadAsync(archive, record, cancellationToken)
: await repository.UpdateFileAsync(id, archive, record, Revision, cancellationToken);
}
catch (Exception ex) when (ex is IOException or System.Net.Http.HttpRequestException or OperationCanceledException or System.Text.Json.JsonException)
{
// The request may have committed before its reply was lost. Do not replay
// pending bytes until the operator explicitly reloads/reconciles the record.
RequiresReload = true;
throw;
}
if (saved.Id != uploadId || (!creating && saved.Revision < Revision))
{
RequiresReload = true;
throw new InvalidDataException("Server returned an invalid nest id or revision. Reload to reconcile before saving again.");
}
Bind(saved.Id, normalizedUrl); Bind(saved.Id, normalizedUrl, saved.Revision);
return saved; return saved;
} }
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/'); private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
private static bool SameEditableMetadata(NestRecord a, NestRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated
&& a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness
&& a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount
&& a.Comments == b.Comments && a.MadeBy == b.MadeBy;
} }
+12
View File
@@ -32,6 +32,18 @@ public sealed class NestStorageSettings
public bool IsDatabaseMode => public bool IsDatabaseMode =>
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl); Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
/// <summary>Validate and normalize a nest server base URL without changing storage mode.</summary>
public static bool TryNormalizeServerUrl(string? url, out string normalized)
{
normalized = NormalizeUrl(url);
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return true;
normalized = "";
return false;
}
/// <summary>%APPDATA%\OpenNest\storage.json.</summary> /// <summary>%APPDATA%\OpenNest\storage.json.</summary>
public static string DefaultPath => Path.Combine( public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+154 -17
View File
@@ -59,7 +59,13 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return items ?? new List<NestRecord>(); return items ?? new List<NestRecord>();
} }
public async Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
QueryCoreAsync(query, false, cancellationToken);
public Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
QueryCoreAsync(query, true, cancellationToken);
private async Task<NestPage> QueryCoreAsync(NestQuery query, bool deleted, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(query); ArgumentNullException.ThrowIfNull(query);
var error = query.GetValidationError(); var error = query.GetValidationError();
@@ -67,7 +73,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
throw new ArgumentException(error, nameof(query)); throw new ArgumentException(error, nameof(query));
using var response = await _httpClient.GetAsync( using var response = await _httpClient.GetAsync(
Url("api/nests/query?" + BuildQueryString(query)), cancellationToken); Url((deleted ? "api/nests/deleted/query?" : "api/nests/query?") + BuildQueryString(query)), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
{ {
throw new IOException( throw new IOException(
@@ -93,18 +99,30 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture) + "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture); + "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, false, cancellationToken);
public Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
GetMetadataCoreAsync(id, true, cancellationToken);
private async Task<NestRecord?> GetMetadataCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
{ {
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken); using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
return null; return null;
await EnsureSuccess(response, $"get nest {id}", cancellationToken); await EnsureSuccess(response, $"get nest {id}", cancellationToken);
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken); return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
} }
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
GetFileCoreAsync(id, false, cancellationToken);
public Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
GetFileCoreAsync(id, true, cancellationToken);
private async Task<byte[]?> GetFileCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
{ {
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken); using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
return null; return null;
await EnsureSuccess(response, $"download nest {id}", cancellationToken); await EnsureSuccess(response, $"download nest {id}", cancellationToken);
@@ -123,26 +141,145 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
public async Task<NestRecord> UpdateFileAsync( public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{ {
using var content = BuildMultipart(nestFile, record); return await UpdateFileAsync(id, nestFile, record, record.Revision, cancellationToken);
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
await EnsureSuccess(response, $"update nest {id}", cancellationToken);
return await ReadRecord(response, cancellationToken);
} }
public async Task<NestRecord> UpdateMetadataAsync( public async Task<NestRecord> UpdateMetadataAsync(
Guid id, NestRecord record, CancellationToken cancellationToken = default) Guid id, NestRecord record, CancellationToken cancellationToken = default)
{ {
using var content = new StringContent( return await UpdateMetadataAsync(id, record, record.Revision, cancellationToken);
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
return await ReadRecord(response, cancellationToken);
} }
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
{ {
using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken); await Task.CompletedTask;
await EnsureSuccess(response, $"delete nest {id}", cancellationToken); throw new IOException("Deletion requires guarded soft-delete support and is not available yet. Upgrade the client before deleting a server nest.");
}
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, false, cancellationToken);
public Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, true, cancellationToken);
private async Task<NestRevisionPage> GetHistoryAsync(Guid id, int offset, int limit, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > NestRevisionPage.MaxLimit) throw new ArgumentOutOfRangeException(nameof(limit));
using var response = await _httpClient.GetAsync(
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload.");
await EnsureSuccess(response, $"read nest history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty history page.");
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
|| page.Items.Any(item => item is null || item.Revision < 1 || item.Id != id)
|| page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision))
throw new IOException("Nest server returned an invalid or unbounded history page.");
return page;
}
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, false, cancellationToken);
public Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, true, cancellationToken);
private async Task<byte[]?> GetHistoricalFileAsync(Guid id, int revisionNumber, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
ValidateRevision(revisionNumber);
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccess(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
public async Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
if (expectedRevision < 1)
throw new IOException("This nest has no known revision. Upgrade the nest server and reload before saving, or Save As a new nest.");
// A legacy server can ignore If-Match and overwrite data. Prove history support
// before sending any mutation; never infer revision 1 from a legacy read.
var history = await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
if (history.Items.Count == 0)
throw new IOException("Nest server returned no revision history. Upgrade the nest server and reload before saving.");
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/file"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
request.Content = BuildMultipart(nestFile, record);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken);
var saved = await ReadRecord(response, cancellationToken);
if (saved.Id != id || saved.Revision < expectedRevision)
throw new IOException("Nest server returned an invalid saved revision. Reload to reconcile before saving again.");
return saved;
}
public async Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
ValidateRevision(expectedRevision);
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/metadata"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
request.Content = JsonContent.Create(record, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"edit nest {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public async Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision,
CancellationToken cancellationToken = default)
{
ValidateId(id);
ValidateRevision(sourceRevision);
ValidateRevision(expectedRevision);
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Post,
Url($"api/nests/{id}/revisions/{sourceRevision.ToString(CultureInfo.InvariantCulture)}/restore"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"restore nest {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
public Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
ChangeVisibilityAsync(id, expectedRevision, true, cancellationToken);
public Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
ChangeVisibilityAsync(id, expectedRevision, false, cancellationToken);
private async Task<NestRecord> ChangeVisibilityAsync(Guid id, int expectedRevision, bool deleted,
CancellationToken cancellationToken)
{
ValidateId(id);
ValidateRevision(expectedRevision);
// A legacy server cannot be trusted to preserve history on DELETE.
await GetHistoryAsync(id, 0, 1, !deleted, cancellationToken);
using var request = new HttpRequestMessage(deleted ? HttpMethod.Delete : HttpMethod.Post,
Url(deleted ? $"api/nests/{id}" : $"api/nests/deleted/{id}/recover"));
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccess(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
return await ReadRecord(response, cancellationToken);
}
private static void ValidateId(Guid id)
{
if (id == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(id));
}
private static void ValidateRevision(int revisionNumber)
{
if (revisionNumber < 1) throw new ArgumentOutOfRangeException(nameof(revisionNumber));
} }
public void Dispose() public void Dispose()
@@ -0,0 +1,197 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
/// <summary>
/// Slice 4: interactive pushes and spacing expansion never target poses inside a
/// clamp keep-out. The plate's zones act as static obstacles for Compactor.Push,
/// and as static walls for Expander — including the Expander edge inputs (clusters
/// that already overlap or are pinned to an edge), which must degrade to a
/// violation report instead of a crash.
/// </summary>
public class ClampZonePushTests
{
// Plate: 24 x 24, 0.5 uniform edge spacing, part spacing 1.0. Zones on the
// front edge; validation clearance (bottom edge spacing) is 0.5.
private const double Edge = 0.5;
private static readonly Box ZoneA = new(3, 0, 1.5, 0.625);
private static readonly Box ZoneB = new(8, 0, 1.5, 0.625);
private static Plate MakePlate(params ClampZone[] zones)
{
var plate = new Plate(new Size(24, 24));
plate.EdgeSpacing = new Spacing(Edge, Edge);
plate.PartSpacing = 1.0;
foreach (var zone in zones)
plate.ClampZones.Add(zone);
return plate;
}
private static Program SquareProgram(double size)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(size, 0);
program.LineTo(size, size);
program.LineTo(0, size);
program.LineTo(0, size);
program.LineTo(0, 0);
return program;
}
private static Part AddSquare(Plate plate, double x, double y, double size = 3)
{
var part = new Part(new Drawing($"sq{plate.Parts.Count}", SquareProgram(size)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Box Inflated(Box zone, double clearance) =>
new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance);
private static bool Overlaps(Box a, Box b) =>
a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6;
private static void AssertClear(Plate plate, params Part[] parts)
{
foreach (var part in parts)
foreach (var zone in plate.ClampZones)
Assert.False(
Overlaps(part.BoundingBox, Inflated(zone.Bounds, Edge)),
$"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) enters clamp '{zone.Id}'"
);
}
[Fact]
public void PushDownTowardTheClampEdgeStopsClearOfTheKeepOut()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
var mover = AddSquare(plate, 3, 8); // column crosses zone front-1
var moved = Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
Assert.True(moved > 0.1, $"expected a downward move, got {moved}");
AssertClear(plate, mover);
}
[Fact]
public void PushDownBetweenTheClampsStillReachesTheEdge()
{
// Material between clamps stays usable (D6): the free column pushes to the edge.
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
var mover = AddSquare(plate, 14, 8);
var moved = Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
Assert.True(moved > 7, $"free column should reach near the edge, moved {moved}");
AssertClear(plate, mover);
}
[Fact]
public void SettleDoesNotMoveGroupThroughAnOccupiedPart()
{
var plate = new Plate(new Size(20, 20))
{
EdgeSpacing = new Spacing(0.25, 0.25),
PartSpacing = 0.1,
};
plate.ClampZones.Add(new ClampZone("remote", new Box(15, 0, 1.5, 0.625)));
var occupant = AddSquare(plate, 0.25, 0.25, 4);
var first = AddSquare(plate, 6, 7);
var second = AddSquare(plate, 11, 7);
Compactor.Settle(new List<Part> { first, second }, plate, plate.WorkArea());
Assert.False(Overlaps(first.BoundingBox, occupant.BoundingBox));
Assert.False(Overlaps(second.BoundingBox, occupant.BoundingBox));
AssertClear(plate, first, second);
}
[Fact]
public void SettleKeepsGroupOutsideClampsInsideItsFillArea()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA));
var first = AddSquare(plate, 3, 8);
var second = AddSquare(plate, 12, 8);
Compactor.Settle(new List<Part> { first, second }, plate, plate.WorkArea());
AssertClear(plate, first, second);
}
[Fact]
public void PushWithAlreadyOverlappingMemberDoesNotMoveClearMemberIntoClamp()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA));
var straddler = AddSquare(plate, 3, 0.5);
var clear = AddSquare(plate, 3, 8);
Compactor.Push(new List<Part> { straddler, clear }, plate, PushDirection.Down);
AssertClear(plate, clear);
}
[Fact]
public void ExpanderPreservesBottomEdgeClearanceWhenItsAchievedSpacingIsSmaller()
{
var plate = new Plate(new Size(12, 12))
{
EdgeSpacing = new Spacing(2, 2),
PartSpacing = 0.1,
};
plate.ClampZones.Add(new ClampZone("front", new Box(4, 0, 2, 1)));
var upper = AddSquare(plate, 4, 5, 2);
var lower = AddSquare(plate, 4, 3.1, 2);
var result = Expander.Expand(new List<Part> { upper, lower }, plate);
Assert.False(result.Cancelled);
Assert.True(lower.BoundingBox.Bottom >= 3 - 1e-6,
$"Achieved={result.AchievedSpacing}, lower at {lower.Location.Y}");
}
[Fact]
public void ExpanderNeverTargetsAPoseInsideAClampWhenGrowthWouldEnterOne()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
// Overlapping pair directly above zone front-2 (validation keep-out top y = 1.125):
// separating them drives the lower square down through the keep-out boundary.
var upper = AddSquare(plate, 8, 4.0, 3);
var lower = AddSquare(plate, 8, 1.2, 3);
var result = Expander.Expand(new List<Part> { upper, lower }, plate);
Assert.False(result.Cancelled);
AssertClear(plate, upper, lower);
}
[Fact]
public void ExpanderDegradesToAReportWhenAMemberAlreadyOverlapsAClamp()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
var straddler = AddSquare(plate, 3, 0.3, 3); // starts inside the clamp keep-out
var clear = AddSquare(plate, 15, 0.3, 3);
var result = Expander.Expand(new List<Part> { straddler, clear }, plate);
Assert.False(result.Cancelled);
// The clear member must not be driven into any clamp while its partner is stuck.
AssertClear(plate, clear);
}
[Fact]
public void ExpanderOnEdgePinnedClusterWithClampsReportsInsteadOfCrashing()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
// Three tight squares away from the clamp edge (the Expander edge-input class:
// an overlapping, mutually blocked cluster). All start clear of the zones.
var a = AddSquare(plate, 15, 0.5, 3);
var b = AddSquare(plate, 17.75, 0.5, 3);
var c = AddSquare(plate, 15, 3.25, 3);
var result = Expander.Expand(new List<Part> { a, b, c }, plate);
Assert.False(result.Cancelled);
// Whatever the achieved spacing or reported violations, no clear member may
// end inside a clamp keep-out.
AssertClear(plate, a, b, c);
}
}
@@ -0,0 +1,121 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderCutoffDeltaTests
{
private static Plate Sheet() => new(new Size(30, 30)) { EdgeSpacing = new Spacing(0, 0) };
private static Program Rectangle(double width = 2, double height = 2)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(width, 0);
program.LineTo(width, height);
program.LineTo(0, height);
program.LineTo(0, 0);
return program;
}
private static Part Add(Plate plate, Program program, double x, double y)
{
var part = new Part(new Drawing("neutral", program), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Program Disk(double radius) =>
ConvertGeometry.ToProgram(new List<Entity> { new Circle(0, 0, radius) });
[Theory]
[InlineData(CutOffAxis.Horizontal)]
[InlineData(CutOffAxis.Vertical)]
public void Expand_RegeneratedCutoff_MustPreserveNativeEntryGap(CutOffAxis axis)
{
var plate = Sheet();
var anchor = Add(plate, Rectangle(), 1, 1);
var mover = Add(plate, Rectangle(), 4, 4);
plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis));
var settings = new CutOffSettings();
plate.RegenerateCutOffs(settings);
var before = axis == CutOffAxis.Horizontal
? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right;
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
var after = axis == CutOffAxis.Horizontal
? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right;
Assert.True(after >= before - 1.1e-6,
$"Cutoff entry gap shrank {before:R}->{after:R}, mover=({mover.Location.X:R},{mover.Location.Y:R}), achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}");
plate.RegenerateCutOffs(settings);
Assert.Single(plate.CutOffs);
}
[Fact]
public void Expand_RemoteOpenU_MustNotMoveTowardNativeCutoff()
{
var plate = Sheet();
var anchor = Add(plate, Rectangle(), 1, 4);
var mover = Add(plate, Rectangle(), 4, 4);
var program = new Program();
program.MoveTo(0, 10);
program.LineTo(30, 10);
program.LineTo(30, 0);
program.LineTo(0, 0);
plate.Parts.Add(new Part(new Drawing("neutral-cutoff", program) { IsCutOff = true }, Vector.Zero));
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
// The three real segments are at least four units from the selected
// square at entry; an invented closing chord is not a real obstacle.
var gap = System.Math.Min(mover.BoundingBox.Bottom,
System.Math.Min(10 - mover.BoundingBox.Top, 30 - mover.BoundingBox.Right));
Assert.True(gap >= 4 - 1.1e-6,
$"Open path gap shrank to {gap:R}; mover=({mover.Location.X:R},{mover.Location.Y:R}); spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}");
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Separate_ShallowDiskOrRingVsRectangle_MustNotClaimClear(bool annulus)
{
var plate = Sheet();
var entities = new List<Entity> { new Circle(0, 0, 2) };
if (annulus)
entities.Add(new Circle(0, 0, 1));
Add(plate, ConvertGeometry.ToProgram(entities), 10, 10);
const double angle = 0.017;
const double depth = 0.0005;
var mover = Add(plate, Rectangle(0.01, 0.01),
10 + (2 - depth) * System.Math.Cos(angle),
10 + (2 - depth) * System.Math.Sin(angle));
var result = Expander.Separate(new List<Part> { mover }, plate, 0);
var nearest = result.Positions[0].DistanceTo(new Vector(10, 10));
Assert.True(nearest >= 2 - 1e-8 || result.Violations.Any(v => v.Achieved < 0),
$"Native material overlap remains: nearest={nearest:R}, converged={result.Converged}, violations={result.Violations.Count}");
}
[Theory]
[InlineData(CutOffAxis.Horizontal)]
[InlineData(CutOffAxis.Vertical)]
public void Separate_RegeneratedCutoff_MustNotCrossEvenAtFarClearEndpoint(CutOffAxis axis)
{
var plate = Sheet();
var horizontal = axis == CutOffAxis.Horizontal;
var anchor = Add(plate, Rectangle(), 1, 1);
var mover = Add(plate, Rectangle(), horizontal ? 1 : 6, horizontal ? 6 : 1);
plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis));
plate.RegenerateCutOffs(new CutOffSettings());
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 12);
var trailingEdge = (horizontal ? result.Positions[1].Y : result.Positions[1].X) + 2;
Assert.True(trailingEdge <= 10 + 1e-6,
$"Crossed native cutoff, trailing edge={trailingEdge:R}");
Assert.False(result.Converged);
}
}
@@ -0,0 +1,94 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderCutoffQuadrantTests
{
private static Program Square()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(2, 0);
program.LineTo(2, 2);
program.LineTo(0, 2);
program.LineTo(0, 0);
return program;
}
public static IEnumerable<object[]> Cases()
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
foreach (var verticalMotion in new[] { false, true })
foreach (var cutoff in new[] { false, true })
foreach (var expand in new[] { false, true })
yield return new object[] { quadrant, verticalMotion, cutoff, expand };
}
[Theory]
[MemberData(nameof(Cases))]
public void RemoteCutoffMustNotPinNegativeParallelMotion(
int quadrant, bool verticalMotion, bool cutoff, bool expand)
{
var plate = new Plate(new Size(80, 80))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0, 0),
};
var origin = new Vector(
quadrant == 2 || quadrant == 3 ? -80 : 0,
quadrant == 3 || quadrant == 4 ? -80 : 0);
var anchorPose = origin + new Vector(30, 30);
var moverPose = anchorPose + (verticalMotion ? new Vector(0, -3) : new Vector(-3, 0));
var anchor = new Part(new Drawing("anchor", Square()), anchorPose);
var mover = new Part(new Drawing("mover", Square()), moverPose);
plate.Parts.Add(anchor);
plate.Parts.Add(mover);
if (cutoff)
{
plate.CutOffs.Add(new CutOff(
origin + (verticalMotion ? new Vector(40, 0) : new Vector(0, 40)),
verticalMotion ? CutOffAxis.Vertical : CutOffAxis.Horizontal));
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
}
Vector end;
int violations;
if (expand)
{
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
end = mover.Location;
violations = result.Violations.Count;
Assert.True(result.AchievedSpacing >= 2.99,
$"spacing={result.AchievedSpacing:R}; end=({end.X:R},{end.Y:R})");
}
else
{
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 3);
end = result.Positions[1];
violations = result.Violations.Count;
Assert.True(result.Converged,
$"unchanged=({end.X:R},{end.Y:R}), violations={violations}");
Assert.Equal(moverPose.X, mover.Location.X);
Assert.Equal(moverPose.Y, mover.Location.Y);
}
// Two units of negative-axis motion opens the selected gap 1 -> 3.
// The parallel open cutoff remains eight units away throughout.
Assert.True(verticalMotion ? end.Y <= moverPose.Y - 1.999 : end.X <= moverPose.X - 1.999);
Assert.Equal(0, violations);
Assert.Equal(anchorPose.X, anchor.Location.X);
Assert.Equal(anchorPose.Y, anchor.Location.Y);
if (cutoff)
{
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Single(plate.CutOffs);
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
}
}
}
@@ -0,0 +1,149 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderFinalQualityTests
{
private static Plate Sheet() => new(new Size(12, 30)) { EdgeSpacing = new Spacing(0, 0) };
private static Program Disk(double radius) =>
ConvertGeometry.ToProgram(new List<Entity> { new Circle(0, 0, radius) });
private static Program Rectangle(double width, double height)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(width, 0);
program.LineTo(width, height);
program.LineTo(0, height);
program.LineTo(0, 0);
return program;
}
private static Part Add(Plate plate, Program program, double x, double y)
{
var part = new Part(new Drawing("neutral", program), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
[Theory]
[InlineData(0.73, 0.001)]
[InlineData(0.017, 0.001)]
[InlineData(0.73, 0.0001)]
public void Expand_NativeDisks_MustReportOrRemoveRemainingOverlap(double angle, double depth)
{
var plate = Sheet();
var anchor = Add(plate, Disk(2), 7, 6);
var mover = Add(plate, Disk(2),
7 + (4 - depth) * System.Math.Cos(angle),
6 + (4 - depth) * System.Math.Sin(angle));
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
var nativeGap = anchor.Location.DistanceTo(mover.Location) - 4;
Assert.True(nativeGap >= -1e-8 || result.Violations.Any(v => v.Achieved < 0),
$"Native disks still overlap by {-nativeGap:R} but spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}; mover=({mover.Location.X:R},{mover.Location.Y:R})");
}
[Fact]
public void Separate_CurvedExactContact_CanStillGrowARealGap()
{
var plate = Sheet();
Add(plate, Disk(2), 10, 6);
var mover = Add(plate, Rectangle(1, 1), 12, 5.5);
var result = Expander.Separate(new List<Part> { mover }, plate, spacing: 1);
Assert.True(result.Converged, $"curved contact pinned: {result.Violations.Count} violations");
Assert.True(result.Positions[0].X >= 13 - 1e-3);
Assert.Equal(12, mover.Location.X);
}
[Fact]
public void Separate_SmallConcaveOverlap_MustNotClaimConvergedAtZero()
{
var plate = Sheet();
var program = new Program();
const double k = 0.0001;
program.MoveTo(0, 0);
program.LineTo(6 * k, 0);
program.LineTo(6 * k, 6 * k);
program.LineTo(4 * k, 6 * k);
program.LineTo(4 * k, 2 * k);
program.LineTo(2 * k, 2 * k);
program.LineTo(2 * k, 6 * k);
program.LineTo(0, 6 * k);
program.LineTo(0, 0);
Add(plate, program, 10, 5);
var mover = Add(plate, Rectangle(2 * k, 2 * k), 10 + k, 5 + 3 * k);
// Left finger [10,10.0002] × [5.0002,5.0006] intersects the
// mover [10.0001,10.0003] × [5.0003,5.0005] in positive area.
var result = Expander.Separate(new List<Part> { mover }, plate, 0);
Assert.False(result.Converged,
$"Known material overlap; pose={result.Positions[0]}, violations={result.Violations.Count}");
Assert.Contains(result.Violations, v => v.Achieved < 0);
}
[Fact]
public void Expand_RemoteRegeneratedCutoff_MustNotPinUnrelatedGrowth()
{
var plate = Sheet();
var anchor = Add(plate, Rectangle(2, 2), 1, 4);
var mover = Add(plate, Rectangle(2, 2), 4, 4);
plate.CutOffs.Add(new CutOff(new Vector(0, 10), CutOffAxis.Horizontal));
var settings = new CutOffSettings();
plate.RegenerateCutOffs(settings);
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
Assert.True(mover.Location.X >= 6 - 1e-4 && result.AchievedSpacing >= 2.99,
$"Remote cutoff pinned horizontal move: x={mover.Location.X:R}, achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}");
Assert.Equal(0, cutPart.Location.X);
Assert.Equal(0, cutPart.Location.Y);
plate.RegenerateCutOffs(settings);
Assert.Single(plate.CutOffs);
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
}
[Fact]
public void Expand_RegeneratedCutoffBlocksCrossingEvenWithClearFarEndpoint()
{
var plate = new Plate(new Size(20, 30)) { EdgeSpacing = new Spacing(0, 0) };
var anchor = Add(plate, Rectangle(2, 2), 1, 1);
var mover = Add(plate, Rectangle(2, 2), 1, 6);
plate.CutOffs.Add(new CutOff(new Vector(0, 10), CutOffAxis.Horizontal));
var settings = new CutOffSettings();
plate.RegenerateCutOffs(settings);
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, spacing: 12);
Assert.False(result.Converged);
Assert.True(result.Positions[1].Y + 2 <= 10 + 1e-6,
$"Mover crossed regenerated cut path: y={result.Positions[1].Y:R}");
Assert.Equal(6, mover.Location.Y);
plate.RegenerateCutOffs(settings);
Assert.Single(plate.CutOffs);
}
[Fact]
public void Expand_OutsideUnchangedMover_MustNotReturnUnqualifiedSuccess()
{
var plate = Sheet();
var anchor = Add(plate, Rectangle(2, 2), 1, 4);
var mover = Add(plate, Rectangle(2, 2), 35, 4);
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 3 });
Assert.True(mover.BoundingBox.Right > 30); // The invalid entry stays unchanged.
Assert.Equal(35, mover.Location.X);
Assert.Equal(0, result.AchievedSpacing);
var edge = Assert.Single(result.Violations.Where(v => ReferenceEquals(v.A, mover)));
Assert.Null(edge.B);
Assert.True(edge.BlockedByEdge);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderIntersectingOpenPathTests
{
private static Program Square()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(2, 0);
program.LineTo(2, 2);
program.LineTo(0, 2);
program.LineTo(0, 0);
return program;
}
public static IEnumerable<object[]> Cases()
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
foreach (var transpose in new[] { false, true })
foreach (var expand in new[] { false, true })
yield return new object[] { quadrant, transpose, expand };
}
[Theory]
[MemberData(nameof(Cases))]
public void IntersectingOpenPathMustRemainAViolation(int quadrant, bool transpose, bool expand)
{
var origin = new Vector(quadrant is 2 or 3 ? -100 : 0,
quadrant is 3 or 4 ? -100 : 0);
Vector Swap(double x, double y) => transpose ? new Vector(y, x) : new Vector(x, y);
var plate = new Plate(new Size(100, 100))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0, 0),
};
var expectedA = origin + Swap(40, 40);
var expectedB = origin + Swap(43, 40);
var anchor = new Part(new Drawing("anchor", Square()), expectedA);
var mover = new Part(new Drawing("mover", Square()), expectedB);
plate.Parts.Add(anchor);
plate.Parts.Add(mover);
var path = new Program();
var start = Swap(20, 40);
var across = Swap(70, 42);
var high = Swap(70, 60);
var tail = Swap(75, 60);
path.MoveTo(start.X, start.Y);
path.LineTo(across.X, across.Y);
path.LineTo(high.X, high.Y);
path.LineTo(tail.X, tail.Y);
var cutoff = new Part(new Drawing("neutral-open-cut", path) { IsCutOff = true }, origin);
plate.Parts.Add(cutoff);
// First segment is y=40+0.04*(x-20). At x=41 and x=44,
// y=40.84 and 40.96 are strictly INSIDE both squares. Signed origins
// and transposition preserve these independent interior witnesses.
List<Expander.Violation> violations;
if (expand)
{
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 0.1 });
violations = result.Violations;
Assert.Equal(0, result.AchievedSpacing);
}
else
{
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 0);
violations = result.Violations;
Assert.False(result.Converged,
$"Native path intersects both squares but reports converged with {violations.Count} violations");
}
Assert.Contains(violations, v => ReferenceEquals(v.B, cutoff) && v.Achieved < 0);
Assert.Equal(expectedA.X, anchor.Location.X);
Assert.Equal(expectedA.Y, anchor.Location.Y);
Assert.Equal(expectedB.X, mover.Location.X);
Assert.Equal(expectedB.Y, mover.Location.Y);
}
}
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderQualityReviewTests
{
private static Program Rectangle(double width, double height)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(width, 0);
program.LineTo(width, height);
program.LineTo(0, height);
program.LineTo(0, 0);
return program;
}
private static Part AddSquare(Plate plate, double x, double y, double size)
{
var part = new Part(new Drawing("neutral", Rectangle(size, size)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Plate MakePlate(double length) =>
new(new Size(12, length)) { EdgeSpacing = new Spacing(0, 0) };
[Fact]
public void TinyZoneContainedInMover_MustNotReturnClearPositiveSpacing()
{
var plate = MakePlate(30);
plate.ClampZones.Add(new ClampZone("tiny", new Box(5, 5, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 4, 2);
var mover = AddSquare(plate, 4, 4, 2);
// Independent oracle: [5,5.00001]^2 lies strictly inside [4,6]^2.
var result = Expander.Expand(
new List<Part> { anchor, mover },
plate,
new Expander.Options { InitialStep = 3, MaxSpacing = 3 }
);
Assert.True(
result.Violations.Count > 0,
$"Physical clamp is still inside mover but report is clear: spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}"
);
Assert.True(result.Violations[0].Achieved < 0);
}
[Fact]
public void TinyZoneInsideCutoutIsNotReportedAsMaterialOverlap()
{
var plate = MakePlate(30);
plate.ClampZones.Add(new ClampZone("tiny", new Box(5, 5, 1e-5, 1e-5)));
var frameProgram = Rectangle(10, 10);
frameProgram.MoveTo(4, 4);
frameProgram.LineTo(6, 4);
frameProgram.LineTo(6, 6);
frameProgram.LineTo(4, 6);
frameProgram.LineTo(4, 4);
var frame = new Part(new Drawing("neutral-frame", frameProgram), Vector.Zero);
plate.Parts.Add(frame);
var result = Expander.Separate(new List<Part> { frame }, plate, spacing: 0.5);
Assert.True(result.Converged, $"zone inside cutout reported as material: {result.Violations.Count}");
Assert.Empty(result.Violations);
Assert.Equal(0, frame.Location.X);
}
[Fact]
public void EdgeLimitedConvexOverlap_ShouldRetainCertifiedPartialImprovement()
{
var plate = MakePlate(11.5);
var anchor = AddSquare(plate, 5, 4, 4);
var mover = AddSquare(plate, 7, 4, 4);
var result = Expander.Expand(
new List<Part> { anchor, mover }, plate, new Expander.Options { MaxSpacing = 1 }
);
// Along x in [7,7.5], penetration is 9-x and strictly decreases.
Assert.True(
mover.Location.X >= 7.49,
$"Lost safe partial-overlap improvement: x={mover.Location.X:R}; expected edge at 7.5"
);
Assert.NotEmpty(result.Violations);
Assert.True(mover.Location.X + 4 <= 11.5 + 1e-9);
}
[Fact]
public void NegativeTarget_ConvexOverlapCanReachLessNegativeClearance()
{
var plate = MakePlate(30);
var anchor = AddSquare(plate, 5, 4, 4);
var mover = AddSquare(plate, 7, 4, 4);
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, -1);
Assert.True(result.Converged, $"Signed target -1 was not reached; x={result.Positions[1].X:R}");
Assert.True(result.Positions[1].X >= 8);
Assert.Equal(7, mover.Location.X);
}
}
@@ -0,0 +1,86 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderRemoteObliquePathTests
{
private static Program Square()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(2, 0);
program.LineTo(2, 2);
program.LineTo(0, 2);
program.LineTo(0, 0);
return program;
}
public static IEnumerable<object[]> Cases()
{
foreach (var quadrant in new[] { 1, 2, 3, 4 })
foreach (var transpose in new[] { false, true })
foreach (var cutoff in new[] { false, true })
foreach (var expand in new[] { false, true })
yield return new object[] { quadrant, transpose, cutoff, expand };
}
[Theory]
[MemberData(nameof(Cases))]
public void RemoteObliqueOpenPathMustNotPinSafeParallelGrowth(
int quadrant, bool transpose, bool cutoff, bool expand)
{
var origin = new Vector(quadrant is 2 or 3 ? -100 : 0,
quadrant is 3 or 4 ? -100 : 0);
Vector Swap(double x, double y) => transpose ? new Vector(y, x) : new Vector(x, y);
var plate = new Plate(new Size(100, 100))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0, 0),
};
var anchorPose = origin + Swap(40, 40);
var moverPose = origin + Swap(43, 40);
var anchor = new Part(new Drawing("anchor", Square()), anchorPose);
var mover = new Part(new Drawing("mover", Square()), moverPose);
plate.Parts.Add(anchor);
plate.Parts.Add(mover);
if (cutoff)
{
var path = new Program();
var start = Swap(18, 32);
var middle = Swap(68, 64);
var high = Swap(68, 80);
var tail = Swap(75, 80);
path.MoveTo(start.X, start.Y);
path.LineTo(middle.X, middle.Y);
path.LineTo(high.X, high.Y);
path.LineTo(tail.X, tail.Y);
plate.Parts.Add(new Part(new Drawing("neutral-path", path) { IsCutOff = true }, origin));
}
// First line is y=.64*x+20.48. The anchor's nearest corner (40,42)
// has exact gap 4.08/sqrt(1+.64^2)>3; mover (43,42) >5.
Vector end;
if (expand)
{
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
new Expander.Options { MaxSpacing = 2 });
end = mover.Location;
Assert.True(result.AchievedSpacing >= 1.99,
$"Remote cutoff pinned mover: spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}");
}
else
{
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 2);
end = result.Positions[1];
Assert.True(result.Converged,
$"Remote cutoff pinned mover: end=({end.X:R},{end.Y:R}), violations={result.Violations.Count}");
Assert.Equal(moverPose.X, mover.Location.X);
Assert.Equal(moverPose.Y, mover.Location.Y);
}
Assert.True(transpose ? end.Y >= moverPose.Y + 0.999 : end.X >= moverPose.X + 0.999);
Assert.Equal(anchorPose.X, anchor.Location.X);
Assert.Equal(anchorPose.Y, anchor.Location.Y);
}
}
@@ -0,0 +1,77 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
public class ExpanderSpecRegressionTests
{
private static Program Disk(double radius)
{
var p = new Program();
p.MoveTo(radius, 0);
p.ArcTo(-radius, 0, 0, 0, RotationType.CCW);
p.ArcTo(radius, 0, 0, 0, RotationType.CCW);
return p;
}
private static (Plate Plate, Part A, Part B) Fixture()
{
var plate = new Plate(new Size(12, 30)) { EdgeSpacing = new Spacing(0, 0) };
var a = new Part(new Drawing("disk-a", Disk(2)), new Vector(7, 6));
var b = new Part(new Drawing("disk-b", Disk(2)), new Vector(9, 6));
plate.Parts.Add(a);
plate.Parts.Add(b);
return (plate, a, b);
}
[Fact]
public void Separate_OverlappingNativeDisks_RetainsCertifiedConvexExit()
{
var (plate, a, b) = Fixture();
var result = Expander.Separate(new List<Part> { a, b }, plate, spacing: 3);
Assert.True(result.Converged,
$"remaining violations={result.Violations.Count}; mover=({result.Positions[1].X:R},{result.Positions[1].Y:R})");
// Independent analytic disk material clearance, not production polygons.
Assert.True(result.Positions[1].DistanceTo(result.Positions[0]) - 4 >= 2.99);
Assert.Equal(7, a.Location.X);
Assert.Equal(9, b.Location.X); // scratch-only
}
[Fact]
public void Separate_OverlappingNativeDisks_CannotPassThroughThirdWall()
{
var (plate, a, b) = Fixture();
var wallProgram = new Program();
wallProgram.MoveTo(0, 0);
wallProgram.LineTo(0.2, 0);
wallProgram.LineTo(0.2, 12);
wallProgram.LineTo(0, 12);
wallProgram.LineTo(0, 0);
var wall = new Part(new Drawing("fixed-wall", wallProgram), new Vector(12, 0));
plate.Parts.Add(wall);
var result = Expander.Separate(new List<Part> { a, b }, plate, spacing: 3);
Assert.False(result.Converged);
Assert.True(result.Positions[1].X + 2 <= 12 + 1e-6,
$"native disk crossed third wall: centre={result.Positions[1].X:R}");
Assert.Equal(9, b.Location.X);
Assert.Equal(12, wall.Location.X);
}
[Fact]
public void Expand_OverlappingNativeDisks_RetainsExistingSeparation()
{
var (plate, a, b) = Fixture();
var result = Expander.Expand(new List<Part> { a, b }, plate,
new Expander.Options { InitialStep = 0.5, MaxSpacing = 3 });
var nativeGap = a.Location.DistanceTo(b.Location) - 4;
Assert.True(nativeGap >= 2.99,
$"native gap={nativeGap:R}; achieved={result.AchievedSpacing:R}; violations={result.Violations.Count}");
Assert.True(result.AchievedSpacing >= 2.9);
Assert.Empty(result.Violations);
Assert.Equal(7, a.Location.X);
}
}
+324
View File
@@ -33,6 +33,13 @@ public class ExpanderTests
return part; return part;
} }
private static Part AddRectangle(Plate plate, double x, double y, double length, double width)
{
var part = new Part(new Drawing($"rect{plate.Parts.Count}", Rectangle(length, width)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5) private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
{ {
var plate = new Plate(new Size(widthY, lengthX)); var plate = new Plate(new Size(widthY, lengthX));
@@ -327,6 +334,323 @@ public class ExpanderTests
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null)); Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null));
} }
[Fact]
public void Separate_PreservesEachEntryGap_EvenWhenTargetIsSmaller()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var mover = AddSquare(plate, 4, 4, 2);
var wall = AddSquare(plate, 12, 4, 2);
var initialWallGap = wall.Location.X - (mover.Location.X + 2);
var (converged, positions, _) = Expander.Separate(
new List<Part> { anchor, mover }, plate, spacing: 3
);
// The wall is six units away at entry. Merely clearing the requested
// three-unit spacing is not permission to consume that original gap.
Assert.True(positions[1].X + 2 <= wall.Location.X - initialWallGap + 1e-6,
$"entry gap {initialWallGap}, proposed gap {wall.Location.X - positions[1].X - 2}");
Assert.Equal(new Vector(4, 4), mover.Location);
Assert.Equal(new Vector(12, 4), wall.Location);
Assert.False(converged); // This direct translation cannot reach 3 safely.
}
[Fact]
public void Separate_RetriesMiddleAfterLaterBlockerMoves()
{
var plate = MakePlate(60, 14, edge: 0);
var first = AddSquare(plate, 5, 5);
var middle = AddSquare(plate, 10, 5);
var last = AddSquare(plate, 14, 5);
var selected = new List<Part> { first, middle, last };
var (converged, positions, violations) = Expander.Separate(selected, plate, spacing: 2);
Assert.True(converged, $"remaining: {violations.Count}");
Assert.Empty(violations);
Assert.Equal(5, positions[0].X);
Assert.True(positions[1].X >= 11 - 1e-4);
Assert.True(positions[2].X >= positions[1].X + 6 - 1e-4);
Assert.Equal(10, middle.Location.X); // Separate is scratch-only.
Assert.Equal(14, last.Location.X);
var result = Expander.Expand(selected, plate, new Expander.Options { MaxSpacing = 2 });
Assert.True(result.AchievedSpacing >= 1.99, $"achieved {result.AchievedSpacing}");
Assert.True(middle.Location.X >= 11 - 1e-4);
Assert.True(last.Location.X >= middle.Location.X + 6 - 1e-4);
Assert.Equal(5, first.Location.X);
}
[Fact]
public void Separate_CannotPassThroughFullHeightWall_ToClearEndpoint()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var moving = AddSquare(plate, 4, 4, 2);
var wall = AddRectangle(plate, 6.5, 0, 0.2, 12);
// Target 5 puts the otherwise clear endpoint beyond the wall AND
// preserves the entry wall gap of 0.5. An endpoint-only check passes it.
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 5
);
Assert.False(converged);
Assert.NotEmpty(violations);
Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6,
$"move crossed wall: right={poses[1].X + 2}, wall={wall.Location.X}");
Assert.Equal(4, moving.Location.X); // public Separate never commits.
Assert.Equal(6.5, wall.Location.X);
var result = Expander.Expand(new List<Part> { anchor, moving }, plate,
new Expander.Options { InitialStep = 5, MaxSpacing = 5 });
Assert.True(moving.Location.X + 2 <= wall.Location.X + 1e-6);
Assert.True(result.AchievedSpacing < 5);
}
[Fact]
public void Separate_OverlappingPair_CannotExitAcrossThirdWall()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 5, 4);
var moving = AddSquare(plate, 7, 4); // Overlaps anchor by two.
var wall = AddRectangle(plate, 12, 0, 0.2, 12);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 5
);
Assert.False(converged);
Assert.NotEmpty(violations);
Assert.True(poses[1].X + 4 <= wall.Location.X + 1e-6,
$"overlap exit crossed third wall: {poses[1].X}");
Assert.Equal(7, moving.Location.X);
Assert.Equal(12, wall.Location.X);
}
[Fact]
public void Separate_CannotCrossOppositeCutoutWall()
{
var plate = MakePlate(40, 20, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(10, 10, 0.2, 0.2, 9.6, 9.6)), new Vector(5, 1));
plate.Parts.Add(host);
var anchor = AddSquare(plate, 1, 5, 1);
var moving = AddSquare(plate, 5.2, 5, 1); // Touches inner left wall.
var (converged, poses, _) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 14
);
Assert.False(converged);
Assert.True(poses[1].X + 1 <= 14.8 + 1e-6,
$"insert crossed opposite hole wall: {poses[1].X}");
Assert.Equal(5.2, moving.Location.X);
}
[Fact]
public void Separate_TinyClampZone_IsNotAnInvisibleWall()
{
var plate = MakePlate(30, 12, edge: 0);
plate.ClampZones.Add(new ClampZone("tiny", new Box(6.5, 0, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 0, 2);
var moving = AddSquare(plate, 4, 0, 2);
var (converged, poses, _) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 6
);
Assert.False(converged);
Assert.True(poses[1].X + 2 <= 6.5 + 1e-6,
$"crossed tiny clamp at x=6.5: right={poses[1].X + 2}");
Assert.Equal(4, moving.Location.X);
}
[Fact]
public void Separate_RemoteTinyClampDoesNotPinUnrelatedGrowth()
{
var plate = MakePlate(30, 12, edge: 0);
plate.ClampZones.Add(new ClampZone("tiny", new Box(0, 0, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 4, 2);
var moving = AddSquare(plate, 4, 4, 2);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 3
);
Assert.True(converged, $"remote zone pinned growth: {violations.Count} violations, " +
$"poses {poses[0].X:R},{poses[1].X:R}; " +
string.Join("; ", violations.Select(v => $"{v.A.BaseDrawing.Name}/{v.B.BaseDrawing.Name}={v.Achieved:R}")));
Assert.True(poses[1].X >= 6 - 1e-4);
Assert.Equal(4, moving.Location.X);
}
[Fact]
public void Separate_TouchingHoleWall_UsesOppositeDirectionToOpenGap()
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), new Vector(0, 0));
plate.Parts.Add(host);
var insert = AddSquare(plate, 13, 9, 2);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { insert }, plate, spacing: 1
);
Assert.True(converged, $"contact still blocked: {violations.Count}");
Assert.True(poses[0].X <= 12 + 1e-4, $"insert at x={poses[0].X}");
Assert.True(poses[0].X >= 5 + 1 - 1e-4);
Assert.Equal(13, insert.Location.X);
Assert.Equal(0, host.Location.X);
}
[Theory]
[InlineData(13, 9)]
[InlineData(5, 9)]
[InlineData(9, 13)]
[InlineData(9, 5)]
[InlineData(12.9, 9)] // Positive gap uses its ordinary nearest-boundary direction.
public void Separate_FourHoleSides_ReachARealUnitGap(double x, double y)
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
plate.Parts.Add(host);
var insert = AddSquare(plate, x, y, 2);
var (converged, poses, violations) = Expander.Separate(new List<Part> { insert }, plate, 1);
Assert.True(converged, $"{x},{y}: {violations.Count} violations");
Assert.InRange(poses[0].X, 6 - 1e-4, 12 + 1e-4);
Assert.InRange(poses[0].Y, 6 - 1e-4, 12 + 1e-4);
Assert.Equal(x, insert.Location.X);
Assert.Equal(y, insert.Location.Y);
}
[Fact]
public void Expand_TouchingHoleInsertWithSecondSelectedPart_StaysInsideCutout()
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
plate.Parts.Add(host);
var insert = AddSquare(plate, 13, 9, 2);
var other = AddSquare(plate, 30, 9, 2);
var result = Expander.Expand(new List<Part> { insert, other }, plate,
new Expander.Options { MaxSpacing = 1 });
Assert.False(result.Cancelled);
Assert.True(result.AchievedSpacing >= 0.99);
Assert.InRange(insert.Location.X, 6 - 1e-4, 12 + 1e-4);
Assert.False(MateriallyOverlaps(insert, host));
Assert.Equal(0, host.Location.X);
}
[Fact]
public void Separate_ReportsPartBlockerRatherThanPlateEdge()
{
var plate = MakePlate(20, 20, edge: 0);
var pinned = AddSquare(plate, 8, 8);
AddSquare(plate, 3.8, 8);
AddSquare(plate, 12.2, 8);
AddSquare(plate, 8, 3.8);
AddSquare(plate, 8, 12.2);
var (_, poses, violations) = Expander.Separate(new List<Part> { pinned }, plate, 1);
Assert.NotEmpty(violations);
Assert.All(violations, v => Assert.False(v.BlockedByEdge,
$"{v.B.BaseDrawing.Name}: blocker before the work-area edge, pose={poses[0]}"));
}
[Fact]
public void Separate_ReportsActualPlateEdgeWhenItLimitsMovement()
{
var plate = MakePlate(12, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var moving = AddSquare(plate, 7, 4, 2);
var (_, poses, violations) = Expander.Separate(new List<Part> { anchor, moving }, plate, 9);
var pair = Assert.Single(violations.Where(v => ReferenceEquals(v.A, anchor) && ReferenceEquals(v.B, moving)));
Assert.True(pair.BlockedByEdge);
Assert.True(poses[1].X + 2 <= 12 + 1e-6);
}
[Fact]
public void Separate_ReportsPartBeforeFartherEdge_OnNarrowPlate()
{
var plate = MakePlate(9, 2, edge: 0);
var anchor = AddSquare(plate, 0, 0, 2);
var moving = AddSquare(plate, 3, 0, 2);
var wall = AddRectangle(plate, 5.5, 0, 0.2, 2);
var (_, poses, violations) = Expander.Separate(new List<Part> { anchor, moving }, plate, 4);
var pair = Assert.Single(violations.Where(v => ReferenceEquals(v.A, anchor) && ReferenceEquals(v.B, moving)));
Assert.False(pair.BlockedByEdge);
Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6);
}
[Theory]
[InlineData((int)Expander.Checkpoint.AfterScratchMove)]
[InlineData((int)Expander.Checkpoint.AfterSuccessfulProbe)]
[InlineData((int)Expander.Checkpoint.BeforeApply)]
public void Expand_CancellationAtDeterministicCheckpoint_LeavesLiveStateBitIdentical(int stage)
{
var cancelAt = (Expander.Checkpoint)stage;
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 5, 4);
var moving = AddSquare(plate, 10, 4);
var wall = AddSquare(plate, 0, 4);
var before = plate.Parts.Select(p => (
Part: p, Program: p.Program,
X: BitConverter.DoubleToInt64Bits(p.Location.X),
Y: BitConverter.DoubleToInt64Bits(p.Location.Y),
Left: BitConverter.DoubleToInt64Bits(p.BoundingBox.Left))).ToArray();
var partSpacing = plate.PartSpacing;
var edgeSpacing = plate.EdgeSpacing;
using var cts = new CancellationTokenSource();
var seen = false;
var result = Expander.ExpandObserved(new List<Part> { anchor, moving }, plate,
new Expander.Options { MaxSpacing = 3 }, checkpoint =>
{
if (checkpoint != cancelAt)
return;
seen = true;
cts.Cancel();
}, cts.Token);
Assert.True(seen, $"checkpoint {cancelAt} was not reached");
Assert.True(result.Cancelled);
foreach (var state in before)
{
Assert.Same(state.Program, state.Part.Program);
Assert.Equal(state.X, BitConverter.DoubleToInt64Bits(state.Part.Location.X));
Assert.Equal(state.Y, BitConverter.DoubleToInt64Bits(state.Part.Location.Y));
Assert.Equal(state.Left, BitConverter.DoubleToInt64Bits(state.Part.BoundingBox.Left));
}
Assert.Equal(partSpacing, plate.PartSpacing);
Assert.Equal(edgeSpacing, plate.EdgeSpacing);
Assert.Equal(0, wall.Location.X);
}
[Fact]
public void Separate_PinnedBetweenFixedParts_StopsAfterOneNoProgressPass()
{
var plate = MakePlate(20, 20, edge: 0);
var pinned = AddSquare(plate, 8, 8);
AddSquare(plate, 3.8, 8);
AddSquare(plate, 12.2, 8);
AddSquare(plate, 8, 3.8);
AddSquare(plate, 8, 12.2);
var passes = 0;
var initial = pinned.Location;
var (converged, poses, violations) = Expander.SeparateObserved(
new List<Part> { pinned }, plate, spacing: 1,
checkpoint =>
{
if (checkpoint == Expander.Checkpoint.AfterPairPass)
passes++;
}, maxIterations: 10000
);
Assert.False(converged);
Assert.NotEmpty(violations);
Assert.Equal(1, passes); // Not a MaxIterations spin or wall-clock assertion.
Assert.Equal(initial, poses[0]);
Assert.Equal(initial, pinned.Location);
}
[Fact] [Fact]
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance() public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
{ {
@@ -0,0 +1,103 @@
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// A template plate following the machine clamp rail offers every stock size with the rail
/// resolved for that size (D2): no clamp beyond a shorter sheet, every clamp on a longer
/// one, measured from the origin in every quadrant.
/// </summary>
public class ClampRailStockTests
{
private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
private static Plate RailTemplate(int quadrant = 1)
{
var template = new Plate(48, 120) { Quadrant = quadrant, EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25) };
template.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters));
return template;
}
private static PlateOption[] Options() =>
[
new PlateOption { Width = 48, Length = 96 },
new PlateOption { Width = 60, Length = 144 },
new PlateOption { Width = 48, Length = 60 },
];
[Fact]
public void EachOptionGetsTheRailResolvedForItsOwnSize()
{
var stock = NestStockBuilder.FromTemplate(RailTemplate(), Options());
Assert.Equal(new[] { 7, 8, 4 }, stock.Select(s => s.ClampZones.Count));
Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, stock[2].ClampZones.Select(z => z.Bounds.X));
// Every offered sheet passes job validation: no clamp lies outside its sheet.
NestJobValidator.Validate(new NestJob([], stock));
}
[Theory]
[InlineData(2)]
[InlineData(3)]
public void OptionsResolveFromTheOriginWhenSheetsExtendToNegativeX(int quadrant)
{
var stock = NestStockBuilder.FromTemplate(RailTemplate(quadrant), Options());
foreach (var sheet in stock)
{
// Placement frame: the first clamp is 6" from the origin, toward -X.
var first = sheet.ClampZones.Single(z => z.Id == "clamp-1").Bounds;
Assert.Equal(-6.0, first.Center.X, 9);
}
NestJobValidator.Validate(new NestJob([], stock));
}
[Theory]
[InlineData("Rectangles")]
[InlineData("Irregular")]
[InlineData("Default")]
public void LanesAvoidTheWholeFootprintOfAClampHangingPastTheSheetEnd(string engine)
{
// 12" sheet, clamp centered 12.25" from the origin: 1" of its 1.5" lies past the end.
var template = new Plate(10, 12) { EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25), PartSpacing = 0.25 };
template.SetClampRail(new ClampRail(1.5, 0.625, new[] { 12.25 }));
var zone = Assert.Single(template.ClampZones);
Assert.Equal(11.5, zone.Bounds.X);
Assert.Equal(1.5, zone.Bounds.Length);
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(1, 1));
var result = NestPipeline.Run(new NestPipelineRequest(engine,
[new NestItem { Drawing = drawing, Quantity = 80 }], NestStockBuilder.SinglePlate(template)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
Assert.NotEmpty(parts);
// Independent oracle: the whole clamp grown by the bottom edge spacing.
var keepOut = zone.Bounds.Offset(0.25);
Assert.All(parts, part =>
{
var box = part.BoundingBox;
var overlapX = System.Math.Min(box.Right, keepOut.Right) - System.Math.Max(box.Left, keepOut.Left);
var overlapY = System.Math.Min(box.Top, keepOut.Top) - System.Math.Max(box.Bottom, keepOut.Bottom);
Assert.False(overlapX > 1e-6 && overlapY > 1e-6, $"{box} enters {keepOut}");
});
// Rectangles still uses the front edge beside the clamp. (Irregular may lift its whole
// block above the clamp band at the same count; that is a layout choice, not a leak.)
if (engine == "Rectangles")
Assert.Contains(parts, p => p.BoundingBox.Bottom < keepOut.Top && p.BoundingBox.Right <= keepOut.Left + 1e-6);
}
[Fact]
public void SingleSheetStockCarriesTheTemplatesRailZones()
{
var template = RailTemplate();
var single = Assert.Single(NestStockBuilder.SinglePlate(template));
Assert.Equal(8, single.ClampZones.Count);
Assert.Equal(template.ClampZones.Select(z => z.Bounds.X), single.ClampZones.Select(z => z.Bounds.X));
}
}
@@ -0,0 +1,48 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// D8/C13: a plate Auto Nest commits with a last-resort clamp removal keeps following its
/// rail - minus the removed clamp - through later resizes, exactly like an ordinary
/// rail-following sheet (Slice 3), and the removal itself is real, inspectable plate state.
/// </summary>
public class ClampRemovalCommitTests
{
[Fact]
public void CommittedPlateKeepsFollowingTheRailMinusTheRemovedClamp()
{
// Same geometry as ClampRemovalRecoveryTests.BlockedPartRemovesOnlyTheNearestOriginCoveredClamp:
// clamps 15" apart on a 23"-long, 3"-wide sheet; a 14"-wide part can only clear both by
// removing one, and "clamp-1" (nearest the origin) wins the tie.
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(3, 23);
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 21.0 }));
nest.CreatePlate(); // empty template the commit fills
using var manager = new PlateManager(nest);
var request = new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
NestStockBuilder.SinglePlate(nest.Plates[0]));
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
var plate = Assert.Single(applied);
Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps);
Assert.NotNull(plate.ClampRail);
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2");
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); // 1 clamp removed
// C13: still follows the rail (minus the removal) through a resize/rotate.
plate.Size = new Size(3, 60);
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2");
}
}
@@ -0,0 +1,132 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// D8/D9 last-resort clamp removal in the whole-job pipeline: a part blocked by clamps on
/// every offered stock gets the fewest clamps removed that let it fit (C10/C11), and the
/// residual solve is merged back into one result; everything else is untouched (C10's
/// bit-identical guarantee for an unblocked job).
/// </summary>
public class ClampRemovalRecoveryTests
{
// Two clamps 15" apart on a 23"-long, 3"-wide sheet (no edge/part spacing, so the math
// below is exact): clamp-1 spans X [5.25, 6.75], clamp-2 spans X [20.25, 21.75]. A 3"-tall
// part exactly fills the sheet's Y extent, so it always sits at Y = 0 - through the clamp
// band - for whatever X it lands at.
private static ClampZone Clamp1() => new("clamp-1", new Box(5.25, 0, 1.5, 0.625));
private static ClampZone Clamp2() => new("clamp-2", new Box(20.25, 0, 1.5, 0.625));
// The machine rail those zones resolve from: only rail-backed clamps can be removed.
private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 });
private static NestPlateStock ClampedSheet(params ClampZone[] zones) =>
RailSheet(null, zones);
private static NestPlateStock RailSheet(int? quantity, params ClampZone[] zones) =>
new("sheet", new Size(3, 23), quantity, 0, default, 1, null, zones) { ClampRail = Rail };
private static readonly RectanglesNestingEngine Engine = new();
[Fact]
public void BlockedPartRemovesOnlyTheNearestOriginCoveredClamp()
{
// A 14"-wide part: with both clamps up, no X position in [0, 9] clears both (the gap
// between them is 13.5" < 14"), so it is genuinely blocked (C10), not merely unlucky.
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status); // sanity: today's solve fails
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
var sheet = Assert.Single(outcome.Result.Plates);
Assert.Single(sheet.Placements);
// Removing either clamp alone would free enough room; "clamp-1" is nearest the origin.
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
var fulfillment = Assert.Single(outcome.Result.Fulfillment);
Assert.Equal(0, fulfillment.Unplaced);
// The expanded job the result validates against offers the original stock plus
// exactly the reduced-clamp variant actually used.
Assert.Equal(2, outcome.Job.Plates.Count);
}
[Fact]
public void RemovalFreesSpaceForOtherResidualDemandToo()
{
// C12: a sheet nested with a clamp removed may also take other residual parts.
var blocked = JobBuilder.Rectangle("wide", w: 14, h: 3, count: 1);
var filler = JobBuilder.Rectangle("filler", w: 2, h: 1, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { blocked, filler }, new[] { stock });
var raw = Engine.Solve(job);
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
Assert.All(outcome.Result.Fulfillment, f => Assert.Equal(0, f.Unplaced));
}
[Fact]
public void AlreadyCompleteResultIsUntouched()
{
// C10: no blocked part exists, so recovery is a strict no-op (bit-identical to Slice 3).
var part = JobBuilder.Rectangle("p", w: 1, h: 1, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Complete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void NoClampsOfferedNeverRecovers()
{
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
var stock = JobBuilder.Stock("sheet", width: 3, length: 10); // too short, no clamps
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.NotEqual(NestJobStatus.Complete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void PartBlockedOnlyByStockExhaustionIsNeverTreatedAsClampBlocked()
{
// The part fits a fresh clamped sheet on its own; only a single-sheet cap stops the
// second copy. That is a capacity limit, not a clamp block, so nothing is removed.
var part = JobBuilder.Rectangle("p", w: 12, h: 3, count: 2);
var stock = RailSheet(1, Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
Assert.Equal(NestJobStopReason.StockExhausted, raw.StopReason);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
[Fact]
public void UnfitEvenWithoutAnyClampStaysUnplaced()
{
// Wider than the whole sheet: removing every clamp still cannot make it fit.
var part = JobBuilder.Rectangle("p", w: 30, h: 3, count: 1);
var stock = ClampedSheet(Clamp1(), Clamp2());
var job = JobBuilder.Job(new[] { part }, new[] { stock });
var raw = Engine.Solve(job);
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
}
}
@@ -0,0 +1,391 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Slice 6 task 6.4: regressions for the independent Slice 3b reviews of last-resort clamp
/// removal (D8/D9, C10-C13). Geometry: a 23"-long, 3"-wide sheet with 1.5 x 0.625 clamps
/// centred at X = 6 and X = 21 (clamp-1 spans X [5.25, 6.75], clamp-2 [20.25, 21.75]); a
/// 3"-tall part always crosses the clamp band, a 14"-wide part fits only with one clamp
/// removed, a 22"-wide part only with both removed.
/// </summary>
public class ClampRemovalRepairTests
{
private static ClampZone[] Zones() => new[]
{
new ClampZone("clamp-1", new Box(5.25, 0, 1.5, 0.625)),
new ClampZone("clamp-2", new Box(20.25, 0, 1.5, 0.625)),
};
private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 });
/// <summary>Stock resolved from the machine rail (as NestStockBuilder builds it).</summary>
private static NestPlateStock Stock(int? quantity = null) =>
new("sheet", new Size(3, 23), quantity, 0, default, 1, null, Zones()) { ClampRail = Rail };
/// <summary>The same zones given explicitly, with no rail behind them.</summary>
private static NestPlateStock ExplicitStock() =>
JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: Zones());
private static NestItem Item(string name, double w, double h, int quantity) =>
new() { Drawing = new Drawing(name, TestDrawingFactory.Rectangle(w, h)), Quantity = quantity };
private static NestPipelineRequest Request(NestPlateStock stock, params NestItem[] items) =>
new("Rectangles", items, new[] { stock });
/// <summary>Delegates the ordinary solve to Rectangles, then fails every later call.</summary>
private sealed class ProbeThrowEngine : INestingEngine
{
public int Calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
if (++Calls > 1)
throw new InvalidOperationException("feasibility probe failed");
return new RectanglesNestingEngine().Solve(job, progress, token);
}
}
/// <summary>Solves with Rectangles and cancels the run on its second call.</summary>
private sealed class CancelOnSecondCallEngine(CancellationTokenSource source) : INestingEngine
{
private int calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
var result = new RectanglesNestingEngine().Solve(job, progress, token);
if (++calls == 2)
source.Cancel();
return result;
}
}
// PM 1db4333f: an optional recovery probe failure must not discard the ordinary result.
[Fact]
public void FailingRecoveryProbeKeepsTheOrdinaryResult()
{
var engine = new ProbeThrowEngine();
var request = Request(Stock(), Item("wide", 14, 3, 1), Item("small", 2, 1, 1));
var result = NestPipeline.Run(engine, "probe-throw", request);
Assert.True(engine.Calls > 1, "recovery never probed");
Assert.True(result.IsValid, string.Join("; ", result.Violations));
// Items map to part ids in request order: part-0 = wide (blocked), part-1 = small.
Assert.Equal(1, Assert.Single(result.Raw.Fulfillment, f => f.PartId == "part-0").Unplaced);
Assert.Equal(0, Assert.Single(result.Raw.Fulfillment, f => f.PartId == "part-1").Unplaced);
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
}
[Fact]
public void CancellationDuringRecoveryStillCancelsTheRun()
{
using var source = new CancellationTokenSource();
var request = Request(Stock(), Item("wide", 14, 3, 1), Item("small", 2, 1, 1));
Assert.ThrowsAny<OperationCanceledException>(() =>
NestPipeline.Run(new CancelOnSecondCallEngine(source), "cancel", request, token: source.Token));
}
// PM 346d46ed: explicit zones have no rail to name a removed clamp or its time.
[Fact]
public void ExplicitZonesWithoutARailAreNeverRemoved()
{
var result = NestPipeline.Run(Request(ExplicitStock(), Item("wide", 14, 3, 1)));
Assert.NotEqual(NestJobStatus.Complete, result.Status);
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
}
[Fact]
public void RailStockRemovalIsNamedOnACommittedPlateWithoutARail()
{
// The stock comes from a rail-following template; the commit target is an empty
// sheet holding the same zones explicitly. The removal must still be recorded.
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
var template = new Plate(new Size(3, 23)) { EdgeSpacing = new Spacing(0, 0, 0, 0) };
template.SetClampRail(Rail);
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(3, 23);
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
var target = nest.CreatePlate();
foreach (var zone in Zones())
target.ClampZones.Add(zone);
Assert.Null(target.ClampRail);
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, NestStockBuilder.SinglePlate(template)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var plate = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps);
Assert.NotNull(plate.ClampRail);
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes);
}
// PM 6ded8ac4 (d): the nearest-origin tie-break follows distance along the edge, not
// the order the zones happen to be listed in.
[Fact]
public void TieBreakPrefersTheClampNearestTheOriginWhateverTheZoneOrder()
{
var reversed = new NestPlateStock("sheet", new Size(3, 23), null, 0, default, 1, null,
Zones().Reverse().ToArray())
{ ClampRail = Rail };
var result = NestPipeline.Run(Request(reversed, Item("wide", 14, 3, 1)));
Assert.Equal(NestJobStatus.Complete, result.Status);
var sheet = Assert.Single(result.Raw.Plates);
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
}
// PM c5984c6e (C10): one lane's failure to place is not proof the part cannot fit. The
// fixed-rotation L below fits at the origin with its notch over the clamp (an explicit
// witness passes the layout check), but Rectangles packs its 10 x 10 bounding box and
// cannot place it; no clamp may be removed for it.
[Fact]
public void PartThatFitsClampedInSomeLaneNeverRemovesAClamp()
{
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(2, 0);
program.LineTo(2, 8);
program.LineTo(10, 8);
program.LineTo(10, 10);
program.LineTo(0, 10);
program.LineTo(0, 0);
var rail = new ClampRail(1.5, 0.625, new[] { 5.75 }); // clamp-1 spans X [5, 6.5]
var size = new Size(10, 10);
var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1))
{ ClampRail = rail };
var drawing = new Drawing("notched", program);
var witnessJob = new NestJob(new[] { DrawingJobMapper.FromItem("p", new NestItem { Drawing = drawing, Quantity = 1 }) },
new[] { stock });
var witness = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.Empty(NestLayoutCheck.Violations(witnessJob, witness)); // fits with the clamp in place
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { stock }));
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
Assert.All(result.Raw.Plates, sheet => Assert.Single(sheet.Stock.ClampZones));
}
private static int RemovedCount(NestJobPlateResult sheet) => 2 - sheet.Stock.ClampZones.Count;
// PM f91e054a (C11): minimality holds per sheet. part-0 (22 wide) needs both clamps off,
// part-1 (14 wide) only clamp-1; the sheet holding only part-1 must keep clamp-2.
[Fact]
public void EachSheetRemovesOnlyTheClampsItsOwnPartsNeed()
{
var result = NestPipeline.Run(Request(Stock(), Item("widest", 22, 3, 1), Item("wide", 14, 3, 1)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Raw.Plates.Count);
foreach (var sheet in result.Raw.Plates)
{
var needsBoth = sheet.Placements.Any(p => p.PartId == "part-0");
Assert.Equal(needsBoth ? 2 : 1, RemovedCount(sheet));
if (!needsBoth)
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
}
}
// PM 245a555f (C12): reduced-clamp sheets come out of the same finite pool.
[Theory]
[InlineData(3, false)] // three blocked parts, one physical sheet
[InlineData(1, true)] // the ordinary part already used the only sheet
public void RemovalNeverInventsSheetsBeyondTheFiniteStock(int wide, bool small)
{
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
if (small)
items.Add(Item("small", 2, 1, 1));
var result = NestPipeline.Run(Request(Stock(quantity: 1), items.ToArray()));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Single(result.Raw.Plates);
Assert.Equal(1, result.Raw.StockUsage.Sum(u => u.Used));
Assert.Equal(1, result.Job.Plates.Sum(s => s.Quantity ?? int.MaxValue));
Assert.All(result.Raw.StockUsage, u => Assert.True(u.Remaining is null or >= 0));
}
/// <summary>Never places anything; counts how often recovery asks it to.</summary>
private sealed class CountingNoFitEngine : INestingEngine
{
public int Calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
Calls++;
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.NoPlacementFound,
Array.Empty<NestJobPlateResult>(),
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)).ToList(),
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)).ToList());
}
}
private static NestPlateStock WideRailStock(string id, ClampRail rail)
{
var size = new Size(3, 23);
return new NestPlateStock(id, size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail };
}
// PM 0e655fdb: parts that cannot fit even with every clamp removed are dismissed by one
// all-clamps-removed probe per stock instead of a search over every clamp run.
[Fact]
public void PartsThatNeverFitAreDismissedWithoutSearchingClampRuns()
{
var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 30, h: 3, count: 1)).ToArray();
var job = new NestJob(parts, stocks);
var engine = new CountingNoFitEngine();
var raw = engine.Solve(job);
engine.Calls = 0;
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.Null(outcome);
// One feasibility probe per part plus one all-clamps-removed probe per part and stock.
Assert.True(engine.Calls <= 25 + 25 * 4, $"recovery made {engine.Calls} solves");
}
// PM 0e655fdb: the search has a fixed budget; running out keeps the ordinary result.
[Fact]
public void RecoveryStopsAtItsProbeBudget()
{
// 23-wide parts fit only with all 20 clamps off: every smaller run is probed first.
var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 23, h: 3, count: 1)).ToArray();
var job = new NestJob(parts, stocks);
var engine = new CountingNoFitEngine();
var raw = engine.Solve(job);
engine.Calls = 0;
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.Null(outcome);
Assert.True(engine.Calls <= ClampRemovalRecovery.MaxProbeSolves, $"recovery made {engine.Calls} solves");
}
// PM 60f7a673: the removal is recorded whatever the empty target sheet already holds.
[Theory]
[InlineData("reduced-explicit")] // no rail, already exactly the solved reduced zones
[InlineData("other-rail")] // follows a different rail that cannot recreate them
public void CommittedRemovalIsRecordedOnAnyEmptyTarget(string target)
{
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(3, 23);
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
var plate = nest.CreatePlate();
if (target == "reduced-explicit")
plate.ClampZones.Add(Zones()[1]);
else
plate.SetClampRail(new ClampRail(2, 0.625, new[] { 6.0, 21.0 }));
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(Request(Stock(), Item("wide", 14, 3, 1)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var applied = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Same(plate, applied);
Assert.Same(Rail, applied.ClampRail);
Assert.Equal(new[] { "clamp-1" }, applied.RemovedClamps);
Assert.Equal(new[] { "clamp-2" }, applied.ClampZones.Select(z => z.Id));
Assert.Equal(ClampRail.DefaultRemovalMinutes, applied.ClampRemovalMinutes);
}
// PM bf4438ac: when one offered stock runs out, a blocked part may still use another
// offered stock that has sheets left (each with its own minimal removal).
[Theory]
[InlineData(2, false)] // two blocked copies: one on A, one on B
[InlineData(1, true)] // the ordinary part took A; the blocked part goes to B
public void RecoveryMovesOnToAnotherStockWithSheetsLeft(int wide, bool small)
{
NestPlateStock Sheet(string id, double length)
{
var size = new Size(3, length);
return new NestPlateStock(id, size, 1, 0, default, 1, null, Rail.ResolveFor(size, 1)) { ClampRail = Rail };
}
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
if (small)
items.Add(Item("small", 2, 1, 1));
var request = new NestPipelineRequest("Rectangles", items, new[] { Sheet("A", 23), Sheet("B", 24) },
new NestJobOptions(maxPlates: 2));
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Raw.Plates.Count);
Assert.All(result.Raw.Plates.Where(p => p.StockId.Contains("removed")), p => Assert.Single(p.Stock.ClampZones));
Assert.Equal(2, result.Raw.StockUsage.Sum(u => u.Used));
}
// Delta review policy gate 3: identical offered stock (four 48x120 sheets with the same
// eight-clamp rail) must not multiply the probe count, or a moderate job of full-sheet
// parts runs out of budget although every part fits with its clamps removed.
[Fact]
public void IdenticalStockOptionsShareTheirProbes()
{
var rail = new ClampRail(1.5, 0.625, new[] { 6.0, 21.0, 36.0, 51.0, 66.0, 81.0, 96.0, 111.0 });
var size = new Size(48, 120);
var stocks = Enumerable.Range(0, 4)
.Select(i => new NestPlateStock($"s{i}", size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail })
.ToArray();
var parts = Enumerable.Range(0, 8)
.Select(i => JobBuilder.Rectangle($"full-{i}", w: 120, h: 48, count: 1, x: i * 1e-3))
.ToArray();
var job = new NestJob(parts, stocks);
var engine = new OpenNest.Engine.NestingEngines.Default.DefaultNestingEngine();
var raw = engine.Solve(job);
Assert.Empty(raw.Plates);
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
Assert.Equal(8, outcome.Result.Plates.Count);
Assert.All(outcome.Result.Plates, sheet => Assert.Empty(sheet.Stock.ClampZones));
}
// PM 3df03af2 (C11/D8 "the fewest clamps"): the minimal set need not be neighbours. The
// notch over the middle clamp lets it stay while the two outer clamps come off.
[Fact]
public void FewestClampsNeedNotBeNeighbours()
{
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(8, 0);
program.LineTo(8, 1);
program.LineTo(15, 1);
program.LineTo(15, 0);
program.LineTo(23, 0);
program.LineTo(23, 3);
program.LineTo(0, 3);
program.LineTo(0, 0);
var rail = new ClampRail(1.5, 0.625, new[] { 3.0, 11.5, 20.0 });
var size = new Size(3, 23);
var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1))
{ ClampRail = rail };
var result = NestPipeline.Run(new NestPipelineRequest("Default",
new[] { new NestItem { Drawing = new Drawing("middle-notch", program), Quantity = 1 } },
new[] { stock }));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
var sheet = Assert.Single(result.Raw.Plates);
Assert.Equal(new[] { "clamp-2" }, sheet.Stock.ClampZones.Select(z => z.Id));
}
}
@@ -0,0 +1,170 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// A committed live plate must carry exactly the clamp keep-outs its layout was solved and
/// validated against, in plate coordinates, including sheets the commit had to create.
/// </summary>
public class ClampZoneCommitTests
{
// A 1.5 x 0.625 clamp on the front edge of a 48 x 96 sheet (Length 96 runs along X).
private const double ZoneX = 5.25;
private static ClampZone Zone() => new("clamp-1", new Box(ZoneX, 0, 1.5, 0.625));
/// <summary>Two different one-part sheets so repeat grouping cannot merge them.</summary>
private sealed class TwoSheetStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var stock = job.Plates[0];
var work = stock.WorkArea;
var builder = new NestJobResultBuilder(job);
builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 20, work.Bottom + 10, 0.0) });
builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 40, work.Bottom + 20, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}
}
private static NestPipelineResult Result(Drawing drawing, int quadrant, params ClampZone[] zones)
{
var result = NestPipeline.Run(new TwoSheetStub(), "stub", new NestPipelineRequest("stub",
new[] { new NestItem { Drawing = drawing, Quantity = 2 } },
new[]
{
new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1, 1, 1),
quadrant, cost: null, clampZones: zones),
}));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Count);
return result;
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void EveryCommittedPlateCarriesTheSolvedZonesInPlateCoordinates(int quadrant)
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.CreatePlate(); // empty template without zones; the second sheet is created by commit
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, quadrant, Zone()), manager);
Assert.Equal(2, applied.Count);
Assert.All(applied, plate =>
{
var zone = Assert.Single(plate.ClampZones);
Assert.Equal("clamp-1", zone.Id);
// Exact bits: the inverse of the stock's placement-frame translation.
Assert.Equal(ZoneX, zone.Bounds.X);
Assert.Equal(0, zone.Bounds.Y);
Assert.Equal(1.5, zone.Bounds.Length);
Assert.Equal(0.625, zone.Bounds.Width);
});
}
[Fact]
public void MatchingPlateZonesAreKeptWithoutChangeEvents()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var template = nest.CreatePlate();
template.Quadrant = 3;
template.Size = new Size(48, 96);
var original = Zone();
template.ClampZones.Add(original);
var changes = 0;
template.ClampZonesChanged += (_, _) => changes++;
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 3, Zone()), manager);
Assert.Same(template, applied[0]);
Assert.Same(original, Assert.Single(template.ClampZones));
Assert.Equal(0, changes);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void SolvedZonesARailCannotRecreateStopFollowingThatRail(int quadrant)
{
// The nest rail puts a clamp at 6"; the template carries a different explicit zone,
// so the extra sheet the commit creates must not keep following the nest rail.
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(48, 96);
nest.PlateDefaults.Quadrant = quadrant;
nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 }));
var template = nest.CreatePlate();
template.SetClampRail(null!);
var manual = new ClampZone("manual", new Box(10, 0, 2, 0.5));
template.ClampZones.Add(manual);
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(
Result(drawing, quadrant, template.ClampZones.ToArray()), manager);
Assert.Equal(2, applied.Count);
Assert.All(applied, plate =>
{
Assert.Null(plate.ClampRail);
plate.Size = plate.Size; // the Edit Plate dialog assigns Size even when unchanged
plate.Quadrant = plate.Quadrant;
Assert.Equal("manual", Assert.Single(plate.ClampZones).Id);
Assert.Equal("manual", Assert.Single(Assert.Single(
NestStockBuilder.FromTemplate(plate, [new PlateOption { Width = 48, Length = 96 }])).ClampZones).Id);
});
}
[Fact]
public void SolvedZonesTheRailRecreatesKeepFollowingIt()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(48, 96);
var rail = new ClampRail(1.5, 0.625, new[] { 6.0 });
nest.SetClampRail(rail);
var template = nest.CreatePlate();
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(
Result(drawing, 1, template.ClampZones.ToArray()), manager);
Assert.Equal(2, applied.Count);
Assert.All(applied, plate =>
{
Assert.Same(rail, plate.ClampRail);
plate.Size = new Size(48, 4);
Assert.Empty(plate.ClampZones); // still re-resolves: no clamp reaches a 4" sheet
});
}
[Fact]
public void ZonesTheLayoutWasNotSolvedWithAreRemoved()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var template = nest.CreatePlate();
template.ClampZones.Add(Zone());
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 1), manager);
Assert.All(applied, plate => Assert.Empty(plate.ClampZones));
}
}
@@ -0,0 +1,223 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Clamp zones as engine input: immutable stock carry with the WorkArea quadrant
/// transform, validation of zone geometry, repeated-layout identity (C8) and the
/// layout-check violation against the D6-inflated rectangle (C1/C5).
/// </summary>
public class ClampZoneEngineTests
{
private const double ZoneX = 5.25;
private const double ZoneLength = 1.5;
private const double ZoneWidth = 0.625;
private static ClampZone Zone(string id = "clamp-0", double x = ZoneX) =>
new(id, new Box(x, 0, ZoneLength, ZoneWidth));
private static NestPlateStock Stock(
int quadrant = 1,
double bottomSpacing = 0.25,
params ClampZone[] zones
) =>
new("sheet", new Size(48, 96), null, 0.25,
new Spacing(0.25, bottomSpacing, 0.25, 0.25), quadrant, null, zones);
// ---- stock carry and quadrant transform ----------------------------------------
[Fact]
public void StockWithoutZonesExposesAnEmptyList()
{
var stock = new NestPlateStock("s", new Size(48, 96));
Assert.Empty(stock.ClampZones);
}
[Theory]
[InlineData(1, 0.0, 0.0)]
[InlineData(2, -96.0, 0.0)]
[InlineData(3, -96.0, -48.0)]
[InlineData(4, 0.0, -48.0)]
public void ZonesTransformWithTheWorkAreaIntoEveryQuadrant(int quadrant, double dx, double dy)
{
var stock = Stock(quadrant: quadrant, zones: Zone());
var zone = Assert.Single(stock.ClampZones);
Assert.Equal("clamp-0", zone.Id);
Assert.Equal(ZoneX + dx, zone.Bounds.X); // exact bits: same translation WorkArea uses
Assert.Equal(dy, zone.Bounds.Y);
Assert.Equal(ZoneLength, zone.Bounds.Length);
Assert.Equal(ZoneWidth, zone.Bounds.Width);
// The keep-out stays inside the sheet box of this quadrant.
var work = stock.WorkArea;
Assert.True(zone.Bounds.Right <= work.Right + 0.25);
Assert.True(zone.Bounds.Bottom >= work.Bottom - 0.25 - 0.25);
}
[Fact]
public void StockDetachesZonesFromCallerBoxes()
{
var bounds = new Box(ZoneX, 0, ZoneLength, ZoneWidth);
var zone = new ClampZone("clamp", bounds);
var stock = Stock(zones: zone);
bounds.Offset(500, 500);
Assert.Equal(ZoneX, Assert.Single(stock.ClampZones).Bounds.X);
}
[Fact]
public void MapperCarriesPlateZonesInBothDirections()
{
var plate = new Plate(48, 96) { Quadrant = 2 };
plate.ClampZones.Add(Zone());
var stock = DrawingJobMapper.FromPlate("s", plate, quantity: null);
var zone = Assert.Single(stock.ClampZones);
Assert.Equal(ZoneX - 96, zone.Bounds.X);
Assert.Equal(0, zone.Bounds.Y);
// A plate materialized from the stock stores plate coordinates like every Plate;
// mapping it into the placement frame reproduces the stock zone exactly.
var materialized = DrawingJobMapper.CreatePlate(stock);
var copy = Assert.Single(materialized.ClampZones);
Assert.Equal(ZoneX, copy.Bounds.X);
Assert.Equal(0, copy.Bounds.Y); // quadrant 2 keeps Y at zero
Assert.Equal("clamp-0", copy.Id);
var placed = Assert.Single(ClampZoneFrame.ToPlacement(materialized));
Assert.Equal(zone.Bounds.X, placed.Bounds.X);
Assert.Equal(zone.Bounds.Y, placed.Bounds.Y);
}
[Fact]
public void StockBuilderCarriesTemplateZonesForEveryOption()
{
var template = new Plate(48, 96);
template.ClampZones.Add(Zone("a", 3));
template.ClampZones.Add(Zone("b", 20));
var single = Assert.Single(NestStockBuilder.SinglePlate(template));
Assert.Equal(2, single.ClampZones.Count);
var options = NestStockBuilder.FromTemplate(template,
[new PlateOption { Width = 48, Length = 96 }, new PlateOption { Width = 40, Length = 80 }]);
Assert.All(options, stock => Assert.Equal(2, stock.ClampZones.Count));
}
// ---- validator ------------------------------------------------------------------
[Fact]
public void ValidatorAcceptsEdgeFlushZones()
{
var job = new NestJob([], [Stock(zones: new[] { Zone(), Zone("flush", 94.5) })]);
NestJobValidator.Validate(job); // y = 0 front edge and right-edge flush both legal
}
[Fact]
public void ValidatorAcceptsWholeClampsHangingPastTheSheet()
{
// A clamp is an avoidance area: a footprint reaching onto the sheet is kept whole.
NestJobValidator.Validate(new NestJob([], [Stock(zones: Zone(x: 95.5))]));
NestJobValidator.Validate(new NestJob([], [new NestPlateStock("s", new Size(48, 96),
clampZones: [new ClampZone("under", new Box(5, -0.5, 1.5, 0.625))])]));
NestJobValidator.Validate(new NestJob([], [Stock(quadrant: 3, zones: Zone(x: -0.5))]));
}
[Theory]
[InlineData(96, 0)] // touches the far end only
[InlineData(100, 0)]
[InlineData(-1.5, 0)] // touches the origin end only
[InlineData(5, -0.625)] // below the front edge, touching it
[InlineData(5, 48)] // beyond the back edge
public void ValidatorRejectsZonesWhollyOffTheSheet(double x, double y)
{
var job = new NestJob([], [new NestPlateStock("s", new Size(48, 96),
clampZones: [new ClampZone("off", new Box(x, y, 1.5, 0.625))])]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(job));
}
[Fact]
public void ValidatorRejectsDuplicateZoneIds()
{
var job = new NestJob([], [Stock(zones: new[] { Zone("dup", 3), Zone("dup", 20) })]);
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(job));
}
// ---- repeated-layout identity (C8) ----------------------------------------------
[Fact]
public void DifferentClampSetsAreDifferentStock()
{
var plain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25);
var clamped = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
clampZones: [Zone()]);
var sameAsPlain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
clampZones: [Zone()]);
Assert.False(RepeatedPlateGrouping.SameStock(plain, clamped));
Assert.True(RepeatedPlateGrouping.SameStock(clamped, sameAsPlain));
var pose = new NestJobPlacement("p", 0, 1, 1, 0);
var groups = RepeatedPlateGrouping.Create(
[
new NestJobPlateResult(0, clamped, [pose]),
new NestJobPlateResult(1, plain, [pose]),
new NestJobPlateResult(2, sameAsPlain, [pose]),
], consolidate: true);
// sheet2 repeats sheet0's exact layout and an identical clamp set, so only the
// plain-clamp sheet stays a separate group; the clamped layout is never reused
// for the plain sheet (C8).
Assert.Equal(new[] { 2, 1 }, groups.Select(g => g.Quantity).ToArray());
}
// ---- layout check (C1/C5) ---------------------------------------------------------
private static NestJobPart RectPart(string id = "p", double w = 2, double h = 2) =>
new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(w, h)), 1);
private static IReadOnlyList<string> Check(NestPlateStock stock, double x, double y)
{
var job = new NestJob([RectPart()], [stock]);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, stock, [new NestJobPlacement("p", 0, x, y, 0)])],
[], []);
return NestLayoutCheck.Violations(job, result);
}
[Fact]
public void CheckerFlagsPartsInsideTheInflatedClampRectangle()
{
// keep-out = zone inflated by bottom EdgeSpacing (0.25): x [~5.0, ~7.0], y up to ~0.875
var violations = Check(Stock(zones: Zone()), x: 5.3, y: 0.4);
Assert.Contains(violations, v => v.Contains("clamp-0") && v.Contains("clamp zone"));
}
[Fact]
public void CheckerAcceptsPartsClearOfTheInflatedClampRectangle()
{
// Above the inflated depth (0.625 + 0.25) and clear in X beyond the inflated width.
Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 5.3, y: 1.0),
v => v.Contains("clamp zone"));
Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 7.2, y: 0.4),
v => v.Contains("clamp zone"));
}
[Fact]
public void CheckerEnforcesTransformedZonesInOtherQuadrants()
{
// Quadrant 3 moves the front-edge zone to (5.25-96, -48); a part inside it must red.
var stock = Stock(quadrant: 3, zones: Zone());
var violations = Check(stock, x: 5.25 - 96 + 0.1, y: -48 + 0.4);
Assert.Contains(violations, v => v.Contains("clamp zone"));
// The same sheet a clear distance away from the transformed zone passes.
Assert.DoesNotContain(Check(stock, x: -50, y: -40), v => v.Contains("clamp zone"));
}
[Fact]
public void EmptyClampListAddsNoViolations()
{
Assert.DoesNotContain(Check(Stock(), x: 10, y: 10), v => v.Contains("clamp"));
}
}
@@ -0,0 +1,266 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Slice 4 (C1): the interactive single-plate surface - PlateFillService strategies
/// (Fill/Strip/Vertical+Horizontal Remnant), PackArea and whole-plate Nest - never
/// proposes a part inside a clamp keep-out inflated by the plate's bottom-edge
/// spacing, while material outside the zones stays usable. Oracles are analytic:
/// strict positive-area overlap against independently inflated zone rectangles.
/// </summary>
public class ClampZoneInteractiveFillTests
{
// Plate: 48 (X) by 24 (Y), uniform 0.25 edge spacing -> bottom clearance 0.25.
// Two 1.5 x 0.625 clamps on the front edge at X 10 and X 20.
private const double Edge = 0.25;
private const double PartSpacing = 0.5;
private static readonly Box ZoneA = new(10, 0, 1.5, 0.625);
private static readonly Box ZoneB = new(20, 0, 1.5, 0.625);
private static Plate MakePlate(params ClampZone[] zones)
{
var plate = new Plate(new Size(24, 48));
plate.EdgeSpacing = new Spacing(Edge, Edge);
plate.PartSpacing = PartSpacing;
foreach (var zone in zones)
plate.ClampZones.Add(zone);
return plate;
}
private static Program RectProgram(double lengthX, double widthY)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(lengthX, 0);
program.LineTo(lengthX, widthY);
program.LineTo(0, widthY);
program.LineTo(0, 0);
return program;
}
private static Drawing RectDrawing(double lengthX, double widthY) =>
new($"rect-{lengthX}-{widthY}", RectProgram(lengthX, widthY));
private static Box Inflated(Box zone, double clearance) =>
new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance);
/// <summary>Strict positive-area overlap: touching edges (the admitted closure) are clear.</summary>
private static bool Overlaps(Box a, Box b) =>
a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6;
private static void AssertClearOfClamps(IEnumerable<Part> parts, params Box[] zones)
{
foreach (var part in parts)
foreach (var zone in zones)
{
var keepOut = Inflated(zone, Edge);
Assert.False(
Overlaps(part.BoundingBox, keepOut),
$"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) "
+ $"box [{part.BoundingBox.Left:R}..{part.BoundingBox.Right:R} x "
+ $"{part.BoundingBox.Bottom:R}..{part.BoundingBox.Top:R}] enters the clamp keep-out"
);
}
}
public static TheoryData<string> Strategies => new() { "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" };
[Theory]
[MemberData(nameof(Strategies))]
public void FillItemNeverProposesPartInsideClampKeepOut(string strategy)
{
var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14)); // occupied-plate fixture
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
plate.Parts.Add(occupant);
var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 3 };
var parts = PlateFillService.FillItem(
strategy, plate, item, plate.WorkArea(), null, CancellationToken.None);
Assert.Equal(3, parts.Count);
AssertClearOfClamps(parts, ZoneA, ZoneB);
}
[Theory]
[MemberData(nameof(Strategies))]
public void FillUsesMaterialBetweenAndBehindTheClamps(string strategy)
{
// The band covers both clamps only where y < 3: parts must land in the strip
// above the clamps and in the slot between them - not merely fewer parts.
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
var band = new Box(9, 0.25, 13, 2.75);
var item = new NestItem { Drawing = RectDrawing(3, 1.8), Quantity = 2 };
var parts = PlateFillService.FillItem(strategy, plate, item, band, null, CancellationToken.None);
Assert.Equal(2, parts.Count);
AssertClearOfClamps(parts, ZoneA, ZoneB);
}
[Fact]
public void DesktopGroupFillThenSettleRemainsClearOfPlateOccupant()
{
var plate = new Plate(new Size(20, 20))
{
EdgeSpacing = new Spacing(0.25, 0.25),
PartSpacing = 0.1,
};
plate.ClampZones.Add(new ClampZone("remote", new Box(15, 0, 1.5, 0.625)));
var occupant = new Part(RectDrawing(4, 19.5), new Vector(0.25, 0.25));
plate.Parts.Add(occupant);
var group = new List<Part> { new(RectDrawing(3, 3)) };
var result = PlateFillService.FillGroup("Fill", plate, group,
plate.WorkArea(), null, CancellationToken.None);
Assert.True(result.Count > 1);
Assert.DoesNotContain(result, part => Overlaps(part.BoundingBox, occupant.BoundingBox));
Compactor.Settle(result, plate, plate.WorkArea());
Assert.DoesNotContain(result, part => Overlaps(part.BoundingBox, occupant.BoundingBox));
AssertClearOfClamps(result, new Box(15, 0, 1.5, 0.625));
}
[Fact]
public void FillGroupRetainsCompleteCopiesAheadOfAPartialTrailingCopy()
{
var drawing = RectDrawing(2, 2);
var group = new List<Part>
{
new(drawing, new Vector(0, 0)), new(drawing, new Vector(3, 0)),
};
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0 };
var area = new Box(0, 0, 8, 2);
var original = PlateFillService.FillGroup("Fill", plate, group, area,
null, CancellationToken.None);
Assert.True(original.Count > group.Count && original.Count % group.Count != 0,
$"expected a full copy and partial tail, got {original.Count}");
plate.ClampZones.Add(new ClampZone("remote", new Box(18, 0, 1, 0.5)));
var clamped = PlateFillService.FillGroup("Fill", plate, group, area,
null, CancellationToken.None);
Assert.Equal(group.Count, clamped.Count);
}
[Fact]
public void FillGroupRejectsTheWholeCopyWhenOnlyOneMemberTouchesAClamp()
{
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0 };
plate.ClampZones.Add(new ClampZone("front", new Box(5, 0, 1, 0.5)));
var drawing = RectDrawing(2, 2);
var group = new List<Part>
{
new(drawing, new Vector(2, 0)), new(drawing, new Vector(5, 0)),
};
var parts = PlateFillService.FillGroup("Fill", plate, group, new Box(2, 0, 5, 2),
null, CancellationToken.None);
Assert.Equal(0, parts.Count % group.Count);
Assert.Empty(parts);
}
[Fact]
public void FillGroupKeepsAnInsertInItsFrameHoleWhenAClampIsElsewhere()
{
var program = RectProgram(4, 4);
program.MoveTo(1, 1);
program.LineTo(1, 3);
program.LineTo(3, 3);
program.LineTo(3, 1);
program.LineTo(1, 1);
var frame = new Drawing("frame", program);
var insert = RectDrawing(1, 1);
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0.25 };
plate.ClampZones.Add(new ClampZone("remote", new Box(18, 0, 1, 0.5)));
var group = new List<Part>
{
new(frame, new Vector(1, 3)), new(insert, new Vector(2.5, 4.5)),
};
var parts = PlateFillService.FillGroup("Fill", plate, group, new Box(1, 3, 4, 4),
null, CancellationToken.None);
Assert.Contains(parts, part => ReferenceEquals(part.BaseDrawing, frame));
Assert.Contains(parts, part => ReferenceEquals(part.BaseDrawing, insert));
Assert.Equal(0, parts.Count % 2);
}
[Fact]
public void PackAreaKeepsAllowedOverhangOnlyAtThePlateBoundary()
{
var plate = new Plate(new Size(12, 12))
{
EdgeSpacing = new Spacing(1, 1),
PartSpacing = 0,
};
plate.ClampZones.Add(new ClampZone("front", new Box(5, 0, 1, 0.5)));
var drawing = RectDrawing(10 + OpenNest.Engine.NestingEngines.Rectangles.SheetPacker.OverhangAllowance * 0.8, 4);
var items = new List<NestItem> { new() { Drawing = drawing, Quantity = 2 } };
var parts = PlateFillService.PackArea("Fill", plate, plate.WorkArea(), items,
null, CancellationToken.None);
Assert.Equal(2, parts.Count);
Assert.All(parts, part => Assert.True(part.BoundingBox.Right > plate.WorkArea().Right));
AssertClearOfClamps(parts, new Box(5, 0, 1, 0.5));
}
[Fact]
public void PackAreaNeverPacksInsideClampKeepOut()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
var items = new List<NestItem> { new() { Drawing = RectDrawing(4, 4), Quantity = 8 } };
var parts = PlateFillService.PackArea(
"Fill", plate, plate.WorkArea(), items, null, CancellationToken.None);
Assert.True(parts.Count >= 5, $"expected the unclamped material to pack parts, got {parts.Count}");
AssertClearOfClamps(parts, ZoneA, ZoneB);
}
[Fact]
public void ProgressNeverPublishesUnverifiedClampPlacements()
{
var plate = MakePlate(new ClampZone("front-1", ZoneA));
var updates = new List<NestProgress>();
var progress = new InlineProgress(update => updates.Add(update));
var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 4 };
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(),
progress, CancellationToken.None);
Assert.NotEmpty(parts);
Assert.NotEmpty(updates);
Assert.All(updates, update => AssertClearOfClamps(update.BestParts, ZoneA));
}
private sealed class InlineProgress(System.Action<NestProgress> report) : System.IProgress<NestProgress>
{
public void Report(NestProgress value) => report(value);
}
[Theory]
[MemberData(nameof(Strategies))]
public void WholePlateNestWithNoRequestedPartsDoesNotFillBecauseOfClamps(string strategy)
{
var plate = MakePlate(new ClampZone("front-1", ZoneA));
var item = new NestItem { Drawing = RectDrawing(3, 3), Quantity = 0 };
var parts = PlateFillService.Nest(strategy, plate, new List<NestItem> { item },
null, CancellationToken.None);
if (strategy != "Strip")
Assert.Empty(parts);
}
[Theory]
[MemberData(nameof(Strategies))]
public void WholePlateNestAvoidsClampsOnAnOccupiedPlate(string strategy)
{
var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14));
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
plate.Parts.Add(occupant);
var items = new List<NestItem> { new() { Drawing = RectDrawing(4, 4), Quantity = 10 } };
var parts = PlateFillService.Nest(strategy, plate, items, null, CancellationToken.None);
Assert.Equal(10, parts.Count);
AssertClearOfClamps(parts, ZoneA, ZoneB);
}
}
@@ -0,0 +1,132 @@
using System;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Every <see cref="Plate"/> stores clamp zones in the sheet's lower-left frame, including
/// plates the engine builds from stock; consumers map them into the signed placement frame
/// through <see cref="ClampZoneFrame"/>. Fixtures use a real rail in all quadrants, with
/// expected footprints from the physical rule (center +/- width/2 from the origin along the
/// edge, depth into the sheet).
/// </summary>
public class ClampZoneSheetFrameTests
{
private static (string, long, long, long, long) Bits(ClampZone zone) =>
(zone.Id, BitConverter.DoubleToInt64Bits(zone.Bounds.X), BitConverter.DoubleToInt64Bits(zone.Bounds.Y),
BitConverter.DoubleToInt64Bits(zone.Bounds.Length), BitConverter.DoubleToInt64Bits(zone.Bounds.Width));
private static Box World(int quadrant, double center, double width = 1.5, double depth = 0.625)
{
var x = quadrant is 2 or 3 ? -(center + width / 2) : center - width / 2;
var y = quadrant is 3 or 4 ? -depth : 0;
return new Box(x, y, width, depth);
}
private static Program Rectangle(double length, double width)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(length, 0);
program.LineTo(length, width);
program.LineTo(0, width);
program.LineTo(0, 0);
return program;
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MaterializedPlatesStoreSheetFrameZonesAndWarnForCutsThroughThem(int quadrant)
{
var live = new Plate(81, 120) { Quadrant = quadrant };
live.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 120.5 }));
var stock = DrawingJobMapper.FromPlate("s", live, 1);
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, Array.Empty<NestJobPlacement>()) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
var nest = NestResultMaterializer.Materialize(job, result).Nest;
var plate = Assert.Single(nest.Plates);
Assert.Equal(live.ClampZones.Select(Bits), plate.ClampZones.Select(Bits));
Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits));
var footprint = World(quadrant, 35);
Assert.Equal(footprint.Left, stock.ClampZones[0].Bounds.Left, 9);
Assert.Equal(footprint.Bottom, stock.ClampZones[0].Bounds.Bottom, 9);
// A short cut-off through the physical clamp must be found and require consent.
plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -35 : 35, 0), CutOffAxis.Vertical)
{
StartLimit = quadrant is 3 or 4 ? -2 : 0,
EndLimit = quadrant is 3 or 4 ? 0 : 2,
});
Assert.Equal("clamp-1", Assert.Single(ClampCutDiagnostics.Find(plate)).ClampId);
plate.RegenerateCutOffs(new CutOffSettings());
var report = PostVerificationAnalyzer.Analyze(nest);
Assert.Contains(report.Findings, f => f.ClampId == "clamp-1");
Assert.False(report.CanPost(false));
}
[Fact]
public void StockClampZonesCannotBeEditedThroughTheReturnedZones()
{
// Stock is immutable input: editing a returned zone must not move the stock's clamp
// away from the zones its materialized plates (validation, fill, posting) use.
var live = new Plate(24, 48);
live.ClampZones.Add(new ClampZone("z", new Box(10, 0, 1.5, 0.625)));
var stock = DrawingJobMapper.FromPlate("s", live, 1);
stock.ClampZones[0].Bounds.X += 10;
foreach (var zone in stock.ClampZones)
zone.Bounds.Y = 5;
var current = Assert.Single(stock.ClampZones);
Assert.Equal(10, current.Bounds.X);
Assert.Equal(0, current.Bounds.Y);
var plate = DrawingJobMapper.CreatePlate(stock);
Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void InteractiveFillOnLiveRailPlateAvoidsClampsInEveryQuadrant(int quadrant)
{
// Five rows of 4.29 squares at 0.5 spacing span 23.45 of the 23.5 work-area height, so an
// unobstructed fill reaches the front edge in every quadrant.
var plate = new Plate(new Size(24, 48))
{
Quadrant = quadrant,
EdgeSpacing = new Spacing(0.25, 0.25),
PartSpacing = 0.5,
};
var centers = new[] { 10.75, 20.75, 30.75, 40.75 };
plate.SetClampRail(new ClampRail(1.5, 0.625, centers));
var keepOuts = centers.Select(c => World(quadrant, c).Offset(0.25)).ToArray();
var item = new NestItem { Drawing = new Drawing("square", Rectangle(4.29, 4.29)), Quantity = 500 };
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, CancellationToken.None);
Assert.True(parts.Count >= 20, $"only {parts.Count} parts placed");
foreach (var part in parts)
{
var box = part.BoundingBox;
Assert.DoesNotContain(keepOuts, k => box.Left < k.Right - 1e-6 && k.Left < box.Right - 1e-6
&& box.Bottom < k.Top - 1e-6 && k.Bottom < box.Top - 1e-6);
}
}
}
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Tests.Jobs;
public class CompletedLayoutRepeatTests
{
private static Dictionary<string, int> Counts(int a, int b) => new(StringComparer.Ordinal) { ["a"] = a, ["b"] = b };
[Theory]
[InlineData(null, null, 5)]
[InlineData(3, null, 3)]
[InlineData(null, 2, 2)]
[InlineData(4, 3, 3)]
[InlineData(0, null, 0)]
[InlineData(null, 0, 0)]
public void ScarcestRequirementAndEachPhysicalBoundClampTotalCopies(int? stock, int? cap, int expected)
{
Assert.Equal(expected, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap));
}
[Theory]
[InlineData(false, 1)]
[InlineData(true, 2)]
public void UnknownSearchAndCompetingEntriesUseOne(bool completed, int entries)
{
Assert.Equal(1, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), completed, entries, null, null));
}
[Fact]
public void EmptyLayoutAndNoStockCannotProduceASheet()
{
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary<string, int>(), true, 1, null, null));
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 0, null, null));
}
[Fact]
public void ExactCaseSensitiveIdsAndOnlyPresentRequirementsMatter()
{
var remaining = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 20, ["A"] = 3, ["absent"] = 0 };
var layout = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 4, ["A"] = 1 };
Assert.Equal(3, CompletedLayoutRepeat.Limit(remaining, layout, true, 1, null, null));
}
[Fact]
public void IntegerEdgeHasNoMultiplicationOverflowOrFirstCopyOffByOne()
{
var remaining = new Dictionary<string, int> { ["a"] = int.MaxValue };
Assert.Equal(int.MaxValue, CompletedLayoutRepeat.Limit(remaining, new Dictionary<string, int> { ["a"] = 1 }, true, 1, null, null));
var q = CompletedLayoutRepeat.Limit(remaining, new Dictionary<string, int> { ["a"] = 2 }, true, 1, null, null);
Assert.Equal(1, checked(int.MaxValue - q * 2));
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(3, 1), Counts(4, 2), true, 1, null, null));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void InvalidCountsAndUnknownIdsAreRefused(int count)
{
Assert.Throws<ArgumentException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(count, 2), true, 1, null, null));
Assert.Throws<ArgumentException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary<string, int> { ["unknown"] = 1 }, true, 1, null, null));
}
[Theory]
[InlineData(-1, null)]
[InlineData(null, -1)]
public void NegativeBoundsAreRefused(int? stock, int? cap)
{
Assert.Throws<ArgumentOutOfRangeException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap));
}
}
@@ -0,0 +1,154 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutNfpProposalTests
{
private const double Spacing = 0.25;
[Fact]
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.Equal(insert.Id, pose.PartId);
Assert.Equal(0, pose.InstanceIndex);
Assert.True(insert.Rotation.Allows(pose.Rotation));
AssertSquareInRoundHole(pose, 6, Spacing);
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
var movedInsert = pose with
{
X = framePose.X - pose.Y,
Y = framePose.Y + pose.X,
Rotation = pose.Rotation + framePose.Rotation,
};
// This insert is fixed at zero for the local proposal; the rotated variant needs
// a correspondingly legal global policy before it can be handed to a job.
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
}
[Fact]
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("square-frame", program, 1);
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
{
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
});
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
Spacing));
}
[Fact]
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("frame", program, 1);
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.True(insert.Rotation.Allows(pose.Rotation));
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
}
}
[Theory]
[InlineData(9.51)]
[InlineData(10.0)]
[InlineData(9.6)]
public void OversizedInsertIsNeverProposed(double side)
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", side, side, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
}
[Fact]
public void UnsupportedCoordinateScaleFailsClosed()
{
const double scale = 1e15;
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
(scale, scale), (-scale, scale));
program.MoveTo(-scale / 2, -scale / 2);
program.LineTo(-scale / 2, scale / 2);
program.LineTo(scale / 2, scale / 2);
program.LineTo(scale / 2, -scale / 2);
program.LineTo(-scale / 2, -scale / 2);
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
}
[Fact]
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
}
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
double centerX = 0, double centerY = 0)
{
var sin = System.Math.Sin(pose.Rotation);
var cos = System.Math.Cos(pose.Rotation);
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
{
var dx = pose.X + x * cos - y * sin - centerX;
var dy = pose.Y + x * sin + y * cos - centerY;
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
$"corner ({dx}, {dy}) does not clear the circular wall");
}
}
[Fact]
public void CancellationIsNotReportedAsNoFit()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
cancelled.Token));
}
}
@@ -0,0 +1,749 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)]
public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
private static NestPipelineRequest Request(string engine = "Irregular")
{
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
return new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
}
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine)
{
var request = Request(engine);
var result = NestPipeline.RunCutoutPreview(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.True(result.CanKeep);
var plate = Assert.Single(result.Plates);
Assert.Equal(2, plate.Parts.Count);
Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing);
Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing);
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested);
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
}
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses)
{
var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity);
return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound,
poses.Length == 0 ? Array.Empty<NestJobPlateResult>() :
new[] { new NestJobPlateResult(0, job.Plates[0], poses) },
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity,
placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))),
new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1,
job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) });
}
[Fact]
public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand()
{
var called = 0;
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
called++;
var proxy = Assert.Single(job.Parts);
Assert.StartsWith("__cutout-proxy-", proxy.Id);
Assert.True(proxy.Rotation.Allows(0));
Assert.False(proxy.Rotation.Allows(System.Math.PI / 2));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.Equal(1, called);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("did not complete"));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
AssertCannotCommit(result, request);
}
[Fact]
public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id;
var pose = new NestJobPlacement(id, 1, 11, 11, 0);
return Result(job, pose);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Single(p => p.Id == "part-1");
Assert.Equal(1, residual.Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement(residual.Id, 0, 22, 1, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing),
p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing));
}
[Fact]
public void UntrustedStockOverdrawOrRotationCannotBindAComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11,
System.Math.PI / 2));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("rotation constraint"));
var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }),
new NestJobPlateResult(1, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>())), "stub", request);
Assert.False(overdraw.CanKeep);
Assert.Empty(overdraw.Plates);
Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available"));
}
[Fact]
public void CancellationBeforePreparationDoesNotCallEngine()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
var engine = new Stub(_ => throw new Exception("Must not call Solve"));
Assert.Throws<OperationCanceledException>(() =>
NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token));
}
[Fact]
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
AssertCannotCommit(result, request);
}
private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request)
{
var nest = new Nest();
foreach (var item in request.Items)
nest.Drawings.Add(item.Drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
private sealed class CollectProgress : IProgress<NestJobProgress>
{
public List<NestJobProgress> Events { get; } = new();
public void Report(NestJobProgress value) => Events.Add(value);
}
[Fact]
public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress);
Assert.False(result.CanKeep);
Assert.Single(progress.Events);
Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage);
Assert.Equal(0, progress.Events[0].CommittedPlates);
Assert.Equal(0, progress.Events[0].CommittedParts);
}
private sealed class ProgressStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 1, 1));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job);
}
}
[Fact]
public void ReorderedButContiguousEngineIndicesAreAccepted()
{
var request = Request("stub");
request.Items[1].Quantity = 3;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } };
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 1, 22, 1, 0),
new NestJobPlacement("part-1", 0, 22, 9, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment)
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
return new NestJobResult(good.Status, good.StopReason, good.Plates,
fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment,
fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) });
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
{
var initial = Request("stub");
var request = initial with
{
Items = new[] { initial.Items[0], initial.Items[1],
new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } },
Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Where(p => p.Id != proxy.Id)
.Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0));
return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) }
.Concat(residual).ToArray());
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-2" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Same(result.Plates.Single().Parts[1].BaseDrawing,
result.Plates.Single().Parts[2].BaseDrawing);
}
[Fact]
public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = new NestJobOptions(maxPlates: 2)
};
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Raw.Plates.Count);
Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" },
plate.Placements.Select(p => p.PartId)));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Fact]
public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } };
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var first = job.Parts.Single(p => p.Id.EndsWith("-0"));
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.NoPlacementFound);
}), "stub", request);
Assert.Equal(1, calls);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status);
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId));
AssertCannotCommit(result, request);
}
[Fact]
public void DifferentFrameRequirementsShareScarceInsertsByIdentity()
{
var initial = Request("stub");
var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10));
initial.Items[1].Quantity = 2;
initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var request = initial with
{
Items = new[] { initial.Items[0],
new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] },
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" },
result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex));
}
[Fact]
public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var quarter = System.Math.PI / 2;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
Assert.True(proxy.Rotation.Allows(quarter));
Assert.False(proxy.Rotation.Allows(0));
return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var poses = result.Raw.Plates.Single().Placements;
Assert.Equal(quarter, poses[0].Rotation, 8);
Assert.Equal(quarter, poses[1].Rotation, 8);
// Independent square-hole oracle, not the production clearance predicate.
var localX = poses[1].Y - poses[0].Y;
var localY = -(poses[1].X - poses[0].X);
foreach (var dx in new[] { 0.0, 3.0 })
foreach (var dy in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + dx, 5.25, 14.75);
Assert.InRange(localY + dy, 5.25, 14.75);
}
}
[Fact]
public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant()
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("tight", new Size(22, 22), 1, 0.25),
new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) }
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal("wide", result.Raw.Plates.Single().StockId);
Assert.Equal(3, result.Plates.Single().Stock.Quadrant);
Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing);
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary()
{
var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N");
var calls = 0;
NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job =>
{
calls++;
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}));
try
{
var result = NestPipeline.RunCutoutPreview(Request(name));
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
finally
{
var registry = Assert.IsType<List<NestingEngineInfo>>(NestingEngineRegistry.AvailableEngines);
Assert.Equal(1, registry.RemoveAll(info => info.Name == name));
}
}
[Fact]
public void TwoCutoutsReserveDifferentCopiesWithinOneFrame()
{
var program = TestShapes.Rectangle(20, 20);
foreach (var origin in new[] { 2.0, 12.0 })
{
program.MoveTo(origin, origin);
program.LineTo(origin, origin + 6);
program.LineTo(origin + 6, origin + 6);
program.LineTo(origin + 6, origin);
program.LineTo(origin, origin);
}
var frame = new Drawing("two-hole-frame", program);
var insert = new Drawing("insert", TestShapes.Rectangle(4, 4));
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 2 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray();
Assert.Equal(2, inserts.Length);
Assert.All(inserts, p =>
{
var localX = p.X - 1;
var localY = p.Y - 1;
Assert.Contains(new[] { 2.0, 12.0 }, origin =>
localX >= origin + 0.25 && localX + 4 <= origin + 5.75
&& localY >= origin + 0.25 && localY + 4 <= origin + 5.75);
});
}
[Fact]
public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert()
{
var request = Request("stub");
request.Items[1].Priority = 3;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job);
}), "stub", request);
Assert.True(result.CanKeep);
Assert.Empty(result.Plates);
}
[Theory]
[InlineData(1, 11.0, 11.0)]
[InlineData(2, -11.0, 11.0)]
[InlineData(3, -11.0, -11.0)]
[InlineData(4, 11.0, -11.0)]
public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y)
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("sheet", new Size(22, 22), 1, 0.25,
new Spacing(0.5, 0.5), quadrant) },
Options = new NestJobOptions(maxPlates: 1)
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))),
"stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant);
Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X,
result.Raw.Plates.Single().Placements[0].Y));
}
[Fact]
public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle()
{
var request = Request("stub");
using var cts = new CancellationTokenSource();
Assert.Throws<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
cts.Cancel();
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request, token: cts.Token));
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
[Fact]
public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
NestJobStatus.Complete, NestJobStopReason.Completed,
Array.Empty<NestJobPlateResult>(),
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)),
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
AssertCannotCommit(result, request);
}
[Fact]
public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted },
progress.Events.Select(e => e.Stage));
Assert.Equal(2, progress.Events.Last().CommittedParts);
Assert.Equal(1, progress.Events.Last().CommittedPlates);
}
private sealed class ReportingStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var proxy = Assert.Single(job.Parts);
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 0, 0));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}
}
[Fact]
public void AutomaticBundleRetainsStockFeasibleQuarterTurn()
{
var program = TestShapes.Rectangle(20, 10);
program.MoveTo(2, 2);
program.LineTo(2, 8);
program.LineTo(18, 8);
program.LineTo(18, 2);
program.LineTo(2, 2);
var request = new NestPipelineRequest("Irregular", new[] {
new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } },
new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var frame = result.Raw.Plates.Single().Placements[0];
Assert.Equal(System.Math.PI / 2, frame.Rotation, 7);
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
[Fact]
public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40);
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert"));
AssertCannotCommit(result, request);
}
[Fact]
public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var options = new NestJobOptions("Strip", maxPlates: 1,
salvageRate: 0.4, minimumSalvageDimension: 2);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = options
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Same(options, job.Options);
Assert.Equal("Strip", job.Options.PlacementStrategy);
Assert.Equal(0.4, job.Options.SalvageRate);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in job.Parts)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("MaxPlates"));
AssertCannotCommit(result, request);
}
[Fact]
public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
const double angle = 0.3;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", program), Quantity = 1,
StepAngle = 1, RotationStart = angle, RotationEnd = angle },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)),
Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } },
new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))),
"stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var poses = result.Raw.Plates.Single().Placements;
Assert.Equal(angle, poses[1].Rotation, 8);
var dx = poses[1].X - poses[0].X;
var dy = poses[1].Y - poses[0].Y;
var cosine = System.Math.Cos(angle);
var sine = System.Math.Sin(angle);
var localX = dx * cosine + dy * sine;
var localY = -dx * sine + dy * cosine;
foreach (var x in new[] { 0.0, 3.0 })
foreach (var y in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + x, 5.25, 14.75);
Assert.InRange(localY + y, 5.25, 14.75);
}
}
[Fact]
public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40,
StepAngle = 1, RotationStart = 0, RotationEnd = 0 },
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)),
Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) });
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-")));
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity);
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40),
(f.Requested, f.Placed, f.Unplaced)));
}
[Fact]
public void OrdinaryPipelineIsStillUnchanged()
{
var request = Request("stub");
var result = NestPipeline.Run(new Stub(job =>
{
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job);
}), "stub", request);
Assert.True(result.CanKeep);
Assert.Empty(result.Plates);
}
}
@@ -0,0 +1,228 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutRouterTests
{
private static NestJobPart Frame()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
return JobBuilder.Part("frame", program, 1);
}
[Fact]
public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3);
var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0));
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(3, poses.Count(p => p.PartId == small.Id));
Assert.Single(poses, p => p.PartId == large.Id);
Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice);
Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ImpossibleRemainderNeverDisplacesCertifiedCopies()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0));
var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0));
var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25);
Assert.Equal(3, together.Count);
Assert.All(together, p => Assert.Equal(small.Id, p.PartId));
Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, impossible }, together);
}
[Fact]
public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0));
var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25);
Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity);
Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity);
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ResidualNfpDoesNotOverlapTheFirstLargeInsert()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
{
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(12, poses.Count);
Assert.Equal(11, origins.Count(row => row.Lattice));
Assert.Single(origins, row => !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
{
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Equal(2, poses.Count);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("same", 1, 1, 3);
Assert.Throws<ArgumentException>(() => CutoutRouter.Fill(frame, 0,
new[] { insert, insert }, 0.25));
}
[Fact]
public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1);
var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3);
var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25);
Assert.Equal(3, early.Count);
var blocked = early[0] with { PartId = first.Id };
var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) };
var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25,
CutoutLatticeFill.DefaultShiftSteps, occupied, default);
Assert.Equal(3, later.Count);
Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked,
JobPartGeometry.Read(more.Geometry), p, 0.25)));
}
[Fact]
public void TinyInsertDoesNotAllocateAnUnboundedLattice()
{
var frame = Frame();
var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0));
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0));
var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0);
Assert.Equal(3, poses.Count);
}
[Fact]
public void CancellationIsNotNoFit()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutRouter.Fill(Frame(), 0,
new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token));
}
private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts,
IReadOnlyList<NestJobPlacement> poses)
{
var stock = JobBuilder.Stock("sheet", 22, 22, 0.25);
var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock });
var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds;
var offsetX = 1 - frameBounds.Left;
var offsetY = 1 - frameBounds.Bottom;
var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) }
.Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray();
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, placements) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0) }
.Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity,
poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id))))
.ToArray(),
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var pose in poses)
{
var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry);
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25));
// Independent square-wall oracle: every corner of each rectangular insert clears the hole.
var bounds = geometry.Bounds;
foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom),
(bounds.Right, bounds.Top), (bounds.Left, bounds.Top) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
if (frameBounds.Left >= 0)
{
Assert.InRange(rx, 5.25, 14.75);
Assert.InRange(ry, 5.25, 14.75);
}
else
{
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
}
}
}
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
for (var i = 0; i < poses.Count; i++)
for (var j = i + 1; j < poses.Count; j++)
{
var a = Bounds(poses[i]);
var b = Bounds(poses[j]);
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
$"independent material gap too small: {poses[i]} vs {poses[j]}");
}
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
{
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
(box.Right, box.Top), (box.Left, box.Top) };
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
return (x.Min(), x.Max(), y.Min(), y.Max());
}
}
}
+11 -4
View File
@@ -9,9 +9,9 @@ public class NestJobCostTests
public static IEnumerable<object[]> EdgeCases() public static IEnumerable<object[]> EdgeCases()
{ {
foreach (var rate in new[] { 0.0, 0.5, 1.0 }) foreach (var rate in new[] { 0.0, 0.5, 1.0 })
foreach (var edge in new[] { "bottom", "top", "left", "right" }) foreach (var edge in new[] { "bottom", "top", "left", "right" })
foreach (var quadrant in new[] { 1, 2, 3, 4 }) foreach (var quadrant in new[] { 1, 2, 3, 4 })
yield return new object[] { rate, edge, quadrant }; yield return new object[] { rate, edge, quadrant };
} }
[Theory] [Theory]
@@ -84,7 +84,14 @@ public class NestJobCostTests
TestDrawingFactory.Rectangle(4, 3)), 5); TestDrawingFactory.Rectangle(4, 3)), 5);
var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options); var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options);
foreach (var sheet in result.Plates) foreach (var sheet in result.Plates)
Assert.Equal(LegacyNestJobCost.EstimateNetArea(cleanJob, sheet), NestJobCost.NetSheetArea(job, sheet)); {
var area = LegacyNestJobCost.EstimateNetArea(cleanJob, sheet);
Assert.Equal(area, NestJobCost.NetSheetArea(job, sheet));
var priced = new NestPlateStock(sheet.Stock.Id, sheet.Stock.Size, sheet.Stock.Quantity,
sheet.Stock.PartSpacing, sheet.Stock.EdgeSpacing, sheet.Stock.Quadrant, 37);
var pricedSheet = new NestJobPlateResult(sheet.PlateIndex, priced, sheet.Placements);
Assert.Equal(37 * (area / sheet.Stock.Area), NestJobCost.NetSheetCost(job, pricedSheet));
}
Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job)); Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job));
Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000, Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000,
NestJobCost.Evaluate(job, result)); NestJobCost.Evaluate(job, result));
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
Assert.Equal(2, Assert.Single(applied).Parts.Count); Assert.Equal(2, Assert.Single(applied).Parts.Count);
} }
[Fact]
public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
drawing.Program = TestDrawingFactory.Rectangle(100, 100);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
private sealed class PreMutationStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}
}
private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source)
: OpenNest.CNC.LinearMove(x, y)
{
public override OpenNest.CNC.CodeType Type
{
get
{
source.Cancel();
return base.Type;
}
}
}
[Fact]
public void CancellationDuringFreshnessScanDoesNotMutateNest()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid);
using var cts = new CancellationTokenSource();
var proposed = Assert.Single(Assert.Single(result.Plates).Parts);
proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts);
Assert.Throws<OperationCanceledException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token));
Assert.True(cts.IsCancellationRequested);
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
[Fact] [Fact]
public void CancelledCommitDoesNotCreateAnyPlate() public void CancelledCommitDoesNotCreateAnyPlate()
{ {
@@ -258,6 +258,44 @@ public class NestPipelineTests
); );
} }
[Fact]
public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot()
{
var item = Item("bracket", 1);
var result = NestPipeline.Run(new StubEngine(job =>
{
item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100);
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
}), "Mutator", Request("Mutator", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket"));
}
private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y)
{
public override ICode Clone()
{
var before = EndPoint;
EndPoint = new Vector(100, 100);
try { return base.Clone(); }
finally { EndPoint = before; }
}
}
[Fact]
public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry()
{
var item = Item("bracket", 1);
item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0);
var result = NestPipeline.Run(new StubEngine(job =>
OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))),
"Transient", Request("Transient", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket"));
}
[Fact] [Fact]
public void CancellationPropagatesWithoutAResult() public void CancellationPropagatesWithoutAResult()
{ {
@@ -0,0 +1,318 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class RepeatedPlateGroupingTests
{
private static NestItem Item(string name = "same-name", int quantity = 10) => new()
{
Drawing = new Drawing(name, TestDrawingFactory.Rectangle(2, 2)),
Quantity = quantity,
};
private static NestPlateStock Stock(string id = "sheet") => new(id, new Size(20, 30), 10, 0.25);
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
private static NestJobResult Raw(IEnumerable<NestJobPlateResult> sheets) =>
new(NestJobStatus.Complete, NestJobStopReason.Completed, sheets, [], []);
private static NestJobPlateResult Sheet(NestPlateStock stock, int index, params NestJobPlacement[] poses) =>
new(index, stock, poses);
private static NestJobPlacement Pose(string id = "part-0", int instance = 0,
double x = 1, double y = 1, double rotation = 0) => new(id, instance, x, y, rotation);
[Fact]
public void NonAdjacentOrderedDuplicatesKeepFirstOccurrenceAndIgnoreInstanceIndices()
{
var item = Item();
var input = new NestPipelineRequest("stub", [item], [Stock()]);
NestJobResult? raw = null;
var result = NestPipeline.Run(new Stub(job => raw = Raw([
Sheet(job.Plates[0], 7, Pose(instance: 0), Pose(instance: 1, x: 6)),
Sheet(job.Plates[0], 8, Pose(instance: 2, x: 3)),
Sheet(job.Plates[0], 9, Pose(instance: 3), Pose(instance: 4, x: 6))])), "stub", input);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Same(raw, result.Raw);
Assert.Equal(new[] { 7, 8 }, result.Plates.Select(p => p.PlateIndex));
Assert.Equal(new[] { 2, 1 }, result.Plates.Select(p => p.Quantity));
Assert.Equal(new[] { 1d, 6d }, result.Plates[0].Parts.Select(p => p.Location.X));
Assert.All(result.Plates.SelectMany(p => p.Parts), p => Assert.Same(item.Drawing, p.BaseDrawing));
Assert.Equal(5, result.Plates.Sum(p => p.Parts.Count * p.Quantity));
Assert.Equal(0, item.Drawing.Quantity.Nested);
Assert.Empty(NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
result.Plates, result.DrawingsByPartId, consolidate: true));
}
[Theory]
[InlineData("x")]
[InlineData("y")]
[InlineData("rotation")]
[InlineData("negative-zero")]
public void ScalarBitDifferenceDoesNotGroup(string field)
{
var second = field switch
{
"x" => Pose(instance: 1, x: double.BitIncrement(1)),
"y" => Pose(instance: 1, y: double.BitIncrement(1)),
"rotation" => Pose(instance: 1, rotation: double.BitIncrement(0)),
_ => Pose(instance: 1, rotation: -0.0),
};
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, second)])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
}
[Fact]
public void ReorderedPlacementsAndDistinctSameNameRequirementsStaySeparate()
{
var a = Item();
var b = Item();
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose(), Pose("part-1", x: 6)),
Sheet(job.Plates[0], 1, Pose("part-1", 1, x: 6), Pose(instance: 1)),
Sheet(job.Plates[0], 2, Pose(instance: 2)),
Sheet(job.Plates[0], 3, Pose("part-1", 2))])),
"stub", new NestPipelineRequest("stub", [a, b], [Stock()]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(4, result.Plates.Count);
Assert.Same(a.Drawing, result.Plates[2].Parts[0].BaseDrawing);
Assert.Same(b.Drawing, result.Plates[3].Parts[0].BaseDrawing);
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
}
[Fact]
public void SameSizeDifferentStockIdentityDoesNotGroup()
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[1], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock("a"), Stock("b")]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Count);
}
[Theory]
[InlineData("width")]
[InlineData("length")]
[InlineData("spacing")]
[InlineData("left")]
[InlineData("right")]
[InlineData("top")]
[InlineData("bottom")]
[InlineData("quadrant")]
[InlineData("cost")]
[InlineData("quantity")]
public void EveryStockSettingIsPartOfExactIdentity(string field)
{
var size = new Size(field == "width" ? double.BitIncrement(20) : 20,
field == "length" ? double.BitIncrement(30) : 30);
var spacing = new Spacing
{
Left = field == "left" ? double.Epsilon : 0,
Right = field == "right" ? double.Epsilon : 0,
Top = field == "top" ? double.Epsilon : 0,
Bottom = field == "bottom" ? double.Epsilon : 0,
};
var second = new NestPlateStock("sheet", size, field == "quantity" ? 11 : 10,
field == "spacing" ? double.BitIncrement(0.25) : 0.25,
spacing, field == "quadrant" ? 2 : 1, field == "cost" ? 1 : null);
var groups = RepeatedPlateGrouping.Create([
Sheet(Stock(), 0, Pose()), Sheet(second, 1, Pose(instance: 1))], true);
Assert.Equal(2, groups.Count);
}
[Fact]
public void ToleranceEquivalentValidStockSettingsStillDoNotGroup()
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()),
Sheet(new NestPlateStock("sheet", job.Plates[0].Size, 10,
double.BitIncrement(0.25)), 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Count);
}
[Fact]
public void EmptySheetsNeverGroup()
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0), Sheet(job.Plates[0], 1)])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
Assert.True(result.IsValid);
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void QuantityWeightedCostAndSalvageMatchExpandedPhysicalSheets(bool priced)
{
var item = Item(quantity: 3);
var stock = new NestPlateStock("sheet", new Size(20, 30), 4, 0.25,
new Spacing(), 1, cost: priced ? 7 : null);
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1)),
Sheet(job.Plates[0], 2, Pose(instance: 2, x: 15))])), "stub",
new NestPipelineRequest("stub", [item], [stock],
new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { 2, 1 }, result.Plates.Select(p => p.Quantity));
var summary = NestCostSummary.FromAccepted(result);
var gross = result.Raw.Plates.Sum(p => NestJobCost.GrossSheetCost(p.Stock));
var net = NestJobCost.Evaluate(result.Job, result.Raw);
Assert.Equal(gross, summary.GrossTotal, 10);
Assert.Equal(net, summary.NetScore, 10);
Assert.Equal(gross - net, summary.SalvageCredit, 10);
Assert.True(summary.SalvageCredit > 0);
Assert.Equal(3, Assert.Single(summary.Stock).Used);
Assert.Equal(gross, Assert.Single(summary.Stock).GrossSubtotal, 10);
}
[Fact]
public void InvalidDuplicatesRemainPhysicalEvenWithExplicitCommitConsent()
{
var item = Item();
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose(), Pose(instance: 1)),
Sheet(job.Plates[0], 1, Pose(instance: 2), Pose(instance: 3))])),
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
Assert.False(result.IsValid);
Assert.True(result.CanKeep);
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
var nest = new Nest();
nest.Drawings.Add(item.Drawing);
using var manager = new PlateManager(nest);
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true);
Assert.Equal(2, applied.Count);
Assert.Equal(4, item.Drawing.Quantity.Nested);
}
[Theory]
[InlineData("ghost", 1)]
[InlineData(null, 1)]
[InlineData("part-0", double.NaN)]
public void MalformedDuplicatesRemainUnkeepable(string? id, double x)
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose(id!, x: x)),
Sheet(job.Plates[0], 1, Pose(id!, 1, x: x))])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Equal(2, result.Raw.Plates.Count);
}
[Fact]
public void ProposalKeepsThreeFieldConstructorAndDeconstructionAndRequiresPositiveQuantity()
{
var stock = Stock();
var proposed = new ProposedPlate(4, stock, []);
var (index, actualStock, parts) = proposed;
Assert.Equal(4, index);
Assert.Same(stock, actualStock);
Assert.Empty(parts);
Assert.Equal(1, proposed.Quantity);
Assert.Equal(2, (proposed with { Quantity = 2 }).Quantity);
Assert.Throws<ArgumentOutOfRangeException>(() => proposed with { Quantity = 0 });
Assert.Throws<ArgumentOutOfRangeException>(() => proposed with { Quantity = -1 });
}
[Theory]
[InlineData("program")]
[InlineData("pose")]
[InlineData("drawing")]
public void GroupedProposalFreshnessStillRefusesMutationBeforeAttachment(string field)
{
var item = Item(quantity: 2);
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
var proposed = Assert.Single(result.Plates);
Assert.Equal(2, proposed.Quantity);
if (field == "program") ((Motion)proposed.Parts[0].Program.Codes[1]).EndPoint = new Vector(10, 10);
else if (field == "pose") proposed.Parts[0].Location = new Vector(double.BitIncrement(1), 1);
else item.Drawing.Program = TestDrawingFactory.Rectangle(10, 10);
var nest = new Nest();
nest.Drawings.Add(item.Drawing);
using var manager = new PlateManager(nest);
Assert.Throws<InvalidOperationException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager, true));
Assert.Empty(nest.Plates);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Theory]
[InlineData("quantity")]
[InlineData("stock")]
[InlineData("index")]
public void RawToGroupedMatchChecksQuantityStockAndFirstPhysicalIndex(string field)
{
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
var proposed = Assert.Single(result.Plates);
var changed = field switch
{
"quantity" => proposed with { Quantity = 1 },
"stock" => proposed with { Stock = Stock("other") },
_ => proposed with { PlateIndex = 1 },
};
Assert.NotEmpty(NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
[changed], result.DrawingsByPartId, true));
}
private sealed class TransientMove(double x, double y) : LinearMove(x, y)
{
public override ICode Clone()
{
var before = EndPoint;
EndPoint = new Vector(100, 100);
try { return base.Clone(); }
finally { EndPoint = before; }
}
}
[Fact]
public void GroupedBindingTransientCloneEditRemainsUnkeepable()
{
var item = Item();
item.Drawing.Program.Codes[^1] = new TransientMove(0, 0);
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("Bound part"));
}
[Fact]
public void CancelledGroupingAndCommitLeaveInputsUntouched()
{
using var source = new CancellationTokenSource();
source.Cancel();
Assert.Throws<OperationCanceledException>(() => RepeatedPlateGrouping.Create([
Sheet(Stock(), 0, Pose()), Sheet(Stock(), 1, Pose(instance: 1))], true, source.Token));
var item = Item();
var result = NestPipeline.Run(new Stub(job => Raw([
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
var nest = new Nest();
using var manager = new PlateManager(nest);
Assert.Throws<OperationCanceledException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager,
token: source.Token));
Assert.Empty(nest.Plates);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
}
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class StockCostTests
{
public static IEnumerable<object[]> Engines()
{
foreach (var engine in new[] { "Rectangles", "Irregular", "StockLadder", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" })
foreach (var factor in new[] { 1e-12, 1.0, 100.0 })
yield return new object[] { engine, factor };
}
private static NestPlateStock Stock(string id, double cost, int quantity = 2, double size = 10) =>
new(id, new Size(size, size), quantity, 0.1, default, 1, cost);
private static NestJob Job(params NestPlateStock[] stocks) => new(
new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1) }, stocks);
[Fact]
public void ValidationRejectsForgedStockPrice()
{
var job = Job(Stock("offered", 10));
var forged = Stock("offered", 1);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, forged, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.NotEmpty(NestLayoutCheck.Violations(job, result));
}
[Fact]
public void UnavailablePriceDoesNotChangeAreaModeOrLegacyPenalty()
{
var job = Job(new NestPlateStock("available", new Size(10, 10)), Stock("unavailable", 10000, 0));
Assert.False(NestJobCost.UsesExplicitCosts(job));
Assert.Equal(100, NestJobCost.UnplacedPartPenalty(job));
}
[Fact]
public void FixedStrategyUsesSalvageOnlyForPricedStock()
{
var poses = new[] { new NestJobPlacement("part", 0, 0, 0, 0) };
var candidate = new PlateCandidate(poses);
var priced = Job(Stock("small", 10, size: 5), Stock("large", 12, size: 10));
var options = new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1);
var pricedJob = new NestJob(priced.Parts, priced.Plates, options);
Assert.True(new NestJobCandidateComparer(pricedJob).Compare(candidate, priced.Plates[1], 1,
candidate, priced.Plates[0], 0) > 0);
var unpriced = new NestJob(priced.Parts, new[] { new NestPlateStock("small", new Size(5, 5)),
new NestPlateStock("large", new Size(10, 10)) }, options);
Assert.True(new NestJobCandidateComparer(unpriced).Compare(candidate, unpriced.Plates[0], 0,
candidate, unpriced.Plates[1], 1) > 0);
}
[Theory]
[InlineData(0, 20, 0)]
[InlineData(0.5, 14, 6)]
public void SummarySeparatesGrossCreditAndNet(double rate, double net, double credit)
{
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(4, 4));
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 6)));
Assert.Empty(result.Violations);
var summary = NestCostSummary.FromAccepted(result);
Assert.Equal("supplied-cost", summary.Basis);
Assert.Equal(20, summary.GrossTotal);
Assert.Equal(net, summary.NetScore, 10);
Assert.Equal(credit, summary.SalvageCredit, 10);
Assert.Equal(1, Assert.Single(summary.Stock).Used);
}
[Theory]
[MemberData(nameof(Engines))]
public void PricesReverseSelectionWithoutChangingFulfillment(string engine, double factor)
{
foreach (var cheap in new[] { "small", "large" })
{
var job = Job(Stock("small", (cheap == "small" ? 1 : 10) * factor),
Stock("large", (cheap == "large" ? 1 : 10) * factor, size: 12));
var result = NestingEngineRegistry.Create(engine).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Assert.Equal(cheap, Assert.Single(result.Plates).StockId);
Assert.Equal(factor, NestJobCost.Evaluate(job, result));
}
}
[Theory]
[InlineData("Rectangles")]
[InlineData("Irregular")]
[InlineData("StockLadder")]
[InlineData("Default")]
[InlineData("Fill")]
public void SameSizeStockRetainsIndependentFiniteInventory(string engine)
{
var stocks = new[] { Stock("expensive", 10, 2, 5), Stock("cheap", 1, 1, 5) };
var job = new NestJob(new[] { new NestJobPart("part",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 3) }, stocks);
var result = NestingEngineRegistry.Create(engine).Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
Assert.Equal("cheap", result.Plates[0].StockId);
Assert.Single(result.Plates.Where(p => p.StockId == "cheap"));
Assert.Equal(21, NestJobCost.Evaluate(job, result));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void ExplicitCostsMustBeFiniteAndPositive(double cost) =>
Assert.Throws<ArgumentOutOfRangeException>(() => Stock("bad", cost));
[Fact]
public void MixedModeRejectsOnlyAvailableStock()
{
Assert.Throws<ArgumentException>(() => Job(Stock("priced", 2), new NestPlateStock("missing", new Size(10, 10))));
var job = Job(Stock("priced", 2), new NestPlateStock("unavailable", new Size(10, 10), 0));
NestJobValidator.Validate(job);
Assert.Equal(2, NestJobCost.UnplacedPartPenalty(job));
}
[Theory]
[InlineData(0, 5, 20)]
[InlineData(0.5, 0, 20)]
[InlineData(0.5, 7, 20)]
[InlineData(0.5, 6, 14)]
public void PricesCreditSalvageProportionally(double rate, double minimum, double expected)
{
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
var job = new NestJob(Job(stock).Parts, new[] { stock }, new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
Assert.Equal(expected, NestJobCost.NetSheetCost(job, sheet), 10);
Assert.Equal(expected, NestJobCost.NetSheetCost(job.Options, stock, new Box(0, 0, 4, 4)), 10);
Assert.Equal(expected * 5, NestJobCost.NetSheetArea(job, sheet), 10);
}
[Fact]
public void AggregateOverflowFailsClearly()
{
var stock = Stock("huge", double.MaxValue);
var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 2) }, new[] { stock });
var builder = new NestJobResultBuilder(job);
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
Assert.Throws<OverflowException>(() => NestJobCost.Evaluate(job, builder.Build(NestJobStopReason.Completed)));
}
[Fact]
public void BuilderMapsLegacyZeroAndSnapshotsPositiveCosts()
{
var option = new PlateOption { Width = 10, Length = 10 };
Assert.Null(Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option })).Cost);
option.Cost = 12;
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option }));
option.Cost = 50;
Assert.Equal(12, stock.Cost);
Assert.Throws<ArgumentException>(() => NestStockBuilder.FromTemplate(new Plate(10, 10),
new[] { option, new PlateOption { Width = 12, Length = 12 } }));
}
}
@@ -0,0 +1,297 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// C16 (PM 6859db53): a clamp zone the job validator accepts may be far smaller than the
/// validation outline tolerance (here 1e-5 square). Every keep-out consumer must check it
/// without throwing, flag a placement whose material overlaps it (plus the D6 clearance),
/// and keep a placement that only touches or clears it clear. Tolerances are unchanged (C5):
/// the expected verdicts below are exact rectangle arithmetic, not the checker's output.
/// </summary>
public class TinyClampZoneTests
{
private const double Tiny = 1e-5;
private static readonly ClampZone TinyZone = new("tiny", new Box(8, 0, Tiny, Tiny));
private static Program SquareProgram()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(2, 0));
program.Codes.Add(new LinearMove(2, 2));
program.Codes.Add(new LinearMove(0, 2));
program.Codes.Add(new LinearMove(0, 0));
return program;
}
private static NestJobPart Square() =>
new("square", PartGeometrySnapshot.FromProgram(SquareProgram()), 1, 0, RotationPolicy.Fixed(0));
private static NestPlateStock Stock(double bottom) =>
new("sheet", new Size(10, 12), null, 0, new Spacing(0, bottom, 0, 0), 1,
new[] { TinyZone });
private static NestJob Job(double bottom) => new(new[] { Square() }, new[] { Stock(bottom) });
private static IReadOnlyList<string> ClampViolations(NestJob job, NestJobResult result) =>
NestLayoutCheck.Violations(job, result).Where(v => v.Contains("clamp zone")).ToList();
[Fact]
public void RectanglesResultOnATinyClampIsCheckedWithoutThrowing()
{
// The PM probe: Rectangles places the square flush against the tiny clamp.
var job = Job(0);
NestJobValidator.Validate(job);
var result = new RectanglesNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
var violations = ClampViolations(job, result);
// Independent oracle: the square's footprint shares no positive area with the zone.
var pose = Assert.Single(result.Plates.SelectMany(p => p.Placements));
var overlapX = System.Math.Min(pose.X + 2, 8 + Tiny) - System.Math.Max(pose.X, 8);
var overlapY = System.Math.Min(pose.Y + 2, Tiny) - System.Math.Max(pose.Y, 0);
var expectOverlap = overlapX > 0 && overlapY > 0;
Assert.Equal(expectOverlap, violations.Count > 0);
}
[Fact]
public void DefaultSolvesATinyClampJobToAValidResult()
{
var job = Job(0);
var result = new DefaultNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Empty(NestLayoutCheck.Violations(job, result));
}
// (bottom edge spacing, x, y, material overlaps the zone inflated by the clearance).
// The keep-out is [8 - c, 8 + 1e-5 + c] x [-c, 1e-5 + c] with c = bottom - 0.0005
// (the shared spacing slack); the 2x2 square spans [x, x+2] x [y, y+2].
[Theory]
[InlineData(0.0, 7.5, 0.0, true)] // covers the zone
[InlineData(0.0, 6.0, 0.0, false)] // right edge flush on the zone's left side
[InlineData(0.0, 8.00001, 0.0, false)] // left edge flush on the zone's right side
[InlineData(0.0, 8.0, Tiny, false)] // bottom edge flush on the zone's top
[InlineData(0.0, 3.0, 0.0, false)] // well clear
[InlineData(0.25, 7.5, 0.1, true)] // over the zone, inside the clearance band
[InlineData(0.25, 8.0, 0.24, true)] // above the zone but inside the clearance
[InlineData(0.25, 8.25001, 0.1, false)] // exactly at the clearance to the right
[InlineData(0.25, 7.0, 0.25001, false)] // exactly at the clearance above
[InlineData(0.25, 3.0, 0.25, false)] // well clear
public void LayoutCheckJudgesPlacementsAgainstATinyClamp(double bottom, double x, double y,
bool overlaps)
{
var job = Job(bottom);
NestJobValidator.Validate(job);
var stock = job.Plates[0];
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("square", 0, x, y, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
var violations = ClampViolations(job, result);
Assert.Equal(overlaps, violations.Count > 0);
}
private static Program FrameProgram()
{
// 4x4 square with a 2x2 hole in its middle.
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(4, 0));
program.Codes.Add(new LinearMove(4, 4));
program.Codes.Add(new LinearMove(0, 4));
program.Codes.Add(new LinearMove(0, 0));
program.Codes.Add(new RapidMove(1, 1));
program.Codes.Add(new LinearMove(1, 3));
program.Codes.Add(new LinearMove(3, 3));
program.Codes.Add(new LinearMove(3, 1));
program.Codes.Add(new LinearMove(1, 1));
return program;
}
// The tiny zone inside the frame's hole is clear; inside its material it is not.
[Theory]
[InlineData(6.0, -2.0, false)] // hole spans [7, 9] x [-1, 1] around the zone
[InlineData(6.0, 0.0, true)] // the frame's bottom bar covers the zone
public void LayoutCheckSeesHolesAroundATinyClamp(double x, double y, bool overlaps)
{
var frame = new NestJobPart("frame", PartGeometrySnapshot.FromProgram(FrameProgram()), 1, 0,
RotationPolicy.Fixed(0));
var job = new NestJob(new[] { frame }, new[] { Stock(0) });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], new[] { new NestJobPlacement("frame", 0, x, y, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.Equal(overlaps, ClampViolations(job, result).Count > 0);
}
[Theory]
[InlineData(7.5, 0.0, true)]
[InlineData(6.0, 0.0, false)]
[InlineData(8.00001, 0.0, false)]
[InlineData(3.0, 0.0, false)]
public void InteractiveKeepOutTestJudgesATinyClamp(double x, double y, bool overlaps)
{
var drawing = new Drawing("square", SquareProgram());
var part = new Part(drawing, new Vector(x, y));
Assert.Equal(overlaps, InteractiveClamps.MaterialInKeepOut(part, new Box(8, 0, Tiny, Tiny)));
}
private static Plate TinyZonePlate()
{
var plate = new Plate(new Size(10, 12)); // zero edge and part spacing
plate.ClampZones.Add(TinyZone);
return plate;
}
// Positive-area overlap with the zone, admitting 1e-9 of floating closure (a ten
// thousandth of the zone): a push that stops flush on the zone is clear.
private static bool EntersTinyZone(Part part) =>
System.Math.Min(part.BoundingBox.Right, 8 + Tiny) - System.Math.Max(part.BoundingBox.Left, 8) > 1e-9
&& System.Math.Min(part.BoundingBox.Top, Tiny) - System.Math.Max(part.BoundingBox.Bottom, 0) > 1e-9;
[Theory]
[InlineData("Fill")]
[InlineData("Strip")]
[InlineData("Vertical Remnant")]
[InlineData("Horizontal Remnant")]
public void InteractiveFillNeverCoversATinyClamp(string strategy)
{
var plate = TinyZonePlate();
var item = new NestItem { Drawing = new Drawing("square", SquareProgram()), Quantity = 30 };
var parts = PlateFillService.FillItem(strategy, plate, item, plate.WorkArea(), null,
System.Threading.CancellationToken.None);
Assert.NotEmpty(parts);
Assert.DoesNotContain(parts, EntersTinyZone);
}
[Fact]
public void PushDownStopsBeforeCoveringATinyClamp()
{
var plate = TinyZonePlate();
var mover = new Part(new Drawing("square", SquareProgram()), new Vector(7.5, 5));
plate.Parts.Add(mover);
Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
Assert.False(EntersTinyZone(mover), $"pushed to ({mover.Location.X:R},{mover.Location.Y:R})");
}
// PM 407f5eb7: the tolerance-free test must read the part's real material, not the
// 1e-4-grid validation outline or a 0.001 arc flattening.
private static Program RectProgram(double w, double h)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(w, 0);
program.LineTo(w, h);
program.LineTo(0, h);
program.LineTo(0, 0);
return program;
}
private static Program DiskProgram()
{
// Radius 2 centred at (10, 10), two absolute CCW half circles.
var program = new Program();
program.MoveTo(12, 10);
program.ArcTo(8, 10, 10, 10, RotationType.CCW);
program.ArcTo(12, 10, 10, 10, RotationType.CCW);
return program;
}
private static Box DiskBox(double radius)
{
var cx = 10 + radius * System.Math.Cos(0.02);
var cy = 10 + radius * System.Math.Sin(0.02);
return new Box(cx - 0.000005, cy - 0.000005, Tiny, Tiny);
}
public static TheoryData<string, Box, bool> RealMaterialRows => new()
{
{ "rect-8.00004", new Box(8.00002, 0.5, Tiny, Tiny), true }, // wholly inside the part
{ "rect-8.00004", new Box(8.00005, 0.5, Tiny, Tiny), false }, // just beyond its edge
{ "rect-2.00004", new Box(2.000032, 1, 5e-6, Tiny), true },
{ "disk", DiskBox(1.9998), true }, // inside the true arc, outside its 0.001 chords
{ "disk", DiskBox(2.0002), false }, // just outside the true arc
// The arc cuts a corner off the zone, 6.2e-7 deep: less than a fine chord's sag.
{ "disk", new Box(11.935615871667656, 10.503377293956166, Tiny, Tiny), true },
};
private static Program MaterialProgram(string name) => name switch
{
"rect-8.00004" => RectProgram(8.00004, 2),
"rect-2.00004" => RectProgram(2.00004, 2),
_ => DiskProgram(),
};
[Theory]
[MemberData(nameof(RealMaterialRows))]
public void LayoutCheckReadsRealMaterialAroundATinyClamp(string material, Box zone, bool overlaps)
{
var program = MaterialProgram(material);
var stock = new NestPlateStock("sheet", new Size(20, 20), null, 0, default, 1,
new[] { new ClampZone("tiny", zone) });
var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1, 0,
RotationPolicy.Fixed(0)) }, new[] { stock });
NestJobValidator.Validate(job);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.Equal(overlaps, ClampViolations(job, result).Count > 0);
}
[Theory]
[MemberData(nameof(RealMaterialRows))]
public void InteractiveKeepOutTestReadsRealMaterial(string material, Box zone, bool overlaps)
{
var part = new Part(new Drawing(material, MaterialProgram(material)));
Assert.Equal(overlaps, InteractiveClamps.MaterialInKeepOut(part, zone));
}
// Delta review 2: a concave (notch) arc must not lose material either. The zone's corner
// sits 6e-7 into the material beside a semicircular notch, at a flattening vertex.
internal static (Program Program, Box Zone) NotchCornerFixture()
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(20, 0);
program.LineTo(20, 20);
program.LineTo(12, 20);
program.ArcTo(8, 20, 10, 20, RotationType.CW);
program.LineTo(0, 20);
program.LineTo(0, 0);
var arc = new ShapeProfile(OpenNest.Converters.ConvertProgram.ToGeometry(program))
.Perimeter.Entities.OfType<Arc>().Single();
var native = arc.ToPoints(arc.SegmentsForTolerance(Tiny / 4)).First(v => v.X > 11.9 && v.Y < 19.5);
var centre = new Vector(10, 20);
var outward = centre + (native - centre) / 2 * (2 + 6e-7);
return (program, new Box(outward.X - Tiny, outward.Y, Tiny, Tiny));
}
[Fact]
public void ConcaveArcMaterialIsNotLostBesideATinyClamp()
{
var (program, zone) = NotchCornerFixture();
var stock = new NestPlateStock("sheet", new Size(25, 25), null, 0, default, 1,
new[] { new ClampZone("tiny", zone) });
var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1, 0,
RotationPolicy.Fixed(0)) }, new[] { stock });
NestJobValidator.Validate(job);
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.NotEmpty(ClampViolations(job, result));
Assert.True(InteractiveClamps.MaterialInKeepOut(new Part(new Drawing("notch", program)), zone));
}
}
@@ -0,0 +1,339 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularClampZoneTests
{
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
new Dictionary<int, IReadOnlyList<PairPose>>();
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void CurvedPartsClearEveryInflatedClampInEveryQuadrant(int quadrant)
{
var stock = Stock("sheet", 10, 20, spacing: 0.1, quadrant: quadrant,
edge: new Spacing(0.1, 0.25, 0.1, 0.1), clampZones:
[new("left", new Box(0, 0, 3, 0.625)), new("middle", new Box(7, 0, 1.5, 0.625))]);
var job = Job([Part("disc", Disc(1), 9, RotationPolicy.Fixed(0))], [stock]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(9, Assert.Single(result.Plates).Placements.Count);
AssertDiscsClearClamps(result, 1);
LayoutAssert.Valid(job, result);
Assert.Contains(result.Plates[0].Placements, p => p.Y - 1 < stock.WorkArea.Bottom + 0.05);
}
[Theory]
[InlineData("Irregular")]
[InlineData("Rectangles")]
[InlineData("Default")]
public void BothLanesAndDefaultAvoidClamps(string engine)
{
var job = Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))],
[Stock("sheet", 5, 12, edge: new Spacing(0.25, 0.25, 0.25, 0.25),
clampZones: [new("front", new Box(0, 0, 3, 0.625))])]);
INestingEngine solver = engine switch
{
"Irregular" => new IrregularNestingEngine(),
"Rectangles" => new RectanglesNestingEngine(),
_ => new DefaultNestingEngine(),
};
var result = solver.Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
AssertDiscsClearClamps(result, 1);
LayoutAssert.Valid(job, result);
}
[Theory]
[InlineData(0)]
[InlineData(4)]
public void ClampClearanceUsesBottomEdgeSpacingNotPartSpacing(double partSpacing)
{
var stock = Stock("sheet", 4, 5, spacing: partSpacing,
edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new("rail", new Box(0, 0, 5, 0.625))]);
var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
Assert.InRange(pose.Y - 1, 0.875, 0.90);
AssertDiscsClearClamps(result, 1);
LayoutAssert.Valid(job, result);
}
[Fact]
public void FullRailBlocksTheOnlyRowInsteadOfReturningAnUnsafePlacement()
{
var stock = Stock("sheet", 2.5, 6, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new("rail", new Box(0, 0, 6, 0.625))]);
var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Empty(result.Plates);
Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced);
LayoutAssert.Valid(job, result);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
public void PairOnlyOrientationsAlsoLoseClampBlockedSpace(int axis)
{
var stock = Stock("sheet", 10, 10, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new("rail", new Box(0, 0, 10, 1))]);
var job = Job([Rectangle("bar", 2, 3, 2, RotationPolicy.Automatic)], [stock]);
var types = PartCatalog.Build(job);
var extra = PartCatalog.CreateOrientation(types[0], 100, System.Math.PI / 2)!;
var pair = new PairPose { A = extra, B = extra, Dx = 0, Dy = 3 };
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
var fill = new FrontierPacker(types, new NoFitCache(0), pairs, stock,
(PackAxis)axis, 1, new WorkCounter()).Fill([2], default);
Assert.Equal(2, fill.Parts.Count);
Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25));
if (axis == 0)
Assert.All(fill.Parts, p => Assert.Same(extra, p.Orientation));
AssertValidFill(job, fill);
}
[Fact]
public void BlockOnlyOrientationsAlsoLoseClampBlockedSpace()
{
var stock = Stock("sheet", 20, 12, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new("rail", new Box(0, 0, 12, 1))]);
var job = Job([Rectangle("bar", 2, 3, 6, RotationPolicy.Automatic)], [stock]);
var types = PartCatalog.Build(job);
using var blocks = new BlockCatalog(0.1, types, NoPairs, new WorkCounter(), long.MaxValue);
var fill = new FrontierPacker(types, new NoFitCache(0.1), NoPairs, stock,
PackAxis.Y, 1, new WorkCounter(), blocks).Fill([6], default);
Assert.Equal(6, fill.Parts.Count);
Assert.True(blocks.PreparationCount > 0);
Assert.Contains(fill.Parts, p => !types[0].Orientations.Contains(p.Orientation));
Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25));
AssertValidFill(job, fill);
}
[Fact]
public void SameSpacingStocksDoNotShareClampGeometry()
{
var wall = new ClampZone("rail", new Box(0, 0, 6, 1));
var job = Job([Rectangle("part", 2, 2, 1)],
[Stock("blocked", 2.5, 6, clampZones: [wall]), Stock("open", 2.5, 6)]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("open", Assert.Single(result.Plates).StockId);
LayoutAssert.Valid(job, result);
}
[Fact]
public void ConcavePartCanUseItsOpenNotchAroundAClamp()
{
// The only fitting pose surrounds the rail in an open notch. Blocking the
// part's bounding box instead of its NFP would incorrectly reject this job.
var program = Polyline((0, 0), (1, 0), (1, 3), (3, 3),
(3, 0), (4, 0), (4, 4), (0, 4));
var stock = Stock("sheet", 4.2, 4, edge: new Spacing(0, 0.1, 0, 0),
clampZones: [new("notch", new Box(1.5, 0, 1, 2))]);
var job = Job([Part("notched", program, 1, RotationPolicy.Fixed(0))], [stock]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
Assert.Equal(0, pose.X, 8);
Assert.Equal(0.1, pose.Y, 8);
// Analytic notch walls versus the rectangle inflated by 0.1.
Assert.True(pose.X + 1 <= 1.4 && pose.X + 3 >= 2.6 && pose.Y + 3 >= 2.1);
LayoutAssert.Valid(job, result);
}
[Fact]
public void DifferentClampedStocksAtTheSameSpacingKeepTheirOwnObstacles()
{
var job = Job([Rectangle("part", 2, 2, 2)],
[Stock("left", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(0, 0, 3, 1))]),
Stock("right", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(3, 0, 3, 1))])]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Plates.Count);
foreach (var sheet in result.Plates)
{
var pose = Assert.Single(sheet.Placements);
Assert.True(sheet.StockId == "left" ? pose.X >= 3 : pose.X + 2 <= 3);
}
LayoutAssert.Valid(job, result);
}
[Fact]
public void CancelledClampPackingDoesNotReturnAnEmptySuccess()
{
var stock = Stock("sheet", 4, 5, clampZones: [new("rail", new Box(0, 0, 5, 1))]);
var job = Job([Rectangle("part", 2, 2, 1)], [stock]);
var packer = new FrontierPacker(PartCatalog.Build(job), new NoFitCache(0), NoPairs,
stock, PackAxis.X, 1, new WorkCounter());
Assert.Throws<OperationCanceledException>(() => packer.Fill([1], new CancellationToken(true)));
}
[Fact]
public void RemoteSubGridClampDoesNotCrashWholeSolve()
{
// Exact reported reproducer: Stock takes Y width, then X length.
var stock = Stock("sheet", 10, 12,
clampZones: [new("remote-small-clamp", new Box(8, 0, 1e-5, 1e-5))]);
var job = Job([Rectangle("part", 2, 2, 1)], [stock]);
NestJobValidator.Validate(job);
var result = new IrregularNestingEngine().Solve(job);
AssertCompleteRectangleClearsClamps(result, 0, 0);
}
public static IEnumerable<object[]> SubGridClampCases()
{
foreach (var quadrant in Enumerable.Range(1, 4))
foreach (var fixture in Enumerable.Range(0, 6))
yield return [quadrant, fixture];
}
[Theory]
[MemberData(nameof(SubGridClampCases))]
public void SubGridClampsBlockTheOriginalRectanglePlacement(int quadrant, int fixture)
{
var (bounds, originX, originY, clearance) = fixture switch
{
0 => (new Box(0, 0, 1e-5, 1e-5), 0.0, 0.0, 0.0),
1 => (new Box(0.75, 0.5, 1e-5, 1e-5), 7.25, -3.125, 0.0),
2 => (new Box(0.75, 0.5, 1e-5, 0.75), 7.25, -3.125, 0.0),
3 => (new Box(0.5, 0.75, 0.75, 1e-5), 7.25, -3.125, 0.0),
// Positive dimensions can disappear even before grid conversion.
4 => (new Box(1, 1, 1e-20, 1e-20), 7.25, -3.125, 0.0),
_ => (new Box(0, 0, 1e-5, 1e-5), 0.0, 0.0, 1e-6),
};
var edge = new Spacing(0, clearance, 0, 0);
var stock = Stock("sheet", 10, 12, quadrant: quadrant, edge: edge,
clampZones: [new("tiny", bounds)]);
var part = Rectangle("part", 2, 2, 1, x: originX, y: originY);
var job = Job([part], [stock]);
NestJobValidator.Validate(job);
var zone = Assert.Single(stock.ClampZones);
if (fixture == 4)
{
Assert.Equal(zone.Bounds.Left, zone.Bounds.Right);
Assert.Equal(zone.Bounds.Bottom, zone.Bounds.Top);
}
// A drop-clamp mutation must fail: the same job without a clamp enters it.
var unclamped = Job([part], [Stock("sheet", 10, 12, quadrant: quadrant, edge: edge)]);
var original = new IrregularNestingEngine().Solve(unclamped);
AssertCompleteRectangleClearsClamps(original, originX, originY);
var originalPose = Assert.Single(Assert.Single(original.Plates).Placements);
Assert.True(RectangleEntersInflatedClamp(originalPose, originX, originY, zone.Bounds, clearance),
"The fixture must block the unclamped placement.");
var result = new IrregularNestingEngine().Solve(job);
AssertCompleteRectangleClearsClamps(result, originX, originY);
}
[Theory]
[InlineData(0, "0F90BA3EF6C6392CF1C1E01A6209D55CA88B4F968D6DD0D01850AE15A349FE88")]
[InlineData(1, "A970D56C5768763CE339999F91ADD7FFB9EC2AF6C1585CC51C4B0869D45D0C53")]
[InlineData(2, "65ECA7F7163DB0AB7BCA331FBF2A240604B25C44D62CE97CB2015FC78A9631AA")]
// Full result hashes captured on d71f086 before static-obstacle preparation.
public void EmptyClampLayoutsKeepBaselineBits(int fixture, string expected)
{
var job = fixture switch
{
0 => Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))], [Stock("sheet", 5, 12, spacing: 0.25)]),
1 => Job([Part("ell", LShape(4, 3, 1), 1), Part("tri", Triangle(3, 2), 1)],
[Stock("sheet", 10, 15, spacing: 0.25)]),
_ => Job([Rectangle("square", 4, 4, 3)], [Stock("sheet", 5, 5, spacing: 0.25)]),
};
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result))));
Assert.Equal(expected, hash);
}
private static void AssertCompleteRectangleClearsClamps(NestJobResult result, double originX, double originY)
{
// Do not call NestLayoutCheck here: its separate S1 tiny-clamp null-outline
// defect can throw when broad-phase bounds touch. This is an analytic oracle.
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(NestJobStopReason.Completed, result.StopReason);
Assert.Equal(new PartFulfillment("part", 1, 1, 0), Assert.Single(result.Fulfillment));
Assert.Equal(1, Assert.Single(result.StockUsage).Used);
var sheet = Assert.Single(result.Plates);
var pose = Assert.Single(sheet.Placements);
Assert.Equal("part", pose.PartId);
Assert.Equal(0, pose.InstanceIndex);
Assert.Equal(0, pose.Rotation);
var work = sheet.Stock.WorkArea;
var slack = NestTolerances.WorkAreaSlack;
var left = pose.X + originX;
var bottom = pose.Y + originY;
Assert.True(left >= work.Left - slack && bottom >= work.Bottom - slack
&& left + 2 <= work.Right + slack && bottom + 2 <= work.Top + slack);
foreach (var zone in sheet.Stock.ClampZones)
Assert.False(RectangleEntersInflatedClamp(pose, originX, originY, zone.Bounds,
sheet.Stock.EdgeSpacing.Bottom),
$"Rectangle at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}");
}
private static bool RectangleEntersInflatedClamp(NestJobPlacement pose, double originX, double originY,
Box bounds, double clearance)
{
// Decimal arithmetic retains the positive 1e-20 fixture dimensions even
// when Box.Right == Box.Left in double arithmetic. No Clipper/checker reuse.
var left = (decimal)pose.X + (decimal)originX;
var bottom = (decimal)pose.Y + (decimal)originY;
var c = (decimal)clearance;
return left < (decimal)bounds.Left + (decimal)bounds.Length + c
&& left + 2 > (decimal)bounds.Left - c
&& bottom < (decimal)bounds.Bottom + (decimal)bounds.Width + c
&& bottom + 2 > (decimal)bounds.Bottom - c;
}
private static void AssertDiscsClearClamps(NestJobResult result, double radius)
{
foreach (var sheet in result.Plates)
foreach (var pose in sheet.Placements)
foreach (var zone in sheet.Stock.ClampZones)
{
var b = zone.Bounds;
var c = sheet.Stock.EdgeSpacing.Bottom;
var dx = pose.X - System.Math.Clamp(pose.X, b.Left - c, b.Right + c);
var dy = pose.Y - System.Math.Clamp(pose.Y, b.Bottom - c, b.Top + c);
Assert.True(dx * dx + dy * dy >= radius * radius,
$"Disc at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}");
}
}
private static void AssertValidFill(NestJob job, SheetFill fill)
{
var builder = new NestJobResultBuilder(job);
builder.AddSheet(fill.Stock, fill.Parts.Select(p =>
(job.Parts[p.Orientation.TypeIndex].Id, p.X, p.Y, p.Orientation.Rotation)));
LayoutAssert.Valid(job, builder.Build(NestJobStopReason.Completed));
}
}
@@ -0,0 +1,92 @@
using System.Linq;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularRepeatTests : RepeatEngineSafetyTests
{
private protected override INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null) =>
new IrregularNestingEngine { WorkBudget = 0, SheetPacked = packed, BatchSelected = batches };
[Fact]
public void CompletedFullLayoutRemovesActualFrontierFillCallsIncludingTailTrials()
{
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var calls = 0;
var result = new IrregularNestingEngine { SheetPacked = () => calls++ }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(10, result.Plates.Count);
Assert.Equal(24, calls); // Six main variants and six trials for each physical tail of 1..3 sheets.
var first = result.Plates[0];
foreach (var plate in result.Plates.Skip(1))
{
Assert.NotSame(first.Placements, plate.Placements);
Assert.Equal(first.Placements.Select(Pose), plate.Placements.Select(Pose));
Assert.NotSame(first.Placements[0], plate.Placements[0]);
}
}
[Fact]
public void SeededPhysicalTailReturnsFiniteStockAndReconsumesItWithoutDroppingRepeatedPrefix()
{
// A lifecycle test, not a claim that public Solve chooses this deliberately wasteful
// incumbent. Four identical full copies precede two half-full physical tail copies.
var job = Job([Rectangle("a", 9, 9, 20)], [Stock("s", 20, 20, spacing: 0.2, quantity: 6)]);
var orientation = Assert.Single(Assert.Single(PartCatalog.Build(job)).Orientations);
var full = new[] { new Placed(orientation, 0, 0), new Placed(orientation, 9.2, 0),
new Placed(orientation, 0, 9.2), new Placed(orientation, 9.2, 9.2) };
var seed = Enumerable.Range(0, 4).Select(_ => new SheetFill(job.Plates[0], full.ToArray(), 324))
.Concat(Enumerable.Range(0, 2).Select(_ => new SheetFill(job.Plates[0], full.Take(2).ToArray(), 162))).ToArray();
var incumbent = new NestJobResultBuilder(job);
foreach (var sheet in seed)
incumbent.AddSheet(sheet.Stock, sheet.Parts.Select(p => ("a", p.X, p.Y, p.Orientation.Rotation)));
var before = incumbent.Build(NestJobStopReason.Completed);
LayoutAssert.Valid(job, before);
var replaced = new List<int>();
var batches = new List<RepeatBatch>();
var result = new IrregularNestingEngine { TailReplaced = replaced.Add, BatchSelected = batches.Add }
.ImproveTailForTesting(job, seed);
LayoutAssert.Valid(job, result);
Assert.Equal(new[] { 2 }, replaced);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(5, result.Plates.Count);
Assert.Equal(20, Assert.Single(result.Fulfillment).Placed);
Assert.Equal((5, 1), (Assert.Single(result.StockUsage).Used, Assert.Single(result.StockUsage).Remaining));
Assert.Equal(2000, NestJobCost.Evaluate(job, result), precision: 8);
Assert.True(NestJobCost.Evaluate(job, result) < NestJobCost.Evaluate(job, before));
for (var i = 0; i < 4; i++)
Assert.Equal(before.Plates[i].Placements.Select(Pose), result.Plates[i].Placements.Select(Pose));
Assert.All(result.Plates, p => Assert.Equal(4, p.Placements.Count));
Assert.Equal(Enumerable.Range(0, 20), result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex));
Assert.All(seed.Take(4), s => Assert.Equal(full, s.Parts)); // The incumbent was not mutated.
Assert.Contains(batches, b => b.ForcedFirst && b.Copies > 1); // The retained physical tail can still repeat.
}
[Fact]
public void ForcedFirstStockCountsAllAvailableEntriesAndResumesAlternatives()
{
var job = Job([Rectangle("a", 9, 9, 16)],
[Stock("one", 20, 20, spacing: 0.2, quantity: 10), Stock("two", 20, 20, spacing: 0.2, quantity: 10)]);
var batches = new List<RepeatBatch>();
var result = new IrregularNestingEngine { BatchSelected = batches.Add }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
var forced = batches.Where(b => b.ForcedFirst).ToArray();
Assert.NotEmpty(forced);
Assert.All(forced, b =>
{
Assert.Equal(2, b.AvailableStockEntries);
Assert.Equal(1, b.Copies);
});
// Within a multi-sheet Decode forced to start on "two", the next decision can
// choose "one". A trial-subset count of one would incorrectly batch that first sheet.
Assert.Contains(Enumerable.Range(0, batches.Count - 1), i =>
batches[i] is { ForcedFirst: true, StockId: "two" }
&& batches[i + 1] is { ForcedFirst: false, StockId: "one", SheetsBefore: 1 });
}
private static (string, double, double, double) Pose(NestJobPlacement p) => (p.PartId, p.X, p.Y, p.Rotation);
}
@@ -16,8 +16,9 @@ public static class JobBuilder
/// <param name="width">Y extent.</param> /// <param name="width">Y extent.</param>
/// <param name="length">X extent.</param> /// <param name="length">X extent.</param>
public static NestPlateStock Stock(string id, double width, double length, double spacing = 0, public static NestPlateStock Stock(string id, double width, double length, double spacing = 0,
Spacing edge = default, int quadrant = 1, int? quantity = null) => Spacing edge = default, int quadrant = 1, int? quantity = null,
new(id, new Size(width, length), quantity, spacing, edge, quadrant); ClampZone[]? clampZones = null) =>
new(id, new Size(width, length), quantity, spacing, edge, quadrant, null, clampZones);
public static NestJobPart Rectangle(string id, double w, double h, int count, public static NestJobPart Rectangle(string id, double w, double h, int count,
RotationPolicy? rotation = null, double x = 0, double y = 0) => RotationPolicy? rotation = null, double x = 0, double y = 0) =>
@@ -0,0 +1,305 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>
/// The Rectangles lane treats clamp zones as hard obstacles: placements never enter
/// the keep-out, demand uses the material between clamps, and layouts with zero
/// clamps stay bit-identical to the pre-clamp baseline (C2). Irregular and Default
/// parity is covered separately by IrregularClampZoneTests.
/// </summary>
public class RectanglesClampZoneTests
{
private static OpenNest.Engine.NestingEngines.Rectangles.RectanglesNestingEngine Engine() => new();
// ---- C2 characterization: zero-clamp layouts must not shift by a single bit -------
private static string LayoutHash(NestJobResult result)
{
var sb = new StringBuilder();
foreach (var sheet in result.Plates)
foreach (var p in sheet.Placements)
sb.Append(sheet.PlateIndex).Append('|').Append(sheet.StockId).Append('|')
.Append(p.PartId).Append('|')
.Append(p.X.ToString("R", CultureInfo.InvariantCulture)).Append('|')
.Append(p.Y.ToString("R", CultureInfo.InvariantCulture)).Append('|')
.Append(p.Rotation.ToString("R", CultureInfo.InvariantCulture))
.Append('\n');
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
}
[Theory]
// Pre-clamp baseline hashes, captured from the unchanged packer at commit a3f1124.
[InlineData("1FA61E933D2D0D6F1E3336FCEBBB21CEB7E7D1533FDB253F9D48E947217575CE")]
public void ZeroClampLayoutIsBitIdenticalToBaseline(string hash) =>
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
new[] { Part("rect", Rectangle(10, 5), 12) },
new[] { Stock("sheet", 48, 96, spacing: 0.25) }))));
[Theory]
[InlineData("9B343B6C5CF2CE8753F2E04ACB0A9C4D5921F4ADC15AA2586AF466769C0F019B")]
public void ZeroClampMixedShapeLayoutIsBitIdenticalToBaseline(string hash) =>
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
new[]
{
Part("disc", Disc(3), 10),
Part("ell", LShape(12, 8, 4), 10),
Part("tri", Triangle(9, 6), 10),
},
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5)) }))));
[Theory]
[InlineData("DBF93C1684D85B5EE7EB24C96A84904D2BA0F9A8183631EFC61638CB8B9B935A")]
public void ZeroClampMultiSheetLayoutIsBitIdenticalToBaseline(string hash) =>
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
new[] { Part("square", Rectangle(10, 10), 30) },
new[] { Stock("sheet", 25, 45, spacing: 0.25) }))));
// ---- clamp avoidance ---------------------------------------------------------------
[Fact]
public void PartsNeverTouchAClampZone()
{
var clamp = new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625));
var job = Job(
new[] { Rectangle("blk", 6, 6, 6) },
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [clamp]) });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
{
// Fixed 0-degree rectangles: analytic independent oracle against the raw zone.
Assert.False(
placement.X < 20.75 && placement.X + 6 > 19.25 &&
placement.Y < 0.625 && placement.Y + 6 > 0,
$"placement at ({placement.X},{placement.Y}) intrudes into the clamp zone");
}
}
[Fact]
public void DemandLandsBetweenClampsWhenItFits()
{
// One row of three 2x2 parts; only [0,3], [5,7] and [9,12] are open along X.
var clamps = new[]
{
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
};
var job = Job(
new[] { Rectangle("row", 2, 2, 3) },
new[] { Stock("strip", 2, 12, clampZones: clamps) });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
Assert.Equal(3, placements.Count);
Assert.Contains(placements, p => System.Math.Abs(p.X - 5) < 1e-9); // the middle slot
}
[Fact]
public void ClampWallAcrossTheFrontEdgeBlocksTheOnlyViableRow()
{
// Without clamps the single viable row sits at y = bottom spacing (1.0). The full-width
// front clamp with its edge clearance raises that floor above the work area.
var wall = new ClampZone("rail-wall", new Box(0, 0, 10, 1));
var job = Job(
new[] { Rectangle("tall", 4, 4, 2) },
new[]
{
Stock("sheet", 6, 10, spacing: 0,
edge: new Spacing(left: 0.25, bottom: 1, right: 0.25, top: 0.25),
clampZones: [wall]),
});
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Empty(result.Plates.SelectMany(p => p.Placements));
}
[Fact]
public void ZonesClearOfTheWorkAreaNeedNotShiftAnything()
{
// Top-edge zone the demand never reaches: layout stays identical to no clamps.
var far = new ClampZone("back", new Box(0, 47.5, 1.5, 0.5));
var jobClamped = Job(
new[] { Part("rect", Rectangle(10, 5), 12) },
new[] { Stock("sheet", 48, 96, spacing: 0.25, clampZones: [far]) });
var jobPlain = Job(
new[] { Part("rect", Rectangle(10, 5), 12) },
new[] { Stock("sheet", 48, 96, spacing: 0.25) });
var clamped = Engine().Solve(jobClamped);
var plain = Engine().Solve(jobPlain);
LayoutAssert.Valid(jobClamped, clamped);
Assert.Equal(LayoutHash(plain), LayoutHash(clamped));
}
// ---- large part spacing vs shallow clamp spans (PM d86d22c9, decision D11) --------
//
// The packer reserves a trailing part-spacing tail behind every item, so the block it
// carves per axis is [zone - clearance + s, zone + clearance]. When s exceeds the
// inflated span, that interval inverts. D11: block a zero-extent line at the clamp
// edge facing the sheet interior — at (zone max + clearance) when the zone center lies
// in the lower half of the bin on that axis, else at (zone min - clearance + s). The
// old collapse to the zone centerline let a part start inside the clamp.
//
// The oracle below is independent of the packer and of NestLayoutCheck: material
// [x, x+w] x [y, y+h] must not share interior with the clamp footprint predicted
// directly from the rail rule (center +/- width/2 from the origin edge, depth into the
// sheet, translated exactly like the work area), inflated by the bottom-edge spacing.
// Boundary contact is clear (C16).
private static Box InflatedRail(int quadrant, double length, double width,
double center, double clampWidth, double depth, double clearance)
{
var left = quadrant is 1 or 4 ? 0.0 : -length;
var bottom = quadrant is 1 or 2 ? 0.0 : -width;
var near = center - clampWidth / 2 + left;
var far = center + clampWidth / 2 + left;
return new Box(
System.Math.Min(near, far) - clearance,
bottom - clearance,
clampWidth + 2 * clearance,
depth + 2 * clearance);
}
private static void AssertMaterialClearOfRail(NestJobResult result, Box inflated,
double partW, double partH)
{
Assert.NotEmpty(result.Plates.SelectMany(p => p.Placements));
foreach (var p in result.Plates.SelectMany(pl => pl.Placements))
{
var overlaps = p.X < inflated.Right - 1e-9 && p.X + partW > inflated.Left + 1e-9
&& p.Y < inflated.Top - 1e-9 && p.Y + partH > inflated.Bottom + 1e-9;
Assert.False(overlaps,
$"material [{p.X},{p.X + partW}] x [{p.Y},{p.Y + partH}] intrudes into the " +
$"inflated clamp footprint [{inflated.Left},{inflated.Right}] x " +
$"[{inflated.Bottom},{inflated.Top}]");
}
}
[Theory]
// PM d86d22c9 probe fixture: Y degenerate (s=4 > 0.625 + 2x0.25), full-width front
// rail. Red on ab50900: Rectangles completed at y=0.3125, material on the clamp.
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void DegenerateYSpanKeepsExactClearanceInEveryQuadrant(int quadrant)
{
var stock = Stock("sheet", 4, 5, spacing: 4, edge: new Spacing(0, 0.25, 0, 0),
quadrant: quadrant, clampZones: [new ClampZone("rail", new Box(0, 0, 5, 0.625))]);
var job = Job(new[] { Rectangle("square", 2, 2, 1) }, new[] { stock });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
AssertMaterialClearOfRail(result,
InflatedRail(quadrant, 5, 4, 2.5, 5, 0.625, 0.25), 2, 2);
}
[Fact]
public void DegenerateXSpanMidRailKeepsExactClearance()
{
// X degenerate (s=3 > 1.5 + 2x0.25); the 10-wide part is forced across the clamp's
// X span because neither side can hold it with the tail. Red on ab50900: completed
// at x=11.5 (the centerline), material inside the clamp.
var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]);
var job = Job(new[] { Rectangle("wide", 10, 2, 1) }, new[] { stock });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 11.5, 1.5, 0.625, 0.25), 10, 2);
}
[Fact]
public void DegenerateXSpanFarRailClearsTheInteriorFacingEdge()
{
// The clamp center sits in the upper half of the bin, so D11 blocks the
// origin-facing edge (zone min - clearance + s = 20). Red on ab50900: the
// centerline split let the 2x2 land at x=21, material inside the clamp;
// after D11 it lands at x=20 — boundary contact, which is clear.
var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new ClampZone("far-rail", new Box(20.25, 0, 1.5, 0.625))]);
var job = Job(new[] { Rectangle("small", 2, 2, 1) }, new[] { stock });
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 21, 1.5, 0.625, 0.25), 2, 2);
}
// ---- C15 keep rows: non-inverted spans and between-clamp packing stay bit-exact ----
private static NestJobResult KeepFixtureLargeSpacingNonInverted() => Engine().Solve(Job(
new[] { Rectangle("blk", 6, 6, 6) },
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625))]) }));
private static NestJobResult KeepFixtureBetweenClamps() => Engine().Solve(Job(
new[] { Rectangle("row", 2, 2, 3) },
new[] { Stock("strip", 2, 12, clampZones: new[]
{
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
}) }));
private static NestJobResult KeepFixtureBetweenClampsWithSpacing() => Engine().Solve(Job(
new[] { Rectangle("row", 2, 2, 3) },
new[] { Stock("strip", 2, 12, spacing: 0.5, clampZones: new[]
{
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
}) }));
private static NestJobResult KeepFixtureMidRailSmallSpacing() => Engine().Solve(Job(
new[] { Rectangle("wide", 10, 2, 2) },
new[] { Stock("sheet", 4, 23, spacing: 0.5, edge: new Spacing(0, 0.25, 0, 0),
clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]) }));
[Theory]
// Captured from the unchanged packer at ab50900 (see progress task-6.2 evidence):
// non-inverted clamp spans must keep today's blocks and layouts bit-for-bit.
[InlineData("KEEP_LARGE_SPACING")]
[InlineData("KEEP_BETWEEN_CLAMPS")]
[InlineData("KEEP_BETWEEN_SPACED")]
[InlineData("KEEP_MID_RAIL_SMALL_SPACING")]
public void NonInvertedClampLayoutsMatchAb50900Digests(string fixture)
{
var hash = fixture switch
{
"KEEP_LARGE_SPACING" => "F7E4691C085FFB2164CEFFFB9B082B5F271E56E8B8B3D2AE23ECF5646FD40450",
"KEEP_BETWEEN_CLAMPS" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61",
"KEEP_BETWEEN_SPACED" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61",
"KEEP_MID_RAIL_SMALL_SPACING" => "97868486E834CA0B1DB110FF717635E38537DB96DF5CDFBB2AFE37E4B1366B71",
_ => throw new ArgumentOutOfRangeException(nameof(fixture)),
};
var actual = fixture switch
{
"KEEP_LARGE_SPACING" => LayoutHash(KeepFixtureLargeSpacingNonInverted()),
"KEEP_BETWEEN_CLAMPS" => LayoutHash(KeepFixtureBetweenClamps()),
"KEEP_BETWEEN_SPACED" => LayoutHash(KeepFixtureBetweenClampsWithSpacing()),
"KEEP_MID_RAIL_SMALL_SPACING" => LayoutHash(KeepFixtureMidRailSmallSpacing()),
_ => throw new ArgumentOutOfRangeException(nameof(fixture)),
};
Assert.Equal(hash, actual);
}
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.RectanglePacking;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
public class RectanglesRepeatTests : RepeatEngineSafetyTests
{
private protected override INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null) =>
new RectanglesNestingEngine { SheetPacked = _ => packed?.Invoke(), BatchSelected = batches };
[Fact]
public void CompletedFullLayoutRemovesActualPackCallsInBothModes()
{
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var calls = new List<PickMode>();
var result = new RectanglesNestingEngine { SheetPacked = calls.Add }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(10, result.Plates.Count);
Assert.Equal(6, calls.Count(m => m == PickMode.Global));
Assert.Equal(6, calls.Count(m => m == PickMode.Ordered));
var first = result.Plates[0];
foreach (var plate in result.Plates.Skip(1))
{
Assert.NotSame(first.Placements, plate.Placements);
Assert.Equal(first.Placements.Select(Pose), plate.Placements.Select(Pose));
Assert.NotSame(first.Placements[0], plate.Placements[0]);
}
}
[Fact]
public void CandidateOutsideTheStricterCertificationBoundsRetainsOrdinaryAllocation()
{
// MaxRects/layout-check slack accepts this overhang; the candidate validator does not.
// This real Solve must pack five times rather than certifying a repeat from that layout.
var job = Job([Rectangle("a", 10.000006, 9, 5)], [Stock("s", 10, 10)]);
var batches = new List<RepeatBatch>();
var calls = 0;
var result = new RectanglesNestingEngine { SheetPacked = _ => calls++, BatchSelected = batches.Add }.Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(5, result.Plates.Count);
Assert.Equal(60, calls);
Assert.Equal(5, batches.Count);
Assert.All(batches, b => Assert.Equal(1, b.Copies));
var candidate = new PlateCandidate(result.Plates[0].Placements);
Assert.Throws<InvalidOperationException>(() => NestJobValidator.ValidateCandidate(candidate, job.Plates[0],
new Dictionary<string, int> { ["a"] = 5 }, job.Parts.ToDictionary(p => p.Id)));
}
private static (string, double, double, double) Pose(NestJobPlacement p) => (p.PartId, p.X, p.Y, p.Rotation);
}
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
namespace OpenNest.Engine.Tests.NestingEngines;
/// <summary>Physical accounting/continuation contract, run independently for each repeating engine.</summary>
public abstract class RepeatEngineSafetyTests
{
private protected abstract INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null);
[Theory]
[InlineData(5)]
[InlineData(1)]
public void MixedScarcityContinuesToDifferentResidualLayoutInOneSolve(int urgentCount)
{
var job = Mixed(urgentCount, 8);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(urgentCount, batches[0].Copies);
Assert.True(batches.Count > 1);
Assert.Equal(urgentCount, result.Fulfillment.Single(f => f.PartId == "a").Placed);
Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "b").Placed);
Assert.All(result.Plates.Take(urgentCount), p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId)));
Assert.All(result.Plates.Skip(urgentCount), p => Assert.All(p.Placements, v => Assert.Equal("b", v.PartId)));
}
[Fact]
public void SmallDemandThatFitsTheRemnantIsPlacedBeforeRepeating()
{
var job = Mixed(10, 10);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(10, Assert.Single(batches).Copies);
Assert.All(result.Plates, p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId)));
}
[Theory]
[InlineData(2, null, NestJobStopReason.StockExhausted)]
[InlineData(null, 2, NestJobStopReason.PlateLimitReached)]
[InlineData(3, 2, NestJobStopReason.PlateLimitReached)]
public void FiniteStockAndMaxPlatesCountPhysicalCopies(int? stockCount, int? cap, NestJobStopReason reason)
{
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2, quantity: stockCount)], new NestJobOptions(maxPlates: cap));
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(reason, result.StopReason);
Assert.Equal(2, result.Plates.Count);
Assert.Equal(8, Assert.Single(result.Fulfillment).Placed);
Assert.Equal(2, Assert.Single(batches).Copies);
Assert.Equal(stockCount - 2, Assert.Single(result.StockUsage).Remaining);
}
[Fact]
public void CompetingSameSizeStockEntriesCommitSinglyUntilOneRemains()
{
var job = Job([Rectangle("a", 9, 9, 16)], [Stock("one", 20, 20, spacing: 0.2, quantity: 2), Stock("two", 20, 20, spacing: 0.2, quantity: 2)]);
var batches = new List<RepeatBatch>();
var calls = 0;
var result = Engine(() => calls++, batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new[] { 1, 1, 2 }, batches.Select(b => b.Copies));
Assert.Equal(new[] { 2, 2, 1 }, batches.Select(b => b.AvailableStockEntries));
Assert.All(result.StockUsage, s => Assert.Equal(2, s.Used));
Assert.True(calls >= 5); // Two competing trials twice, then the genuinely sole remaining stock.
}
[Fact]
public void SameNamedDrawingsRetainExactDistinctRequirementIds()
{
var a = new Drawing("same", Shapes.Rectangle(6, 9));
var b = new Drawing("same", Shapes.Rectangle(3, 9)) { Priority = 1 };
var job = Job([DrawingJobMapper.FromDrawing("a", a, 5), DrawingJobMapper.FromDrawing("A", b, 8)], [Stock("s", 10, 10, spacing: 0.2)]);
var result = Engine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "a").Placed);
Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "A").Placed);
Assert.Equal(0, a.Quantity.Nested);
Assert.Equal(0, b.Quantity.Nested);
}
[Fact]
public void SalvageOffcutDoesNotPreventRepeatingCompletedJobSearch()
{
var stock = new NestPlateStock("s", new OpenNest.Geometry.Size(10, 10), null, 0.2, default, 1, cost: 20);
var job = Job([Rectangle("a", 6, 9, 10)], [stock], new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1));
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(10, Assert.Single(batches).Copies);
Assert.Equal(10, result.Plates.Count);
Assert.Equal(162, NestJobCost.Evaluate(job, result), precision: 8);
Assert.True(NestJobCost.NetSheetCost(job, result.Plates[0]) < stock.Cost);
}
[Fact]
public void OversizedRequirementRemainsUnplacedWhileUsefulDemandRepeats()
{
var job = Job([Rectangle("huge", 100, 100, 4), Rectangle("small", 6, 9, 5)], [Stock("s", 10, 10, spacing: 0.2)]);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(4, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "small").Placed);
Assert.Contains(batches, b => b.Copies == 5);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void NoFitOrUnavailableStockProducesNoEmptyRepeat(bool unavailable)
{
var job = Job([Rectangle("a", 100, 100, 2)], [Stock("s", 10, 10, quantity: unavailable ? 0 : null)]);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
LayoutAssert.Valid(job, result);
Assert.Empty(result.Plates);
Assert.Empty(batches);
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
}
[Fact]
public void CancellationAtRepeatDecisionCannotReturnAProposal()
{
using var cancellation = new CancellationTokenSource();
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var observed = 0;
var engine = Engine(batches: b => { Assert.Equal(10, b.Copies); observed++; cancellation.Cancel(); });
Assert.Throws<OperationCanceledException>(() => engine.Solve(job, token: cancellation.Token));
Assert.Equal(1, observed);
}
[Theory]
[InlineData(1)]
[InlineData(10)]
public void CancellationDuringPhysicalExpansionIncludingLastCopyIsObserved(int cancelAfter)
{
using var cancellation = new CancellationTokenSource();
var committed = new List<NestJobProgress>();
var progress = new InlineProgress(p =>
{
if (p.Stage != NestJobStage.PlateCommitted) return;
committed.Add(p);
if (p.CommittedPlates == cancelAfter) cancellation.Cancel();
});
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
Assert.Throws<OperationCanceledException>(() => Engine().Solve(job, progress, cancellation.Token));
Assert.Equal(cancelAfter, committed.Count);
Assert.Equal(cancelAfter * 4, committed.Last().CommittedParts);
}
[Fact]
public void SuccessfulProgressRemainsPhysicalAndOrdered()
{
var committed = new List<NestJobProgress>();
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var result = Engine().Solve(job, new InlineProgress(p => { if (p.Stage == NestJobStage.PlateCommitted) committed.Add(p); }));
LayoutAssert.Valid(job, result);
Assert.Equal(Enumerable.Range(1, 10), committed.Select(p => p.CommittedPlates));
Assert.Equal(Enumerable.Range(1, 10).Select(i => i * 4), committed.Select(p => p.CommittedParts));
}
[Fact]
public void UnreadableGeometryIsNotTurnedIntoAnEmptyRepeat()
{
var job = Job([Part("unsupported", new OpenNest.CNC.Program(), 12)], [Stock("s", 10, 10)]);
var batches = new List<RepeatBatch>();
var result = Engine(batches: batches.Add).Solve(job);
Assert.Equal(NestJobStatus.Incomplete, result.Status);
Assert.Equal(12, Assert.Single(result.Fulfillment).Unplaced);
Assert.Empty(result.Plates);
Assert.Empty(batches);
Assert.Equal(0, Assert.Single(result.StockUsage).Used);
}
[Fact]
public void CancellationInsideTheRealSheetSearchNeverCertifiesARepeat()
{
using var cancellation = new CancellationTokenSource();
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
var batches = new List<RepeatBatch>();
var packed = 0;
var progress = new InlineProgress(p =>
{
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
});
Assert.ThrowsAny<OperationCanceledException>(() => Engine(() => packed++, batches.Add).Solve(job, progress, cancellation.Token));
Assert.Equal(0, packed);
Assert.Empty(batches);
}
private static NestJob Mixed(int a, int b) => Job(
[Part("a", Shapes.Rectangle(6, 9), a, RotationPolicy.Fixed(0)), Part("b", Shapes.Rectangle(3, 9), b, RotationPolicy.Fixed(0), priority: 1)],
[Stock("s", 10, 10, spacing: 0.2)]);
private sealed class InlineProgress(Action<NestJobProgress> report) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value) => report(value);
}
}
@@ -22,13 +22,13 @@ internal static class BestEffortCuttingPlan
return new(CuttingPlanStatus.InvalidInput); return new(CuttingPlanStatus.InvalidInput);
var findings = snapshot.Findings.ToList(); var findings = snapshot.Findings.ToList();
findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete, findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete,
"Best-effort plan: lead clearance, rapid travel and material containment are not certified. " "Best-effort plan: review lead clearance, rapid travel, material containment and source "
+ "Review the complete plan before cutting. Source contours have not been repaired.")); + "contours as part of a complete review before cutting."));
var order = new List<FixedProgramPlacement>(); var order = new List<FixedProgramPlacement>();
var done = new HashSet<int>(); var done = new HashSet<int>();
var position = snapshot.StartPoint; var position = snapshot.StartPoint;
var distance = 0.0; var distance = 0.0;
var checker = new ReleasedContourState(); var checker = new ReleasedContourState(reportMissingLeadIns: false);
try try
{ {
while (order.Count < snapshot.Placements.Count) while (order.Count < snapshot.Placements.Count)
@@ -31,8 +31,8 @@ public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status,
/// candidate contour through the S06 diagnostic seam, reads it at the placement position, /// candidate contour through the S06 diagnostic seam, reads it at the placement position,
/// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a /// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a
/// new collision implementation and not a plan approval: a Clear verdict certifies this /// new collision implementation and not a plan approval: a Clear verdict certifies this
/// contour's emitted leads only — the missing-lead (NoLeadIn) check still runs later on the /// contour's emitted leads only — NoLeadIn is supported, and the complete plan must
/// complete plan, and rapids/pierce clearance belong to their existing checkers. A /// still pass rapid travel and contour-accounting checks. A
/// candidate rejected here is not proven infeasible by anything else: this adapter only /// candidate rejected here is not proven infeasible by anything else: this adapter only
/// reports what the validator reported. /// reports what the validator reported.
/// One instance is one captured planning attempt: verdicts cache per exact choice and node /// One instance is one captured planning attempt: verdicts cache per exact choice and node
@@ -165,7 +165,7 @@ internal static class ContourEntrySelection
if (sawIncomplete) if (sawIncomplete)
{ {
shortfall = ContourSelectionShortfall.Incomplete; shortfall = ContourSelectionShortfall.Incomplete;
reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible." reason = "At least one lead check could not complete; treat the result as undetermined rather than a geometric verdict."
+ (incompleteReason == null ? "" : $" First reason: {incompleteReason}"); + (incompleteReason == null ? "" : $" First reason: {incompleteReason}");
} }
else if (selected.Count >= maxEntries) else if (selected.Count >= maxEntries)
@@ -83,6 +83,16 @@ internal sealed class CuttingDependencyGraph
{ {
if (!nodes[cut].IsCutOff) if (!nodes[cut].IsCutOff)
continue; continue;
if (nodes[cut].SeparatesSheet)
{
// Cutting a line that splits the sheet comes after every part and every other
// cutoff, so the head never travels back across the separated edge. Such a line
// is continuous, so it passes through no part and needs no crossing edge.
for (var other = 0; other < nodes.Count; other++)
if (!nodes[other].SeparatesSheet)
edges[cut].Add(other);
continue;
}
var definition = nodes[cut].CutOff; var definition = nodes[cut].CutOff;
for (var part = 0; part < nodes.Count; part++) for (var part = 0; part < nodes.Count; part++)
{ {
@@ -190,9 +200,10 @@ internal sealed class DependencyNode
private LeadMaterialSnapshot material; private LeadMaterialSnapshot material;
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds, internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
Func<LeadMaterialSnapshot> materialFactory) Func<LeadMaterialSnapshot> materialFactory, bool separatesSheet = false)
{ {
IsCutOff = isCutOff; IsCutOff = isCutOff;
SeparatesSheet = isCutOff && separatesSheet;
CutOff = cutOff; CutOff = cutOff;
PlacedBounds = Copy(placedBounds); PlacedBounds = Copy(placedBounds);
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds); CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
@@ -201,6 +212,8 @@ internal sealed class DependencyNode
} }
internal bool IsCutOff { get; } internal bool IsCutOff { get; }
/// <summary>A cutoff whose placed program is one continuous cut across the whole sheet.</summary>
internal bool SeparatesSheet { get; }
internal CutOffDefinition CutOff { get; } internal CutOffDefinition CutOff { get; }
internal Box PlacedBounds { get; } internal Box PlacedBounds { get; }
internal Box CleanBounds { get; } internal Box CleanBounds { get; }
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Proposes a whole-part order for plates with parts nested inside other parts' cutouts. A host
/// is cut after everything inside it, so the tool reaches it from whichever inner part was cut
/// last; from the middle of a crowded cutout every straight approach crosses a finished
/// neighbour. Each host's inner parts are therefore toured as one run that ends beside the
/// host's own contours, immediately before the host. Plates without nested parts get exactly
/// <see cref="CuttingPartOrder"/>'s order. Like that order, this is only a proposal.
/// </summary>
internal static class CuttingNestedOrder
{
/// <param name="Centres">One representative point per part ordinal.</param>
/// <param name="Prerequisites">Ordinals that must come before each part, learned rules included.</param>
/// <param name="Inside">Per part ordinal, the parts nested inside its cutouts at any depth.</param>
/// <param name="HostEntry">The point on a host's contours nearest a tool position.</param>
internal sealed record Context(IReadOnlyList<Vector> Centres,
IReadOnlyList<IReadOnlyCollection<int>> Prerequisites, IReadOnlyList<IReadOnlyCollection<int>> Inside,
Func<int, Vector, Vector> HostEntry, CancellationToken Token);
/// <param name="parts">The part ordinals to order; prerequisites outside this set count as done.</param>
/// <param name="start">The modeled tool position before the first of these parts.</param>
/// <param name="endpoint">Optional position the run should end beside.</param>
internal static int[] Plan(IReadOnlyList<int> parts, Vector start, Context context, Vector? endpoint = null)
{
context.Token.ThrowIfCancellationRequested();
var set = parts.ToHashSet();
var rootOf = new Dictionary<int, int>();
foreach (var host in parts)
foreach (var inner in context.Inside[host])
if (set.Contains(inner) && !context.Inside[inner].Contains(host))
rootOf[inner] = host;
if (rootOf.Count == 0)
return Flat();
// Follow nested hosts up to the outermost one present.
foreach (var inner in rootOf.Keys.ToArray())
while (rootOf.TryGetValue(rootOf[inner], out var outer))
rootOf[inner] = outer;
var roots = parts.Where(p => !rootOf.ContainsKey(p)).ToArray();
// A rule between parts of two groups orders the groups.
var groupRules = context.Centres.Select(_ => new HashSet<int>()).ToArray();
foreach (var part in parts)
{
var group = rootOf.GetValueOrDefault(part, part);
foreach (var prerequisite in context.Prerequisites[part])
if (set.Contains(prerequisite) && rootOf.GetValueOrDefault(prerequisite, prerequisite) != group)
groupRules[group].Add(rootOf.GetValueOrDefault(prerequisite, prerequisite));
}
// A finished host is a large obstacle and can only be left across its own outside
// edge, so the parts beside it go first. Rules that say otherwise win.
var hostsLast = groupRules.Select(rules => new HashSet<int>(rules)).ToArray();
var hosts = rootOf.Values.ToHashSet();
foreach (var host in hosts)
foreach (var root in roots)
if (!hosts.Contains(root))
hostsLast[host].Add(root);
int[] rootOrder;
try
{
rootOrder = CuttingPartOrder.Plan(roots, context.Centres, start, hostsLast, context.Token, endpoint);
}
catch (InvalidOperationException)
{
try
{
rootOrder = CuttingPartOrder.Plan(roots, context.Centres, start, groupRules, context.Token, endpoint);
}
catch (InvalidOperationException)
{
return Flat(); // Rules that cross groups both ways cannot keep the groups whole.
}
}
var order = new List<int>(parts.Count);
var position = start;
foreach (var root in rootOrder)
{
var inner = parts.Where(p => rootOf.TryGetValue(p, out var host) && host == root).ToArray();
if (inner.Length != 0)
order.AddRange(Plan(inner, position, context, context.HostEntry(root, position)));
order.Add(root);
position = context.Centres[root];
}
var placed = new HashSet<int>();
foreach (var part in order)
{
if (context.Prerequisites[part].Any(p => set.Contains(p) && !placed.Contains(p)))
return Flat();
placed.Add(part);
}
return order.ToArray();
int[] Flat() => CuttingPartOrder.Plan(parts, context.Centres, start, context.Prerequisites, context.Token, endpoint);
}
}
@@ -23,8 +23,9 @@ internal static class CuttingPartOrder
/// <param name="centres">One representative point per part ordinal.</param> /// <param name="centres">One representative point per part ordinal.</param>
/// <param name="start">The modeled tool position before the first of these parts.</param> /// <param name="start">The modeled tool position before the first of these parts.</param>
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param> /// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
/// <param name="endpoint">Optional fixed final position after the last of these parts.</param>
internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centres, Vector start, internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token) IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token, Vector? endpoint = null)
{ {
var local = new Dictionary<int, int>(); var local = new Dictionary<int, int>();
for (var i = 0; i < parts.Count; i++) for (var i = 0; i < parts.Count; i++)
@@ -33,7 +34,7 @@ internal static class CuttingPartOrder
var localPrerequisites = parts var localPrerequisites = parts
.Select(p => (IReadOnlyCollection<int>)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray()) .Select(p => (IReadOnlyCollection<int>)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray())
.ToArray(); .ToArray();
return Plan(localCentres, start, localPrerequisites, token).Select(i => parts[i]).ToArray(); return Plan(localCentres, start, localPrerequisites, token, endpoint).Select(i => parts[i]).ToArray();
} }
/// <param name="centres">One representative point per part ordinal.</param> /// <param name="centres">One representative point per part ordinal.</param>
@@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch
/// </summary> /// </summary>
public const int ExpansionsPerPart = 400; public const int ExpansionsPerPart = 400;
// Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget.
internal const int FirstPassBudget = 1000;
internal const int FirstPassEntries = 8;
private readonly Entry[] entries; private readonly Entry[] entries;
private readonly CuttingParameters ownedParameters; private readonly CuttingParameters ownedParameters;
@@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch
if (entry.Reorder != null) if (entry.Reorder != null)
{ {
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering)); progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
reorder = CuttingPlanService.Plan(entry.Reorder, token); reorder = PlanReorder(entry.Reorder, token);
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget) if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
{ {
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap); plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
@@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch
return new(plans, ownedParameters); return new(plans, ownedParameters);
} }
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
int firstPassBudget = FirstPassBudget)
{
// A failed first pass is not a proof of impossibility: retain all original
// full-cap alternatives and the complete original budget for the retry.
if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries
&& snapshot.ExpansionBudget > firstPassBudget)
{
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed)
|| first.Status == CuttingPlanStatus.Cancelled)
return first;
}
return CuttingPlanService.Plan(snapshot, token);
}
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token) private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
{ {
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null) if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
@@ -216,6 +236,10 @@ public sealed class CuttingPlanPlateResult
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed); Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
} }
/// <summary>A diagnostic tied to captured plate and source part positions, not display text.</summary>
public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber,
int? OtherPartNumber, string Message);
/// <summary> /// <summary>
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance. /// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
/// </summary> /// </summary>
@@ -243,6 +267,35 @@ public sealed class CuttingPlanProposal
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply; public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
/// <summary>All findings, including ones hidden by a blocked plate's abbreviated summary.</summary>
public IReadOnlyList<CuttingPlanDiagnosticRow> DiagnosticRows()
{
var rows = new List<CuttingPlanDiagnosticRow>();
for (var index = 0; index < Plates.Count; index++)
{
var plate = Plates[index];
if (plate.Overlap != null)
{
foreach (var pair in plate.Overlap.Pairs)
rows.Add(new(index, plate.PlateNumber, pair.PartAId + 1, pair.PartBId + 1,
$"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}."));
foreach (var issue in plate.Overlap.Issues)
rows.Add(new(index, plate.PlateNumber, issue.PartAId + 1,
issue.PartBId is int other ? other + 1 : null,
$"Overlap check incomplete for part {issue.PartAId + 1}"
+ (issue.PartBId is int second ? $" and part {second + 1}" : string.Empty)
+ $": {issue.Message}"));
}
foreach (var finding in plate.Result.Findings)
rows.Add(new(index, plate.PlateNumber,
finding.SourceOrdinal is int part ? part + 1 : null,
finding.OtherSourceOrdinal is int other ? other + 1 : null,
DescribeFinding(finding)));
}
return rows;
}
/// <summary> /// <summary>
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the /// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy /// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
@@ -283,6 +336,9 @@ public sealed class CuttingPlanProposal
PartSpacing = source.PartSpacing, PartSpacing = source.PartSpacing,
EdgeSpacing = source.EdgeSpacing, EdgeSpacing = source.EdgeSpacing,
}; };
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
foreach (var zone in source.ClampZones)
preview.ClampZones.Add(zone);
foreach (var proposal in planned.Result.ProposedOrder) foreach (var proposal in planned.Result.ProposedOrder)
{ {
var part = (Part)proposal.SourcePart.Clone(); var part = (Part)proposal.SourcePart.Clone();
@@ -296,7 +352,7 @@ public sealed class CuttingPlanProposal
public IReadOnlyList<string> Describe(string unit) public IReadOnlyList<string> Describe(string unit)
{ {
var lines = new List<string>(); var lines = new List<string>();
var ready = Plates.Count(p => p.IsReady); var blocked = Plates.Count(p => !p.CanApplyWithWarnings);
if (IsCancelled) if (IsCancelled)
lines.Add("Planning was cancelled. Nothing has changed."); lines.Add("Planning was cancelled. Nothing has changed.");
else if (CanApply) else if (CanApply)
@@ -305,9 +361,9 @@ public sealed class CuttingPlanProposal
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated."); + $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
else if (RequiresWarningAcceptance) else if (RequiresWarningAcceptance)
lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. " lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
+ "This is not approval to cut or post CNC output."); + "Posting CNC output still requires its own review and approval.");
else else
lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not " lines.Add($"Apply is unavailable: {blocked} of {Count(Plates.Count, "plate")} could not "
+ "be planned. No plate changes until every plate is ready."); + "be planned. No plate changes until every plate is ready.");
foreach (var plate in Plates) foreach (var plate in Plates)
@@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot
public int ExpansionBudget { get; } public int ExpansionBudget { get; }
internal CuttingPlanStatus? Failure { get; } internal CuttingPlanStatus? Failure { get; }
internal IReadOnlyList<CuttingPlanFinding> Findings { get; } internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
// A worker-only search variant over the same owned capture. Never recapture the live plate:
// both proposals retain the identical freshness record and confirmed settings.
internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) =>
new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration,
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
Dependencies, BestEffort);
} }
public sealed class FixedProgramPlacement public sealed class FixedProgramPlacement
@@ -140,12 +147,15 @@ public sealed class FixedProgramPlacement
} }
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution, return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices) OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
{ SelectedProgram = selected, IsCutOff = IsCutOff }; { SelectedProgram = selected, IsCutOff = IsCutOff, SeparatesSheet = SeparatesSheet };
} }
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary> /// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
public bool IsCutOff { get; internal init; } public bool IsCutOff { get; internal init; }
/// <summary>A cutoff that splits the whole sheet; it is cut after every other placement.</summary>
public bool SeparatesSheet { get; internal init; }
public Part SourcePart { get; } public Part SourcePart { get; }
public int SourceOrdinal { get; } public int SourceOrdinal { get; }
public Vector Location { get; } public Vector Location { get; }
@@ -78,12 +78,15 @@ public static class CuttingPlanService
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token); var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
if (!cut.HasCuttingContour) if (!cut.HasCuttingContour)
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions."); throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition) var defined = definitions.TryGetValue(source.BaseDrawing, out var definition);
var separates = defined && CutOff.SeparatesSheet(source, definition,
request.Plate.BoundingBox(includeParts: false));
nodes.Add(new(true, defined
? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null, ? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
source.BoundingBox, null, null)); source.BoundingBox, null, null, separates));
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked, placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
cut, OwnedProgramCopy.Copy(source.Program, token)) cut, OwnedProgramCopy.Copy(source.Program, token))
{ IsCutOff = true }); { IsCutOff = true, SeparatesSheet = separates });
continue; continue;
} }
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token); var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
@@ -101,6 +104,8 @@ public static class CuttingPlanService
// Exact placed/proposed payloads still use lossless OwnedProgramCopy. // Exact placed/proposed payloads still use lossless OwnedProgramCopy.
var ownedClean = (Program)source.BaseDrawing.Program.Clone(); var ownedClean = (Program)source.BaseDrawing.Program.Clone();
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation); ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
// A baked-in seam overcut encloses no area; left in, the ring touches itself.
SeamOvercut.Trim(ownedClean);
// Material extent only: remote rapids and scribe marks must not widen it, or a // Material extent only: remote rapids and scribe marks must not widen it, or a
// genuine insert could fail the containment prefilter. // genuine insert could fail the containment prefilter.
var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions
@@ -241,7 +246,7 @@ public static class CuttingPlanService
if (placement.Prepared != null) if (placement.Prepared != null)
continue; // An eligible old crossing is precisely what regeneration may repair. continue; // An eligible old crossing is precisely what regeneration may repair.
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked. // Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
var findings = new ReleasedContourState().Check(placement.Execution, null, var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
placement.SourceOrdinal + 1, placement.IsCutOff, token); placement.SourceOrdinal + 1, placement.IsCutOff, token);
fixedFindings.AddRange(Map(snapshot, findings)); fixedFindings.AddRange(Map(snapshot, findings));
} }
@@ -279,7 +284,7 @@ public static class CuttingPlanService
if (snapshot.Dependencies.FirstViolation(order) is { } violation) if (snapshot.Dependencies.FirstViolation(order) is { } violation)
return new(CuttingPlanStatus.InvalidInput, return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions); findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState(); var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint; var position = snapshot.StartPoint;
var distance = 0.0; var distance = 0.0;
var findings = new List<PostVerificationFinding>(); var findings = new List<PostVerificationFinding>();
@@ -313,7 +318,7 @@ public static class CuttingPlanService
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation) if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
return new(CuttingPlanStatus.InvalidInput, return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions); findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
var checker = new ReleasedContourState(); var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint; var position = snapshot.StartPoint;
var distance = 0.0; var distance = 0.0;
var findings = new List<CuttingPlanFinding>(); var findings = new List<CuttingPlanFinding>();
@@ -380,6 +385,10 @@ public static class CuttingPlanService
{ {
var part = snapshot.Placements[violation.Part]; var part = snapshot.Placements[violation.Part];
var prerequisite = snapshot.Placements[violation.Prerequisite]; var prerequisite = snapshot.Placements[violation.Prerequisite];
if (part.SeparatesSheet)
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
$"{subject} cuts the sheet-separating cutoff (part {part.SourceOrdinal + 1}) before part "
+ $"{prerequisite.SourceOrdinal + 1}; every part and other cutoff must be cut before the sheet is separated.");
var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout"; var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null, return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1})."); $"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
@@ -0,0 +1,92 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Owner-thread lifetime of a read-only cutting proposal. Capture before starting the worker;
/// complete on the owner thread. A close request never releases ownership while work is running.
/// </summary>
public sealed class CuttingProposalInspection
{
private CancellationTokenSource cancellation;
private Task<CuttingPlanProposal> worker;
public bool IsRunning => cancellation != null;
public bool CloseRequested { get; private set; }
public CuttingPlanProposal Proposal { get; private set; }
public Plate Preview { get; private set; }
public Task<CuttingPlanProposal> BeginPlanning(CuttingPlanBatch captured,
Action<CancellationToken> beforePlan = null, IProgress<CuttingPlanProgress> progress = null)
{
ArgumentNullException.ThrowIfNull(captured);
if (IsRunning || CloseRequested)
throw new InvalidOperationException("This inspection cannot start another worker.");
Proposal = null;
Preview = null;
cancellation = new CancellationTokenSource();
var token = cancellation.Token;
worker = Task.Run(() =>
{
beforePlan?.Invoke(token);
token.ThrowIfCancellationRequested();
return captured.Plan(progress, token);
});
return worker;
}
/// <summary>True only when the panel can release its input lock and be removed.</summary>
public bool RequestClose()
{
CloseRequested = true;
Preview = null;
Proposal = null;
cancellation?.Cancel();
return !IsRunning;
}
public void CompletePlanning(Task<CuttingPlanProposal> completed)
{
if (!ReferenceEquals(worker, completed) || !completed.IsCompleted)
throw new InvalidOperationException("Only the completed current worker can release ownership.");
cancellation.Dispose();
cancellation = null;
worker = null;
if (CloseRequested || !completed.IsCompletedSuccessfully)
return;
Proposal = completed.Result;
RefreshPreview();
}
/// <summary>Recheck exact freshness before displaying, including every subsequent paint.</summary>
private int previewIndex = -1;
public Plate RefreshPreview(int plateIndex = 0)
{
if (plateIndex != previewIndex)
{
Preview = null;
previewIndex = plateIndex;
}
if (Proposal == null || CloseRequested || plateIndex < 0 || plateIndex >= Proposal.Plates.Count)
return Preview = null;
// BuildPreview is the authority; never clone a rejected program for presentation.
if (Proposal.Plates[plateIndex].Result.Snapshot?.PlateState?.IsCurrent() != true)
return Preview = null;
return Preview ??= Proposal.BuildPreview(plateIndex);
}
/// <summary>Zero-based proposed position for a one-based source diagnostic number.</summary>
public static int? ProposedPosition(CuttingPlanProposal proposal, int plateIndex, int sourceNumber)
{
if (proposal == null || plateIndex < 0 || plateIndex >= proposal.Plates.Count || sourceNumber < 1)
return null;
var order = proposal.Plates[plateIndex].Result.ProposedOrder;
for (var i = 0; i < order.Count; ++i)
if (order[i].SourceOrdinal == sourceNumber - 1)
return i;
return null;
}
}
@@ -16,7 +16,7 @@ internal static class FixedProgramSearch
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token) internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{ {
var stack = new Stack<Frame>(); var stack = new Stack<Frame>();
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState())); stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false)));
var rejected = new HashSet<PostVerificationFinding>(); var rejected = new HashSet<PostVerificationFinding>();
var expansions = 0; var expansions = 0;
while (stack.Count != 0) while (stack.Count != 0)
@@ -24,6 +24,7 @@ internal static class JointCuttingPlanSearch
/// along its order before it gives up and learns from the part that blocked it. /// along its order before it gives up and learns from the part that blocked it.
/// </summary> /// </summary>
internal const int StallExpansionsPerEntry = 8; internal const int StallExpansionsPerEntry = 8;
private const int MaximumRecoveryHoles = 16;
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary> /// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order, internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
@@ -46,6 +47,9 @@ internal static class JointCuttingPlanSearch
var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true); var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true);
if (preferred.Order != null) if (preferred.Order != null)
return walk.Ready(preferred.Order); return walk.Ready(preferred.Order);
var recovered = walk.Follow(keptSequence, stall, null, contourRecovery: true);
if (recovered.Order != null)
return walk.Ready(recovered.Order);
} }
var kept = walk.Follow(keptSequence, null, null); var kept = walk.Follow(keptSequence, null, null);
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted(); return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
@@ -55,24 +59,36 @@ internal static class JointCuttingPlanSearch
var prerequisites = Enumerable.Range(0, count) var prerequisites = Enumerable.Range(0, count)
.Select(i => new HashSet<int>(snapshot.Dependencies.PrerequisitesOf(i))).ToArray(); .Select(i => new HashSet<int>(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres, var nesting = NestedOrder(snapshot, centres, prerequisites, token);
snapshot.StartPoint, prerequisites, token); var sequence = CuttingNestedOrder.Plan(Enumerable.Range(0, count).ToArray(), snapshot.StartPoint, nesting);
Node resume = null; Node resume = null;
while (true) while (true)
{ {
if (preferEndpoints) // A pass that teaches an order rule ends the round: the heavier passes would
// only repeat the same blocked approach along the same order.
Attempt attempt = null;
foreach (var (preferredOnly, contourRecovery) in preferEndpoints
? new[] { (true, false), (false, true), (false, false) } : [(false, false)])
{ {
var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true); var pass = walk.Follow(sequence, stall, resume, preferredOnly, contourRecovery);
if (preferred.Order != null) if (pass.Order != null)
return walk.Ready(preferred.Order); return walk.Ready(pass.Order);
if (Learn(pass, prerequisites))
{
attempt = pass;
break;
}
} }
var attempt = walk.Follow(sequence, stall, resume); if (attempt == null)
if (attempt.Order != null)
return walk.Ready(attempt.Order);
if (!Learn(attempt, prerequisites))
{ {
// "Cut before" rules are a heuristic: a part blocked straight after another can // "Cut before" rules are a heuristic: a part blocked straight after another can
// still be reachable via a third. Search every order with what budget remains. // still be reachable via a third. Search every order with what budget remains.
if (preferEndpoints)
{
var recovery = walk.Follow(null, stall, null, contourRecovery: true);
if (recovery.Order != null)
return walk.Ready(recovery.Order);
}
var full = walk.Follow(null, null, null); var full = walk.Follow(null, null, null);
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted(); return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
} }
@@ -80,8 +96,7 @@ internal static class JointCuttingPlanSearch
// parts cut before it, and re-plan the rest from where the tool is at that point. // parts cut before it, and re-plan the rest from where the tool is at that point.
var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part)); var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part));
resume = attempt.BoundaryAt(back); resume = attempt.BoundaryAt(back);
sequence = [.. sequence.Take(back), .. CuttingPartOrder.Plan(sequence[back..], centres, sequence = [.. sequence.Take(back), .. CuttingNestedOrder.Plan(sequence[back..], resume.Position, nesting)];
resume.Position, prerequisites, token)];
} }
} }
catch (BudgetExceededException) catch (BudgetExceededException)
@@ -94,6 +109,24 @@ internal static class JointCuttingPlanSearch
} }
} }
// Captured containment (never learned rules) and each host's placed cut contours.
private static CuttingNestedOrder.Context NestedOrder(CuttingPlanSnapshot snapshot, Vector[] centres,
HashSet<int>[] prerequisites, CancellationToken token)
{
var inside = snapshot.Placements.Select((placement, host) => placement.IsCutOff ? []
: snapshot.Dependencies.PrerequisitesOf(host).Where(p => !snapshot.Placements[p].IsCutOff).ToArray()).ToArray();
var contours = new Dictionary<int, Entity[]>();
return new(centres, prerequisites, inside, (host, position) =>
{
if (!contours.TryGetValue(host, out var entities))
contours[host] = entities = snapshot.Placements[host].Execution.Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity()).ToArray();
return entities.Length == 0 ? centres[host]
: entities.Select(e => e.ClosestPointTo(position)).MinBy(point => point.DistanceTo(position));
}, token);
}
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that // "Cut the blocked part before every part whose cut contour its approach crossed", unless that
// would contradict an order already required. False when nothing new was learned. // would contradict an order already required. False when nothing new was learned.
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites) private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
@@ -162,6 +195,7 @@ internal static class JointCuttingPlanSearch
// memoize per owned choice for the attempt, never statically or across attempts. // memoize per owned choice for the attempt, never statically or across attempts.
private readonly Dictionary<int, ContourEntryFeasibility> feasibility = []; private readonly Dictionary<int, ContourEntryFeasibility> feasibility = [];
private readonly HashSet<int> reportedNoFit = []; private readonly HashSet<int> reportedNoFit = [];
private readonly Dictionary<int, Entity[]> hostContours = [];
internal int Expansions { get; private set; } internal int Expansions { get; private set; }
@@ -189,36 +223,56 @@ internal static class JointCuttingPlanSearch
/// With a stall limit the attempt ends once that many expansions pass without getting /// With a stall limit the attempt ends once that many expansions pass without getting
/// further along the order; it never backtracks behind its starting node. /// further along the order; it never backtracks behind its starting node.
/// </summary> /// </summary>
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false) internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false)
{ {
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null); var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
var attempt = new Attempt(sequence); var attempt = new Attempt(sequence);
var progressExpansions = Expansions; var progressExpansions = Expansions;
var stack = new Stack<Frame>(); var stack = new Stack<Frame>();
stack.Push(new(root)); stack.Push(new(root));
while (stack.Count != 0) try
{ {
token.ThrowIfCancellationRequested(); while (stack.Count != 0)
var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
{ {
attempt.Order = node.Order; token.ThrowIfCancellationRequested();
return attempt; var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
{
attempt.Order = node.Order;
return attempt;
}
if (attempt.Advance(node))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
{
if (sequence != null || !contourRecovery)
return attempt;
// Diversify the first whole part rather than spending the remainder
// on endpoints of a stalled root's already completed prefix.
while (stack.Count > 1)
stack.Pop().Children?.Dispose();
attempt.Restart();
progressExpansions = Expansions;
continue;
}
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly, contourRecovery).GetEnumerator();
if (!frame.Children.MoveNext())
{
stack.Pop().Children.Dispose();
continue;
}
stack.Push(new(frame.Children.Current.Node));
} }
if (attempt.Advance(node)) return attempt;
progressExpansions = Expansions; }
else if (stall is int limit && Expansions - progressExpansions > limit) finally
return attempt; {
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly); // Ready, stalled, cancelled and budget-exhausted searches can all leave
if (frame.Next == frame.Children.Length) // suspended siblings. Release their captured prefixes on every exit.
{ foreach (var frame in stack)
stack.Pop(); frame.Children?.Dispose();
continue;
}
stack.Push(new(frame.Children[frame.Next++].Node));
} }
return attempt;
} }
/// <summary> /// <summary>
@@ -228,16 +282,19 @@ internal static class JointCuttingPlanSearch
/// dependency-ready remaining part once the current part counts as finished, stable /// dependency-ready remaining part once the current part counts as finished, stable
/// ordinal ties. Never the current or a finished part. Global coordinates. /// ordinal ties. Never the current or a finished part. Global coordinates.
/// </summary> /// </summary>
private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source) private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source) =>
NextCut(node, sequence, source) is { } next ? Facing(source, next) : null;
private FixedProgramPlacement NextCut(Node node, int[] sequence, FixedProgramPlacement source)
{ {
var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet(); var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
finished.Add(source.SourceOrdinal); finished.Add(source.SourceOrdinal);
if (sequence != null) if (sequence != null)
for (var i = node.Order.Length + 1; i < sequence.Length; i++) for (var i = node.Order.Length + 1; i < sequence.Length; i++)
if (!finished.Contains(sequence[i])) if (!finished.Contains(sequence[i]))
return Centre(snapshot.Placements[sequence[i]]); return snapshot.Placements[sequence[i]];
var from = Centre(source); var from = Centre(source);
Vector? best = null; FixedProgramPlacement best = null;
var bestDistance = double.PositiveInfinity; var bestDistance = double.PositiveInfinity;
foreach (var candidate in snapshot.Placements foreach (var candidate in snapshot.Placements
.Where(p => !finished.Contains(p.SourceOrdinal) .Where(p => !finished.Contains(p.SourceOrdinal)
@@ -248,20 +305,61 @@ internal static class JointCuttingPlanSearch
if (distance < bestDistance - 1e-9) if (distance < bestDistance - 1e-9)
{ {
bestDistance = distance; bestDistance = distance;
best = Centre(candidate); best = candidate;
} }
} }
return best; return best;
} }
// Where the tool may have to go next, in global coordinates: the look-ahead point and,
// for an ordinary next part, the corners of its cut extent. Its pierce can be anywhere
// on it, so an outside entry that sees all of them leaves the most ways open.
private Vector[] SightTargets(Node node, int[] sequence, FixedProgramPlacement source)
{
if (NextCut(node, sequence, source) is not { } next)
return [];
var facing = Facing(source, next);
var cuts = next.Execution.Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList();
if (cuts.Count == 0 || facing != Centre(next))
return [facing];
var box = cuts.GetBoundingBox();
return [facing, new(box.Left, box.Bottom), new(box.Right, box.Bottom), new(box.Left, box.Top), new(box.Right, box.Top)];
}
// The next cut as seen from the current part. A host's centre lies in the middle of
// its cutout, among the parts nested there; a part inside it leaves toward the
// nearest of the host's own contours instead.
private Vector Facing(FixedProgramPlacement source, FixedProgramPlacement next)
{
if (source.IsCutOff || next.IsCutOff
|| !snapshot.Dependencies.PrerequisitesOf(next.SourceOrdinal).Contains(source.SourceOrdinal))
return Centre(next);
if (!hostContours.TryGetValue(next.SourceOrdinal, out var contours))
hostContours[next.SourceOrdinal] = contours = next.Execution.Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity()).ToArray();
var from = Centre(source);
return contours.Length == 0 ? Centre(next)
: contours.Select(c => c.ClosestPointTo(from)).MinBy(point => point.DistanceTo(from));
}
/// <summary> /// <summary>
/// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside /// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone /// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order. /// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
/// </summary> /// </summary>
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly) private IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery)
{ {
var edges = Expand(node, sequence, attempt, preferredOnly).ToList(); // A prescribed next part (or an active part's next contour) has a single
// source. Expand already yields its contour/entry rank order, so emit and
// validate a sibling only when DFS reaches it. Free whole-part selection
// still needs every source's distances before it can rank them. Recovery
// instead visits dependency-ready sources lazily by their material centres.
if (sequence != null || node.Active != null || contourRecovery)
return Expand(node, sequence, attempt, preferredOnly, contourRecovery);
var edges = Expand(node, sequence, attempt, preferredOnly, contourRecovery).ToList();
if (edges.Count <= 1) if (edges.Count <= 1)
return edges.ToArray(); return edges.ToArray();
// Stable source order: the minimum incremental rapid per source, ties ordinal. // Stable source order: the minimum incremental rapid per source, ties ordinal.
@@ -279,7 +377,7 @@ internal static class JointCuttingPlanSearch
.ToArray(); .ToArray();
} }
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly) private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery)
{ {
IEnumerable<FixedProgramPlacement> sources; IEnumerable<FixedProgramPlacement> sources;
if (node.Active is { } active) if (node.Active is { } active)
@@ -293,6 +391,8 @@ internal static class JointCuttingPlanSearch
: snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished) : snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished)
? [snapshot.Placements[sequence[node.Order.Length]]] : []; ? [snapshot.Placements[sequence[node.Order.Length]]] : [];
} }
if (contourRecovery)
sources = sources.OrderBy(p => Centre(p).DistanceTo(node.Position)).ThenBy(p => p.SourceOrdinal);
foreach (var source in sources) foreach (var source in sources)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -306,6 +406,12 @@ internal static class JointCuttingPlanSearch
continue; continue;
} }
var prepared = source.Prepared; var prepared = source.Prepared;
if (contourRecovery && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
{
foreach (var edge in RecoverPart(node, sequence, source, attempt))
yield return edge;
continue;
}
if (prepared.Count > 1 && node.Active == null) if (prepared.Count > 1 && node.Active == null)
{ {
// Selecting an endpoint does not cut it. Each endpoint owns a separate // Selecting an endpoint does not cut it. Each endpoint owns a separate
@@ -334,8 +440,10 @@ internal static class JointCuttingPlanSearch
// First try one ranked hole chain per endpoint. A later-part failure then // First try one ranked hole chain per endpoint. A later-part failure then
// changes the endpoint before replaying all earlier hole combinations. // changes the endpoint before replaying all earlier hole combinations.
// The retained pass below still searches every entry and hole order. // The retained pass below still searches every entry and hole order.
if (preference != null)
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
if (preferredOnly && preference != null) if (preferredOnly && preference != null)
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1); contours = contours.Take(1);
foreach (var contour in contours) foreach (var contour in contours)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -352,12 +460,18 @@ internal static class JointCuttingPlanSearch
.FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1); .FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1);
var target = downstream < 0 ? preference.PerimeterPierce var target = downstream < 0 ? preference.PerimeterPierce
: preference.Pierces[downstream]; : preference.Pierces[downstream];
entries = SelectEntries(source, contour, target, node.Position - source.Location); // The coarse pass already checked one owned, lead-feasible entry
// Preference only reorders retained candidates. Neither a failed // for this hole. Do not prepare every alternate before trying it.
// preferred entry nor an alternate hole order prunes this branch. // The subsequent full pass still enumerates and checks them all.
if (preference.Entries.TryGetValue(contour, out var preferred)) if (preferredOnly && preference.Entries.TryGetValue(contour, out var first))
entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point) entries = [first];
<= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray(); else
{
entries = SelectEntries(source, contour, target, node.Position - source.Location);
if (preference.Entries.TryGetValue(contour, out var preferred))
entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point)
<= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray();
}
} }
} }
else else
@@ -402,6 +516,92 @@ internal static class JointCuttingPlanSearch
} }
} }
// Recovery only: solve directed contour transitions rather than assuming
// that a centre-to-centre hole tour can cross the already released material.
// One complete checked chain per outside endpoint; original DFS remains fallback.
private IEnumerable<Edge> RecoverPart(Node node, int[] sequence,
FixedProgramPlacement source, Attempt attempt)
{
var prepared = source.Prepared;
var target = LookAheadCentre(node, sequence, source) - source.Location;
// Placement origins are authored program coordinates, not a downstream cut.
// Use the same material-centre look-ahead as the retained entry search.
var perimeters = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, token: token)
.OrderBy(c => c.Choice.Point.DistanceTo(target ?? node.Position - source.Location))
.Where(c => Evaluate(source, c).Status == ContourFeasibilityStatus.Clear).Select(c => c.Choice).ToArray();
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal)
.Select(c => prepared.AutomaticEntryCandidatesWithFallbacks(c, token: token)
.Where(e => Evaluate(source, e).Status == ContourFeasibilityStatus.Clear).Select(e => e.Choice).ToArray()).ToArray();
var motions = new Dictionary<ContourChoice, OwnedExecution>();
var stop = Expansions + (long)StallExpansionsPerEntry * snapshot.MaxEntries * prepared.Count;
OwnedExecution Motion(ContourChoice choice)
{
if (motions.TryGetValue(choice, out var known)) return known;
CountExpansion(source);
return motions[choice] = ExecutionMotionReader.Read(
prepared.EmitCandidateForValidation(choice), source.Location, null, token);
}
for (var endpoint = 0; endpoint < perimeters.Length; endpoint++)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) yield break;
var outside = perimeters[endpoint];
var chain = new List<ContourChoice>();
// This dominance heuristic is confined to recovery: emitted
// seams can differ for the same released subset. Never prune full DFS.
var failed = new HashSet<(int Mask, long X, long Y)>();
if (!Solve(0, node.Position, node.Checker)) continue;
var program = prepared.Emit(chain);
var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token);
var checker = node.Checker.Copy();
CountExpansion(source);
if (!Check(source, execution, node.Position, checker, attempt)) continue;
var proposal = source.Propose(program, execution, chain, token);
yield return new(new([.. node.Order, proposal], execution.DeparturePoint, checker, null, node),
execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, endpoint, endpoint);
bool Solve(int mask, Vector position, ReleasedContourState before)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) return false;
var key = (mask, BitConverter.DoubleToInt64Bits(position.X),
BitConverter.DoubleToInt64Bits(position.Y));
if (failed.Contains(key)) return false;
if (mask == (1 << prepared.PerimeterOrdinal) - 1)
{
CountExpansion(source);
var findings = before.Copy().Check(Motion(outside), position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
if (findings.Count != 0) { failed.Add(key); return false; }
chain.Add(outside);
return true;
}
var candidates = Enumerable.Range(0, holes.Length).Where(c => (mask & (1 << c)) == 0)
.SelectMany(c => holes[c])
.OrderBy(c => Motion(c).Motions[0].End.DistanceTo(position));
foreach (var choice in candidates)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) return false;
CountExpansion(source);
var execution = Motion(choice);
var after = before.Copy();
var findings = after.Check(execution, position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
if (findings.Count != 0) continue;
chain.Add(choice);
if (Solve(mask | (1 << choice.ContourOrdinal), execution.DeparturePoint, after)) return true;
chain.RemoveAt(chain.Count - 1);
}
failed.Add(key);
return false;
}
}
}
/// <summary> /// <summary>
/// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates /// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
/// converted exactly once: rank the (fallback-complemented) catalogue toward the /// converted exactly once: rank the (fallback-complemented) catalogue toward the
@@ -417,7 +617,7 @@ internal static class JointCuttingPlanSearch
// One global->local conversion of target and arrival; geometry is already rotated. // One global->local conversion of target and arrival; geometry is already rotated.
Vector? local = LookAheadCentre(node, sequence, source) is { } target Vector? local = LookAheadCentre(node, sequence, source) is { } target
? target - source.Location : null; ? target - source.Location : null;
return SelectEntries(source, contour, local, arrival); return SelectEntries(source, contour, local, arrival, SightTargets(node, sequence, source));
} }
private ContourEntryFeasibility Adapter(FixedProgramPlacement source) => private ContourEntryFeasibility Adapter(FixedProgramPlacement source) =>
@@ -436,13 +636,23 @@ internal static class JointCuttingPlanSearch
} }
private IReadOnlyList<ContourChoice> SelectEntries(FixedProgramPlacement source, int contour, private IReadOnlyList<ContourChoice> SelectEntries(FixedProgramPlacement source, int contour,
Vector? target, Vector arrival) Vector? target, Vector arrival, Vector[] sightTargets = null)
{ {
// Last contour has no downstream target, but still needs its exact native // Last contour has no downstream target, but still needs its exact native
// closest-arrival fallback. Keep target null in the ranker: arrival is not // closest-arrival fallback. Keep target null in the ranker: arrival is not
// a next cut and must not acquire facing-side priority. // a next cut and must not acquire facing-side priority.
var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token); var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token);
var ordered = catalogue.RankTowardNextCut(target, arrival); var ordered = catalogue.RankTowardNextCut(target, arrival);
// With no lead-out the tool leaves the part from its outside entry. From inside a
// slot or pocket the straight way to the next cut runs back over the part just
// cut, so entries with the clearest way out lead. Order only: every entry stays.
if (sightTargets is { Length: > 0 } && contour == source.Prepared.PerimeterOrdinal
&& source.Material is { IsComplete: true } material)
{
var perimeter = material.Rings[0];
ordered = ordered.OrderBy(c => sightTargets.Count(sight => PostVerificationGeometry.Crosses(
c.Choice.Point + source.Location, sight, perimeter, token))).ToList();
}
var selection = ContourEntrySelection.Select(ordered, var selection = ContourEntrySelection.Select(ordered,
candidate => Evaluate(source, candidate), snapshot.MaxEntries, token); candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0) if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0)
@@ -466,6 +676,12 @@ internal static class JointCuttingPlanSearch
var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray(); var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray();
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray(); var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
var localArrival = active.Arrival - source.Location; var localArrival = active.Arrival - source.Location;
// The tool arrives inside a cutout from a part nested there. That cutout's centre
// says nothing about where its cut can be reached: route it from where the tool is,
// before a later hop to it has to cross the other parts nested in it.
foreach (var hole in holes)
if (prepared.HoleContains(hole, localArrival))
centres[hole] = localArrival;
var endpoint = perimeter.Point; var endpoint = perimeter.Point;
var entries = new Dictionary<int, ContourChoice>(); var entries = new Dictionary<int, ContourChoice>();
var pierces = holes.ToDictionary(h => h, h => centres[h]); var pierces = holes.ToDictionary(h => h, h => centres[h]);
@@ -558,6 +774,13 @@ internal static class JointCuttingPlanSearch
return true; return true;
} }
internal void Restart()
{
progress = -1;
Deepest = null;
Crossed.Clear();
}
internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings) internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings)
{ {
if (Blocked != ordinal) if (Blocked != ordinal)
@@ -593,7 +816,6 @@ internal static class JointCuttingPlanSearch
private sealed class Frame(Node node) private sealed class Frame(Node node)
{ {
internal Node Node { get; } = node; internal Node Node { get; } = node;
internal Edge[] Children { get; set; } internal IEnumerator<Edge> Children { get; set; }
internal int Next { get; set; }
} }
} }
+67 -9
View File
@@ -12,11 +12,22 @@ namespace OpenNest.Engine.Fill
/// </summary> /// </summary>
public static class Compactor public static class Compactor
{ {
/// <summary>
/// The plate's clamp zones as static push obstacles (C1). Unlike ordinary
/// already-overlapping parts, a clamp stays a wall even when a selected member
/// straddles it: dropping it would let other clear members move into the zone.
/// Empty zone lists change every caller's arithmetic by nothing (C2).
/// </summary>
private static List<Part> ZoneObstacles(Plate plate) =>
Jobs.InteractiveClamps.ObstacleParts(plate);
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction) public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{ {
var obstacleParts = plate var obstacleParts = ZoneObstacles(plate);
.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) obstacleParts.InsertRange(
.ToList(); 0,
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList()
);
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
} }
@@ -26,9 +37,12 @@ namespace OpenNest.Engine.Fill
/// </summary> /// </summary>
public static double Push(List<Part> movingParts, Plate plate, double angle) public static double Push(List<Part> movingParts, Plate plate, double angle)
{ {
var obstacleParts = plate var obstacleParts = ZoneObstacles(plate);
.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) obstacleParts.InsertRange(
.ToList(); 0,
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList()
);
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
@@ -214,9 +228,12 @@ namespace OpenNest.Engine.Fill
PushDirection direction PushDirection direction
) )
{ {
var obstacleParts = plate var obstacleParts = ZoneObstacles(plate);
.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts)) obstacleParts.InsertRange(
.ToList(); 0,
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList()
);
return PushBoundingBox( return PushBoundingBox(
movingParts, movingParts,
@@ -374,5 +391,46 @@ namespace OpenNest.Engine.Fill
break; break;
} }
} }
/// <summary>
/// As <see cref="Settle(List{Part}, Box, double, double, int)"/>, settling inside the
/// plate's work area with its clamp zones as static obstacles: settling never parks
/// a part inside a keep-out (C1), and a clear cluster still compacts against zones
/// like any other obstacle. With no zones this behaves exactly like settling in the
/// plate's work area (C2).
/// </summary>
public static void Settle(
List<Part> parts,
Plate plate,
double threshold = 0.01,
int maxIterations = 20
) => Settle(parts, plate, plate.WorkArea(), threshold, maxIterations);
/// <summary>Keep a fill group's own work-area bounds while excluding plate clamps.</summary>
public static void Settle(
List<Part> parts,
Plate plate,
Box workArea,
double threshold = 0.01,
int maxIterations = 20
)
{
if (parts.Count < 2)
return;
var obstacles = Jobs.InteractiveClamps.ObstacleParts(plate);
obstacles.AddRange(plate.Parts.Where(part => !parts.Contains(part)
&& !part.BaseDrawing.IsCutOff));
for (var i = 0; i < maxIterations; i++)
{
var moved = 0.0;
moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Left);
moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Down);
if (moved < threshold)
break;
}
}
} }
} }
File diff suppressed because it is too large. Load diff
+9 -1
View File
@@ -15,10 +15,18 @@ namespace OpenNest.Engine.Fill
private readonly RemnantFinder finder; private readonly RemnantFinder finder;
private readonly double spacing; private readonly double spacing;
public RemnantFiller(Box workArea, double spacing) /// <summary>
/// <paramref name="boxObstacles"/> are kept-out rectangles (e.g. inflated clamp
/// zones) treated like occupied space: they are excluded from the remnant areas
/// every fill attempt sees. Null keeps today's behaviour exactly.
/// </summary>
public RemnantFiller(Box workArea, double spacing, IEnumerable<Box> boxObstacles = null)
{ {
this.spacing = spacing; this.spacing = spacing;
finder = new RemnantFinder(workArea); finder = new RemnantFinder(workArea);
if (boxObstacles != null)
foreach (var box in boxObstacles)
finder.AddObstacle(box);
} }
public void AddObstacles(IEnumerable<Part> parts) public void AddObstacles(IEnumerable<Part> parts)
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff. Show more