383 Commits
Author SHA1 Message Date
ajandClaude Opus 5.5 6c4ebe49b3 fix(planning): cut the remnant cut-offs last
The last strip line stops at the remnant's full-length horizontal
separator, so it never counted as separating the sheet and the planner
cut it wherever the route passed it, often first (N26-264). That frees
the X-tail remnant early and loses the mass that absorbs heat while the
parts are cut.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Previews of all seven now take 57-166 ms.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Docs also cover the leftover placement from the previous commit.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

FillEndEven now tries both stripe directions, as FillEndOdd does.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:33:20 -04:00
ajandClaude Opus 5.5 3e767ca3b6 feat(avalonia): prototype a cross-platform plate viewer
Adds OpenNest.Avalonia, a throwaway Avalonia 11 app kept out of
OpenNest.sln, to gauge a Linux port. It reads nests through
OpenNest.IO and draws plates with Skia: a port of the desktop's
display paths (cuts, etches, manual lead-ins, filled material),
pan, cursor zoom, fit, hover and selection, beside a plate list and
a drawings DataGrid. --snapshot renders the window to a PNG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:12:18 -04:00
ajandClaude Opus 5.5 e18304066d feat(ui): draw library previews in one shared style from the color scheme
The Shape Library, drawing library and drawing set preview each drew
parts their own way (hardcoded green fill, steel-blue fallback, black
outlines with gray etch). They now share a library style on ColorScheme
(background, fill, outline, etch and bend colors) that ignores drawing
colors, follows the active scheme (Dark gets a dark variant) and
round-trips through saved schemes, defaulting when absent. Nest part
colors are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:23:42 -04:00
ajandClaude Opus 5.5 dcb0a5c519 feat(shapes): pick shapes from a thumbnail grid in the Shape Library
Replace the name list with a ListView of 96px thumbnails drawn by
Program.GetImage at each shape's preview defaults (or its first
configuration), labelled with the name and configuration count. The
left pane opens three tiles wide instead of half the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:11:22 -04:00
ajandClaude Opus 5.5 1592d4bfd9 fix(ci): expect inches units on migrated legacy nests
Server schema v12 (2d8d877) adds a units column, so migrated legacy
records now report units: "inches" and the Server image smoke's
legacy-vs-migrated manifest comparison failed (cmp differs at line 27)
on every master push since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:47 -04:00
ajandClaude Opus 5.5 857a560dce test(engine): repin the clamp-zone Fill-stack layouts to what the engine produces
FillItem_WithClampZones_PinnedLayout (Fill, Strip, Vertical Remnant) has
failed in the Engine.Tests CI job on every push since it was added in
ccd361d: the pinned digest 9BF5FC7FC47364A9 is not reproducible at that
commit or any later one, locally or on GitHub runners. The engine
deterministically yields 9B3EFF9B72243576 (Fill, Strip) and
039C0D607166D805 (Vertical Remnant) at ccd361d, 36c8de8 and master, so the
later filler consolidation is still layout-neutral for these cases.

Before repinning, all four strategies' layouts were checked: 40 parts, all
inside the work area, bounding boxes clear of both clamps, and no
overlapping parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:46 -04:00
ajandClaude Opus 5.5 69a83b3ad9 feat(support): let a tab alone hold short parts the bed can't
Small parts such as 1.5" test squares on a 2" laser grid rest on at most
one grid point, so no single support tab can bring their center of
gravity inside the contacts and Plan Cutting warned "no single tab can
steady it" on every one. In practice a part that short stays in the
sheet on its tab, so the bed's contacts don't matter.

Cutting Settings > Tabs gets "Tab alone holds parts up to N long"
(CuttingParameters.TabHeldMaxLength, 0 = off, the default). On a plate
with a bed, a part the bed can't hold level whose length (long side of
its minimum-area bounding rectangle) is within the limit is still
tabbed, but any tab clear of inside corners steadies it, so Plan
Cutting, hand-placed lead-ins and the pre-post review warn only when it
has no tab at all. The setting persists with the cutting parameters and
is part of the plan state fingerprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:20:03 -04:00
ajandClaude Opus 5.5 a3a75d589d fix(converter): accept a required quantity of 0 when loading drawings
Nest drawings can require no parts, but the CAD Converter's quantity
spinner had a minimum of 1, so Edit Drawings in Converter threw
ArgumentOutOfRangeException as soon as such a drawing was selected.
Lowering the minimum to 0 lets the converter load it and hand the
drawing back with its quantity still 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:26:29 -04:00
ajandClaude Opus 5.5 a3b7c715d5 docs: add the Undo/Redo contract and point AGENTS.md at it
Undo/Redo landed in phases 1-3 without a written contract, so the next
person adding a desktop edit has no rule telling them to record it, and
nothing explains why a restore must close Cutting Inspection or why
snapshots keep empty plates and skip best fits. docs/undo-redo.md states
what is and is not undoable, the BeginChange/RecordChange rule, the
"Change" safety net, how states are captured and restored, the guards
and key routing, and the measured limits. AGENTS.md links it from the
OpenNest bullet and the contract list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:59 -04:00
ajandClaude Opus 5.5 16afb41c26 feat(desktop): undo machine changes and Nest Info edits
The nest's machine and its Nest Info fields were the last edits only the
fingerprint safety net could catch (and it misses most of them). Machine
is now one step, together with the support grid and cut-off settings the
machine brings. Nest Info is one step for what Undo can put back (units,
material, thickness, customer, notes and plate defaults); the name,
status, author and creation date stay as they are through Undo, so an
edit of only those records nothing.

The sheet strip only shows Auto Nest proposals; what it shows is
committed through Auto Nest, which is already recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:57 -04:00
ajandClaude Opus 5.5 2e72469d6c feat(desktop): undo drawing imports, edits and deletes
Drawing changes were unrecorded, so Undo skipped them or lumped them
into an anonymous "Change". Each is now one labelled step: Import
Drawings, Shape Library (menu and panel button), Drawing Library (what
the dialog added or re-quantified), Save to Drawing Library (the nest
keeps the drawing's new library link), Edit Drawings in the converter,
Drawing Properties, Delete Drawing and Clean Unused Drawings. Dropping a
drawing on a plate was already recorded as Add Part and is now tested.

The converter can change live programs while it loads, even when it is
cancelled; a cancel that did so now rolls the nest back to before the
converter opened instead of leaving those edits unrecorded.

Deleting a drawing threw when removing its parts emptied the trailing
plates, because the plate manager drops trailing empty plates while the
loop walked the live plate list; it walks a copy now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:54 -04:00
ajandClaude Opus 5.5 3dd1acd3cf feat(desktop): undo cut-offs, sequencing, lead-ins and cutting plans
Cutting edits were unrecorded, so Undo skipped them or folded them into
an anonymous "Change". Each is now one labelled step: Add Cut-Off, Move
Cut-Off (one step per drag), Automatic Cut-Offs, Auto Sequence (current
plate or all plates, only when the order changed), Set Sequence (per
click), Sequence by Path (the whole tool session; Backspace and Ctrl+Z
inside it still take back one line), Place Lead-In, Remove Lead-Ins (the
tool's menu, the plate and the nest commands), Cutting Parameters (an
edited lead-in panel when the tool closes), Plan Cutting and Clear
Lead-ins and Cut-offs.

Plan Cutting stages scrap cut-offs on the live plates before it plans,
so its scope opens with the staging and records only when the plan is
applied; closing without applying records nothing. Clearing applied work
is its own step and no longer warns that it cannot be undone.

Opening the lead-in tool used to give a plate without parameters the
defaults straight away; the plate now takes the tool's parameters with
its first lead-in, so opening and closing the tool changes nothing.
Undo no longer waits while the tool has a part picked: it ends the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:52 -04:00
ajandClaude Opus 5.5 9a62a5102d feat(desktop): undo plate edits, clamps and the support grid
Plate and clamp changes went unrecorded, so Undo skipped them or lumped
them into an anonymous "Change". Each is now one labelled step: Edit
Plate (only on OK), Rotate Plate, Resize Plate, Set Nest Default Plate,
Clamps and Support Grid (applying to the nest; saving machine defaults
stays outside the history), Remove Clamp / Put Clamp Back from the
plate's clamp menu, and Add/Move/Delete Clamp in Edit Clamps.

Edit Plate used to copy an empty plate's settings into the nest defaults
even when the dialog was cancelled; that now happens only on OK, so a
cancelled dialog changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:49 -04:00
ajandClaude Opus 5.5 223bfea47c feat(desktop): undo fills, pattern tiles, auto nest and clear nest
Placement edits were only caught by the fingerprint safety net, so they
undid as an anonymous "Change" or folded into the next edit. Each now
records one labelled step: Fill (at the commit, so a stopped fill that
keeps nothing records nothing), Fill Area, Pattern Tile, Auto Nest (from
the kept proposal only; Make Nests tabs too), Clear Nest, Remove Empty
Plates and Center Parts. Clear Nest no longer warns that it cannot be
undone.

Pattern Tile onto a new plate created a plate beside the empty sentinel,
which the plate manager dropped at once, so the pattern landed on a
detached plate; it now fills the sentinel.

The undo tests share one harness that compares a text description of the
nest before an edit and after Undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:47 -04:00
ajandClaude Opus 5.5 d1270f5208 feat(desktop): undo and redo the most-used nest edits
Whole-nest snapshots make undo safe without a hand-written inverse per
operation: each recorded edit stores one NestSnapshot in the document's
history, and Undo/Redo read one back and swap it into the open window.

- Document.History is reset with the opened nest; Save and database
  saves mark the save point instead of clearing it.
- EditNestForm.BeginChange opens a scope: nested scopes merge into the
  outermost, Complete commits one entry, and leaving it incomplete rolls
  a changed nest back. No-ops are detected by the scope's changed flag,
  not by comparing bytes (copies are not byte-stable).
- RestoreNest shares the per-nest binding with the constructor and keeps
  the Document, the nest's identity, the plate index, the viewport and
  the selection. It ends the action, closes Cutting Inspection, side
  panels and the remnant viewer, rebuilds the PlateManager, reapplies
  the view's cut-off settings and recolours the drawings, because each
  of those holds or derives from the old nest's objects.
- An edit no scope recorded (Phase 3 sites) is detected by a cheap
  fingerprint and committed as its own "Change" entry before a scope,
  Undo or Redo, so it is never silently discarded; DEBUG builds also
  warn about it on idle.
- Edit > Undo/Redo name the next entry. Ctrl+Z/Ctrl+Y/Ctrl+Shift+Z go
  to focused text editors first, then to an action's own step-back
  (Sequence by Path), then to the nest. They wait for jobs, fills,
  background saves and busy actions, but not for a plain selection.
- Recorded: delete, drag, arrow nudges (coalesced), rotate, align,
  push, expand spacing, add part (drop and clone), cut, add and remove
  plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:45 -04:00
ajandClaude Opus 5.5 fe31a3f8ec feat(io): add in-memory nest snapshots for undo
Undo will keep whole-nest states and restore by reading one back.
NestSnapshot.Capture writes the same archive a save writes, with three
differences Phase 0 found necessary: it does not stamp DateLastModified
on the live nest, it leaves best fits out (they are a cache), and it
keeps empty plates, which files drop, so an undo does not shift plate
indices or lose a plate the user added. File output is unchanged.

Restore reuses each live drawing whose saved content is unchanged,
matched by a fingerprint over its nest.json entry, program, hole
sub-programs and source entities. BestFitCache and FillResultCache are
keyed by the Drawing object, so a plain read would make the next fill
recompute every best-fit pair; reuse keeps those caches, colours and
Drawing.Id, and skips parsing the reused programs. Nested counts are
recomputed for the restored nest, and given back to the live one if the
read fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:43 -04:00
ajandClaude Opus 5.5 a88c056f4b test(io): expect saved part cutting parameters after reload
These round-trip tests pinned the old behaviour, where a part with a
saved lead-in program read back with null cutting parameters. Since
7abf0701 the part keeps the settings its leads were built with, as an
owned copy that stays separate from the plate's settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:42 -04:00
ajandClaude Opus 5.5 81ec6cc738 feat(data): add a state-based undo history
The desktop app has no undo. Phase 0 showed whole-nest snapshots are
cheap enough to record after every edit, so the history keeps whole
states with a cursor instead of per-command inverses that each need
hand-written reversal code.

It drops the redo tail on commit, evicts the oldest states past 50 steps
or 128 MB without ever evicting the current one, tracks a save point for
a later dirty indicator, and merges matching commits within a second so
repeated nudges undo as one. The clock is a TimeProvider so tests control
the coalescing window. It is generic and free of IO and WinForms so it is
unit tested on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:40 -04:00
ajandClaude Opus 5.5 11b04cc272 fix(io): save each part's cutting parameters with its lead-in program
PartDto had no field for Part.CuttingParameters, and restoring a saved
program cleared it, so after save and reopen the lead and pierce-zone
reviews judged manual and planned leads against the plate's settings
instead of the ones they were built with. Undo/redo will restore nests
through the same reader, so it would have carried the same loss.

The settings are written only beside a saved part program and read
back after the program installs. Older files and clean parts read null,
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:11:39 -04:00
ajandClaude Opus 5.5 6a9b81326e feat(planning): cut text-like cutouts in reading order
Serial-number tags with 27 stencil-text cutouts never planned: contour
recovery stopped at 16 holes, a lone part never got recovery, and the
remaining search kept leaving letters on the wrong side so rapids ran
back across letters already cut. Recovery now handles up to 30 holes,
runs for lone holed parts, and first sweeps the holes in reading order:
by their far side along the way they spread, each left from its far side
(stencil pieces face the next piece), ending at an outside entry beyond
every hole, with a few expansions per hole. Parts within the old 16-hole
reach keep the search first, so their plans are unchanged.

The eroded pierce now steps down through shallower levels so each entry
gets a pierce on its own stroke's centre line, and leads to the entry the
planner aimed at (falling back to the nearest wall), which the sweep needs
to leave a letter from the right place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:36:54 -04:00
ajandClaude Opus 5.5 37cdc52f0d feat(cutting): pierce cutouts too small for their lead-in by erosion
Small text and narrow slots often have no entry where the configured
lead-in fits, so Plan Cutting refused them. When every entry of a cutout
fails, the planner now erodes the cutout inward (to the pierce clearance,
or nine tenths of its inscribed radius when smaller), pierces at the
eroded point nearest each ranked entry and leads straight to the closest
wall, with no lead-out. Letter strokes pierce on their centre lines; a
tapering feature pierces at its widest spot. The lead validator still
checks every candidate, and cutouts whose lead fits are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:36:03 -04:00
ajandClaude Opus 5.5 d3d7bc0299 fix(planning): cast containment rays clear of every vertex height
Text cutouts on an ID tag sat a micro-inch below another letter's
baseline. The parity ray, offset a fixed 1e-6 above the test point, ran
exactly through that letter's seam vertex, whose two copies differ in the
last bit, so one edge counted and the tag captured as "Nested cutouts are
not supported material" at some plate locations but not others. The ray
height now steps until no vertex (or arc top/bottom) lies within a small
band of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:36:00 -04:00
ajandClaude Opus 5.5 3884d058ec feat(cutting): redesign the cutting parameters form
The old layout clipped lead fields inside fixed-height group boxes, scrolled the
whole dialog, hid tabs in a collapsed section and filed angle rounding under
Pierce. Leads now sit on Outside/Holes tabs sized for the type with the most
fields, labels share one aligned column with degree suffixes and hints, angle
rounding moved under Leads, the tab options read as plain sentences and enable
only when they apply, and the dialog sizes itself to fit without scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:44 -04:00
ajandClaude Opus 5.5 27610c3b1d fix(library): lay out the set history's drawings grid on first show
The members group resumed layout only after the outer table had already
sized it, so its docked grid kept the default 240x150 until the form was
resized. Inner containers now resume before the table lays them out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:43 -04:00
ajandClaude Opus 5.5 1b99a7c866 feat(display): show plate ids only when the zoom makes room for them
Zoomed out, small parts and short cut-off segments piled their ids on each
other. As the CAD converter numbers entities, an id now shows only once its
part is big enough on screen to hold it: a part must be as wide and tall as
the id's text, a cut-off segment as long as the id's disc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:43 -04:00
ajandClaude Opus 5.5 6e40c2e6ed feat(library): redesign Edit Drawing with drop-downs, color modes and a rotation dial
Typing customer, material, grade and thickness by hand let near-duplicate
spellings into the library, which then split drawings that should match.
The fields are now drop-downs of the values the library and the machine
material catalog already use, while still accepting new values; thicknesses
already used for the material are listed ahead of the standard gauges.

A library color could be set but never cleared back to the color scheme's,
so Automatic and Reset are added. The rotation start/end/step boxes gave no
sense of the range, so a dial shades it, ticks the angles the engines try
(via RotationPolicy) and lets either end be dragged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:43 -04:00
ajandClaude Opus 5.5 0ba5539fab fix(display): keep cut-off lines from striking through segment ids
Cut-off lines are painted after the parts, so the dashed line ran straight
through each segment's id, which sits on the cut. Cut-off segment ids are now
held back from the parts pass and drawn after the lines, over a disc of the
plate's fill color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:43 -04:00
ajandClaude Opus 5.5 2e17dd7bc4 fix(desktop): size grid columns before they are first painted
Grids opened at their designer widths and jumped a moment later, because
GridStyle waited for the first paint with rows before measuring them.
Columns now measure their header and first 50 rows directly: at handle
creation when the rows are already there, otherwise in a call posted when
rows arrive, which runs after the filling code and before the repaint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:42 -04:00
ajandClaude Opus 5.5 86b0370a8f fix(names): drop S from generated nest names
S reads too much like 5 on labels and screens, so the sequence alphabet is
now base-29. Already-issued names keep their numbers: the server's stored
counter stays authoritative and a base-29 name can never equal an earlier
base-30 one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:42 -04:00
ajandClaude Opus 5.5 e5e2c268b8 feat(ui): redesign the Nest Defaults dialog
The flat ten-row list squeezed the quadrant picker to 120x28 and gave no
hint of units or where the defaults are saved. Group the fields (General,
Plate Size, Quadrant, Spacing), lay the edge spacings out around a plate,
show in/mm suffixes, and convert the values when the units switch so a
48 in plate does not silently become 48 mm.

The footer says whether Save writes to this PC or the shared nest server,
and Save is disabled while the shared copy is read-only instead of
failing after the click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:42 -04:00
ajandClaude Opus 5.5 7f1c5a3854 fix(display): label each cut-off segment at its own cut
Every segment of a cut-off is its own part of the one cut-off drawing, but
its sequence label was placed from the drawing's whole program, so all of a
cut-off's segments stacked their numbers on one point. The view culls parts
outside the visible area, so zooming changed which segment drew last and the
shown number appeared to change. Each segment is now labelled at the middle
of its own cutting moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:41 -04:00
ajandClaude Opus 5.5 96d8a6874f fix(autonest): show part rotation limits in degrees
The Rot Start, Rot End and Step columns showed the drawing's radian
constraints as-is, so users had to type 1.5708 for 90 degrees. The grid now
shows and accepts degrees; rows keep the radian values, so an unedited row
passes the drawing's limits to the engine without a conversion round trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:10:59 -04:00
ajandClaude Opus 5.5 fecfcc337e build(help): ship help v0.4.0 with the shared nest server, Drawing Library, drawing sets and releases guides
The server and Database-mode pages were written after v0.3.0 was tagged,
so builds pinned to v0.3.0 had no help for setting up or connecting to a
nest server, or for the library, sets and releases that depend on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:01:55 -04:00
ajandClaude Opus 5.5 b0697f92b3 feat(cutoffs): keep large horizontal remnants in automatic scrap cut-offs
Strip lines now stop at a full-length horizontal cut-off that clears the
parts instead of slicing the band beyond it. The desktop dialog and Plan
Cutting also propose their own horizontal separator when that band beats
the X tail (opt-in for API callers via KeepHorizontalRemnant); a kept X
tail beside the band becomes a corner piece whose separator stops there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:20:34 -04:00
ajandClaude Opus 5.5 98f8e98230 fix(cutting): curved leads meet the cut tangentially; arc lead-in sweep
Curved leads turned with the contour winding, which is backwards on outside
perimeters: arc lead-ins arrived against the cut, and Line + Arc / arc
lead-outs went the long way round on one winding. LeadTurn now gives the
arc's turn direction for emission, the pierce-zone check and the preview,
and the arc geometry mirrors with it.

At convex outside corners, curved lead-ins use the first-cut edge's normal
and arc lead-outs the last-cut edge's, whichever edge was picked.

ArcLeadIn gains SweepAngle (degrees, (0, 180], default 180) in the cutting
panel, settings, nest files and cut pins; older files read as 180.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:50:30 -04:00
ajandClaude Opus 5.5 5611d9a663 refactor(engine): one PlateNester and one strategy name table
DefaultPlateNester, StripPlateNester and RemnantPlateNester differed only
in the filler they built. They are replaced by one internal PlateNester
over a Func<Plate, PlateFiller>, which keeps the old Place exactly:
restricted rotation goes to OrderedPlateNester, and each trial gets a fresh
private plate and filler.

PlacementStrategies maps Fill (and its legacy "Default" alias), Strip and
both Remnants to a filler policy and layout. PlateNesterFactory reads it
case-sensitively and PlateFillService ignores case, as before; each keeps
its own error type and message. Nothing outside the Engine and its tests
used the three public nester types, so they are removed.

No layout changes: all 29 fill-stack pins pass with the pin file untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:51 -04:00
ajandClaude Opus 5.5 5761eefd37 refactor(engine): merge the plate fillers into one PlateFiller
DefaultPlateFiller becomes PlateFiller(plate, policy, layout); PlateFillerBase
and StripPlateFiller are gone. PlateLayout.FillThenPack is the old
orchestrator path (Fill and both Remnants); PlateLayout.Strips is the old
StripPlateFiller.Nest, and Strip is Fill policy plus Strips layout.

Lifetimes are unchanged: a strip-layout filler runs every Fill, group Fill
and PackArea on a fresh inner filler and gives each strip fresh remnant
fillers, so learned angles and PlateNumber never leak into them. The
comparer, preferred direction, trim axis and angle recording now read the
policy directly. The base class's unsorted two-angle BuildAngles is dropped:
only DefaultPlateFiller's pipeline called BuildAngles, and Strip never ran it.

Layout-neutral: all 29 FillStackCharacterizationTests pins pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:51 -04:00
ajandClaude Opus 5.5 9f335ea111 refactor(engine): fold remnant fill settings into one filler policy
Fill, Vertical Remnant and Horizontal Remnant are now three values of an
internal PlateFillPolicy that DefaultPlateFiller takes (Fill by default),
replacing RemnantPlateFiller and RemnantFillPolicy. The policy holds the
comparer factory, preferred direction, trim axis and angle mode; the
remnant angle mode still replaces the angle builder outright, and the
public FillPolicy that strategies read is unchanged. Layout-neutral: the
fill-stack characterization pins pass unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:51 -04:00
ajandClaude Opus 5.5 0c7ad5aefb refactor(engine): list the built-in fill strategies instead of scanning
FillStrategyRegistry now holds a fixed, ordered list of the seven
built-in strategies (Pairs, Row, Column, RectBestFit, Extents, Linear,
TiltedRemnant, in their Order sequence) instead of reflecting over the
Engine assembly. LoadFrom and LoadPlugins are gone; nothing outside
tests called them, so outside IFillStrategy plug-ins were never loaded
by a shipped host and none are loaded now. IFillStrategy stays public.

The disabled set stays process-wide and keeps its Disable, Enable,
SetEnabled, Strategies, AllStrategies and DisabledNames behavior for the
Options form. DefaultPlateFiller still reads Strategies, so interactive
Fill, the fixed-strategy engines, Irregular's block catalog and private
plate fill, the cutout lattice fill and the brute-force runner all keep
honoring it.

PlateViewFillLifetimeTests drives fills through PlateView, which builds
its own filler, so a filler-level seam can't reach it. The registry gets
an internal UseForTesting hook that swaps the list until disposed, and
the WinForms test project can see Engine internals for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:44:50 -04:00
ajandClaude Opus 5.5 73a7c8d6a2 feat(desktop): drop a set's shared name prefix from preview labels
When every drawing in a set starts with the same words, as in
"4980 A111 PT01", the set preview's labels were all cut short to the
same "4980 A111 P…". The preview now drops the leading words all names
share, cutting only at a space, '-', '_' or '.' and never emptying a
name, and notes the dropped prefix above the drawings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:39:37 -04:00
ajandClaude Opus 5.5 479883e7ac feat(cutting): tab parts by width when the plate has no support bed
With support tabs on and no support bed, nothing was tabbed and nothing
said so. A new no-bed width range (NoBedTabMinWidth..NoBedTabMaxWidth,
the short side of the part's minimum-area bounding rectangle) now picks
the parts to tab instead. SupportTabSettings.Analyze makes the choice
for Plan Cutting, hand-placed lead-ins and the pre-post review; without
a bed any tab clear of inside corners counts. With no bed and no range,
the plan notes that nothing was tabbed. Cutting Settings > Tabs gains
the range fields, and the values persist with the cutting parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:02:31 -04:00
ajandClaude Opus 5.5 07499fe1cd fix(engine): judge part spacing alike in the layout check and runner
NestLayoutCheck rejected Fill layouts whose interlocked arcs sat exactly
at the part spacing: its SpacingOutline circumscribes arcs by up to 0.001
each, more than the 0.0005 slack, so facing arcs overlapped. The runner's
placement validator measured inscribed 0.001 polygons instead and so
accepted gaps up to ~0.002 under the slack.

Both now find candidates with SpacingOutline and confirm them on
NativeContours, the native contours flattened at a twentieth of the slack
(moved out of PartSpacingReview, which keeps using them). The runner calls
NestLayoutCheck.KeepsSpacing, the same outline, tie rule and
confirmation, so the two validators accept exactly the same pairs.
Clears stays unconfirmed and stricter for engine certification. No
tolerance changed.

FixedStrategyEngine_IrregularJob pins now assert LayoutAssert.Valid with
unchanged digests; the NativeU 70-part grid's 28 reports were the same
cause and its test now requires none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:58:41 -04:00
ajandClaude Opus 5.5 1b1f86085d fix(engine): keep best-fit pair candidates in a stable order
BestFitFinder gathered each rotation strategy's candidates, and PairEvaluator
gathered evaluated results, in ConcurrentBags, so their order depended on which
worker finished first. The stable area sort then broke equal-area ties in that
arbitrary order, and fills, which try candidates in order and keep the first of
equal layouts, could nest the same parts differently between runs.

Both now fill index-addressed slots: candidates keep rotation-angle and
slide-offset order, results keep candidate order, and ties break the same way
every run. The candidates produced and their scores are unchanged.

The Strip whole-job pin in FillStackCharacterizationTests is re-pinned
(A66602F707290A36 -> FD03A4529D37E32C). That job ties between best-fit
pairs; the old pin recorded whichever tied pair the bag happened to return
first. The new layout places the same 26 parts, passes LayoutAssert.Valid
and gave the same digest in five separate test processes. The other 28
pins are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:58:34 -04:00
ajandClaude Opus 5.5 36c8de825e fix(engine): route restricted-rotation Strip plates to the ordered nester
StripPlateNester sent every request to the strip fills, which try two
classification angles and ignore rotation bounds. With a fixed or bounded
rotation at quantity above one they proposed forbidden poses, the job
validator threw "Candidate rotation is not allowed for the requirement."
and the whole Strip job failed. Strip now uses the same guard as
DefaultPlateNester and hands such plates to OrderedPlateNester.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:13:39 -04:00
ajandClaude Opus 5.5 ccd361dfa9 test(engine): pin Fill-stack layouts on irregular parts
Characterization digests for the interactive PlateFillService surface and
the fixed-strategy whole-job engines on L-shaped, notched, arc, bar and
chevron parts: single-item fills, whole-plate nests (including one where
learned angles change the layout, guarded by a full-sweep comparison),
fills beside clamp zones, and one job per Fill/Strip/remnant engine.
They let the planned filler/nester consolidation be shown layout-neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:07:20 -04:00
ajandClaude Opus 5.5 7c4276a457 refactor(engine): retire the StockLadder engine
Every whole-job engine chooses among the offered stock sizes, and on the
synthetic benchmark nests Default matched or beat StockLadder on all 18
runs (saved sizes, a six-size pool, with and without salvage credit),
costing 30-40% less in total with mixed sizes. Its sheet-swap step never
produced a win over Irregular's tail re-plan.

The registry maps "StockLadder" to Default so saved selections, scripts
and API requests keep working. OrderedPlateNester stays: Fill and the
remnant strategies use it for restricted rotations, and its golden layout
is now pinned through NestJobRunner with the parts requested largest
first, as StockLadder requested them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:24:37 -04:00
ajandClaude Opus 5.5 14ba2e653b docs: describe the settings shared through the nest server
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:02:22 -04:00
ajandClaude Opus 5.5 18612e698d feat(desktop): use the server's shared settings in Database mode
Machines, post settings, clamp defaults and nest defaults come from the
nest server once it holds them, and from this PC's files until then; saves
go where the settings were read. The server's copy is re-read when older
than two minutes, before every post and when Machine Configuration opens.
While the server is unreachable the last copy read is used, the user is told
once, and nothing shared can be changed.

Posting re-reads the post's shared settings; saving them is skipped when the
dialog changed nothing, and a conflict loads the other workstation's
settings and stops the post. On first start the desktop offers to upload the
settings the server lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:02:22 -04:00
ajandClaude Opus 5.5 90e2fd2e6e feat(server): share machines, post settings, clamps and nest defaults
Schema v13 adds shared_settings and shared_setting_revisions behind
/api/settings: one revision-guarded JSON document per kind and key, with
every revision (deletions included) kept with who saved it. Recreating a
deleted document continues its revision count.

OpenNest.Data gains the client (RemoteSharedSettings), a synchronous store
that keeps the last copy read for offline use and refuses writes while the
server is unreachable (SharedSettingsStore), a machine provider over it, and
JSON parse/serialize entry points for clamp settings and nest defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:02:08 -04:00
ajandClaude Opus 5.5 9f3c22dc4f feat(posts): let posts load and save their settings as JSON
IJsonConfigPostProcessor lets a host keep a post's settings somewhere other
than the JSON file beside the post DLL. The CL-707, CI Fiber and Gravograph
posts implement it; LoadConfig keeps the current settings when the JSON is
unusable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 20:02:08 -04:00
ajandClaude Opus 5.5 84e6adbcf2 fix(data): give the CL-980 default a 0.02 cut-off part clearance
Plates now take cut-off settings from the nest's machine, so the
default's 0.25-0.75 clearances replaced the 0.02 operators expect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:29:59 -04:00
ajandClaude Opus 5.5 b35490addf test(planning): hold problem nests to a cut-off baseline
Nests with a "planning with cut-offs:" line are planned again after clearing
applied work and adding default scrap cut-offs. OPENNEST_PROBLEM_NESTS_CUTOFFS=1
plans every nest that way to record new lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:17:51 -04:00
ajandClaude Opus 5.5 1fbe57a841 fix(data): match machine thicknesses rounded to three decimals
PEP stores 3/16 as 0.188, which missed the machine's 0.1875 at the
0.0005 in tolerance; widen it to 0.001 in, still far below gauge steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:15:03 -04:00
ajandClaude Opus 5.5 1bb58117af fix(desktop): fit the set preview at the largest scale the pane allows
The set preview guessed row widths and settled the scale in four rounds,
which could stick at rows of minimum-width label cells and leave much of
a narrow pane empty. At a given scale, filling each tallest-first row as
far as the pane allows is the best row split, and the packed height only
grows with scale, so the layout now bisects for the largest scale that
fits. A 21-drawing set in a 400x590 pane goes from scale 1.14 with 345 px
used to 2.23 filling the height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:04:58 -04:00
ajandClaude Opus 5.5 70e8666335 feat(posts): give the Cincinnati CL-707 settings the CI Fiber sectioned editor
The CL-707 config now declares sections, sentence-case labels and ranges,
so its dialog matches the CI Fiber one instead of the generic PropertyGrid.
The sectioned editor gains what the CL-707 config needs: optional (nullable)
numbers behind an enable box, editable tables for lists of simple records,
and a text field that suggests values from a table column (Selected library).
"Lead-in arc line 2 feedrate" is relabeled "Lead-in arc feedrate", which is
what #127 drives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:04:57 -04:00
ajandClaude Opus 5.5 2d8d877482 feat(server): keep a nest's units on its record
A record's thickness is in the nest's units, but nothing on the record said
which, so a list of nests could not tell 9.525 mm from 9.525 inches without
reading each archive. Records now carry units, read from the uploaded
archive like the counts (absent or unknown values are inches) and kept by a
metadata-only save. Schema v12 adds the column and reads every stored
archive, current and historical, so existing millimeter nests report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:40:43 -04:00
ajandClaude Opus 5.5 610b9dafcb fix(autonest): mark ledger substitutes against the best rank matched
An acceptance rule may list sheet grades first, so at plate thickness its
best match can be rank 3 (A36 for HR). Auto Nest marked every row above
rank 1 a substitute; it now ranks rows against the best rule rank ML
matched at the nest's thickness, as ML labels them, so A36 is preferred
and only A572 is a substitute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:20:11 -04:00
ajandClaude Opus 5.5 079dfaf9ea fix(desktop): count only active MaterialLedger holds and link the nest page
MaterialLedger's Release lets a hold go, so a Released hold no longer keeps
a sheet for the nest; only Proposed, Approved and AwaitingCloseout holds,
the statuses ML counts against free stock, are open. The Has Been Cut
note's link pointed at /Nests/{id}, which ML does not serve; its nest page
is /Nests/Details/{id}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:20:11 -04:00
ajandClaude Opus 5.5 c27188c722 perf(desktop): reuse cached part paths when zooming and dragging
Each zoom step rebuilt every part's display paths from its CNC program,
and each drag step rebuilt the moved parts, which dominates for parts
with hundreds of holes. LayoutPart now keeps its paths in the drawing's
program frame and only maps them to the view on a zoom or move,
rebuilding when IsDirty is set or the program, rotation or manual
lead-ins change. Also caches the label StringFormat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:11:08 -04:00
ajandClaude Opus 5.5 548522775f fix(server): report a nest's units with its sheet sizes
GET /api/nests/{id}/sheets returns sizes as stored, in the nest's units,
but did not say which. MaterialLedger compares them with inches, so a
millimeter nest's sheets never matched inventory. The response now carries
units (inches or millimeters), read the way NestReader reads them: absent
or unknown values are inches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:41:29 -04:00
ajandClaude Opus 5.5 be01124a0f feat(autonest): rank MaterialLedger stock and mark substitutes
MaterialLedger's /api/stock/opennest now resolves an OpenNest material
and grade through ranked acceptance rules. The client reads the new
spec rank, family, grade and finish and the item rank; an older ledger
that sends none of them deserializes as rank 1 with no label.

LedgerStockMapper groups drops only by identical size and spec, keeps
the ledger's order in rows and in each row's ItemCodes, and orders rows
by rank. Auto Nest's Source column shows the row's grade and finish
("A1011 P&O"); substitutes (rank above 1) read "A1011 P&O for HR" in
amber, name the substitution in their tooltip and still load ticked.
The engine still sees no rank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:45 -04:00
ajandClaude Opus 5.5 f847b216f2 feat(desktop): show a server nest's MaterialLedger holds and closeout note
Server nests in Database mode get a read-only status bar label comparing their
open MaterialLedger holds with the sheets they cut, e.g. "Ledger: 2 of 4 held,
2 short". Nothing writes to the ledger and nothing waits on it.

- MaterialLedgerClient.GetHoldsAsync reads GET /api/stock/holds?openNestNestId=
  with the closed LedgerHoldsResult set; unknown status strings are kept. The
  request plumbing is shared with GetStockAsync.
- LedgerHoldSummary (OpenNest.Data) counts Proposed/Approved/Released/
  AwaitingCloseout holds against the plate quantities, skipping the trailing
  empty plate as /sheets does.
- NestHoldStatus refreshes on server open, after save, after storage or
  Options changes and on label click; it never polls. The tooltip lists holds
  in nest units. File-mode, unsaved and ledger-less nests show nothing.
- Setting Has Been Cut in Nest Info with holds still open shows a non-modal
  note linking to {ledgerUrl}/Nests/{id}. Posting is not gated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:45 -04:00
ajandClaude Opus 5.5 23f093fcc1 feat(nest): plates report the remnant they are expected to leave
Core PlateRemnant.Expected picks a full-span cut-off drop clear of the
parts (less kerf, nominal position for head-locked overcuts), else the
largest salvage edge offcut, else none below the nest's new persisted
MinimumRemnantDimension. NestJobCost's salvage offcut now calls the Core
rule unchanged. NestWriter writes a derived plate expectedRemnant that
readers skip, and /sheets reports it per plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00
ajandClaude Opus 5.5 15750949dd feat(nest): plates name the ledger item each sheet is cut from
Auto Nest on MaterialLedger stock now records which inventory item each
physical sheet uses, so ML can hold exactly those items instead of matching
by size.

- Core: Plate.StockItemCodes (entry i = sheet i of Quantity; empty = uncoded).
  Core invalidates it: a resize clears it (turning the sheet keeps it), a lower
  Quantity truncates it, a nest material/grade/thickness change clears every
  plate's, ClearAll clears it and new plates never copy it.
- Desktop Auto Nest: after a kept proposal is committed, LedgerStockAssignment
  deals each ledger row's codes to its plates in Nest.Plates order (bundles
  repeat their code; clamp-removal variants use their base row). Rejected,
  discarded or cancelled proposals never reach it. NestPipelineCommit.PairWithStock
  pairs committed plates with their solved stock; the engine stays ledger-free.
- Plate list tooltip: "Stock: CODE (location)"; locations are display-only.
- nest.json: optional plate stockItemCodes, omitted when empty, read leniently.
- /api/nests/{id}/sheets: new plates[] { index, width, length, quantity,
  stockItemCodes }; sheets[] unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00
ajandClaude Opus 5.5 ea1decc097 feat(autonest): load sheet and drop stock from MaterialLedger
Auto Nest's Stock Options gets a Load from ledger button that reads the nest
material's free sheets and drops from MaterialLedger's API-key-protected
GET /api/stock/opennest. Ledger rows are request-local: the engine sees them as
ordinary NestPlateStock rows with ledger:{n} ids and finite quantities, and they
are never written to Nest.PlateOptions.

- OpenNest.Data/MaterialLedger: settings file, read-only client (X-Api-Key,
  5 s timeout, closed LedgerStockResult set) and LedgerStockMapper (bundles and
  sheets one row each, same-size drops grouped with ordered item codes, unknown
  shapes and too-small items skipped, inch/mm conversion).
- Options: MaterialLedger URL and API key (DPAPI, CurrentUser) with Test.
- Auto Nest grid: Use, Qty and Source columns for every row; PlateOption gains
  request-local Quantity and Id, which NestStockBuilder passes to the engine.
  Ledger rows score by area; hand-typed costs stay saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00
ajandClaude Opus 5.5 42f1762c56 fix(desktop): keep the cut simulator moving while the plate view pans
Panning invalidates the view on every mouse move, so a paint is always
pending and WM_TIMER never arrives; the torch froze until the pan ended.
Paints now advance the playhead from the playback clock too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:46:47 -04:00
ajandClaude Opus 5.5 455c543501 fix(verify): close contours whose cut runs past its start as a seam overcut
Converted part programs keep a source CAM's overcut: a hole's arc or a slot's
last line runs about 0.03 past the pierce along the first cut. The rapid check
only counted a contour closed when it ended exactly at its start, so every
overcut contour became an open fragment, the open-cut proof refused them, and
each part reported "Multiple interrupted/open cutting fragments" and an
unverifiable lead-out instead of being checked (P260810-02: all 50 parts).

ReleasedContourState now closes such a contour at its seam (matched at the
1e-6 seam tolerance, since a hole's two arcs carry centers ~1e-7 apart) and
lets the rapid leaving the overcut end ignore contact within that tolerance of
its departure. The nest now validates fully and reports the real crossings it
was hiding: rapids that cross the hole just cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:25:22 -04:00
ajandClaude Opus 5.5 4b982e8eeb fix(desktop): paint the utilization bar when its fill is a sliver
A one-pixel fill (about 2% on the Nesting Progress bar) asked GDI+ for a
zero-size corner arc, which threw "Parameter is not valid" mid-nest. Clamp
the corner radius to the rectangle and fall back to a plain rectangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:56:51 -04:00
ajandClaude Opus 5.5 0d369c9a51 feat(desktop): draw numeric units beside the value and accept typed units
Unit suffixes are painted in a reserved right margin instead of being part
of the edit text, so editing the number no longer fails to parse. Length
boxes also convert values typed in other units (mm, cm, m, in, ft,
fractions such as 3/8").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:36:13 -04:00
ajandClaude Opus 5.5 cbf3b728aa feat(desktop): preview the highlighted set beside the Sets list
The set preview moves from its own window into a details pane beside
the list, as the Drawing Library has, with the set's notes below it.
It reads the highlighted set shortly after the selection settles and
caches drawings per revision. Parts are drawn as outlines without fill,
and the info lines sit above the fitted drawings instead of over them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:35:29 -04:00
ajandClaude Opus 5.5 fa70713bfc fix(geometry): treat near-joint contacts as the joint when classifying slides
Drawing data can fix a fillet's center slightly off the tangent normal, so
the arc's end sits ~1e-7 past its neighbouring line's end. The slide contact
classifier only joined entities within 1e-7, so a contact at that tangent
point read as two boundaries, was classified ambiguous and blocked the push.
In N26-243 this stopped a part pushed left along another once the parts
aligned. Contacts within 1e-6 of a joint now take the joint's tangents;
the 1e-7 incidence tolerance is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:52:48 -04:00
ajandClaude Opus 5.5 ba55d5bff0 revert: drop the duplicate near-vertex hole-cut fix
Master already starts such entries exactly at the vertex (45592a81), so the
cherry-picked snap was redundant and its test's off-vertex precondition no
longer holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:24:57 -04:00
ajandClaude Opus 5.5 f600c5e58b feat(desktop): nest released nests live in the main window
With Nest after release ticked, Make Nests now minimizes the Drawing
Sets window and nests each group's built nest in its own main-window
tab, before it is saved, with Auto Nest's progress window and live plan
strip, one nest at a time in plan order. Stop there (or switching tabs)
stops the whole release, as the panel's Stop does; the layout is kept
by the same rules as the headless renest, without the Keep review.

After upload each tab is bound to its saved record instead of a second
tab opening; a tab whose nest was not saved is closed. The main window
stays in front, and the Drawing Sets window flashes its taskbar button
when a group failed or was refused or the release was not recorded.

NestRenest splits into CreateRequest and Keep so the solve runs off the
UI thread and the commit on it; AutoNestCommand's solve-with-progress
step is shared rather than copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:21:28 -04:00
ajandClaude Opus 5.5 27796851f3 feat(desktop): build releases in a panel beside the Drawing Sets lists
The modal release dialog is replaced by ReleasePanel, docked beside the
Library and Sets tabs and shared by both. A double-click or Enter adds a
set at one kit or a library drawing on its own at the entered quantity;
adding one already listed adds to its quantity. Lines are edited in
place with a remove button, and the nests it will make preview live.

Each group now merges into a matching Draft nest by default: the only
one, or the most recently saved of several (chosen in the row); a draft
open in a tab blocks Make Nests instead of making a parallel nest.
Nest after release is remembered per user. Edit Set and Edit Drawing
move to their buttons and right-click menus.

The Drawing Sets window is now an unowned top-level window with its own
taskbar entry and remembered bounds, closed with the main window; a
release not yet made survives closing it, and Make Nests brings the
main window forward. ReleaseSetForm remains as a thin dialog host for
releasing from Nest > Add from Drawing Library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:21:27 -04:00
ajandClaude Opus 5.5 d4269c1f06 refactor(cutting): merge circle lead settings into internal leads
Full circles now take InternalLeadIn/InternalLeadOut like every other
cutout; the ArcCircle lead settings and the cutting panel's Arc / Circle
tab are removed. Circle-specific geometry handling (radius clamp, angle
rounding, pierce zone) is unchanged.

Older nest files and desktop settings still load: their arcCircleLeadIn/
arcCircleLeadOut keys are ignored and no longer written, so re-planned
circles take the internal leads. Saved part programs are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:20:09 -04:00
ajandClaude Opus 5.5 8cd9b2cbe3 fix(planning): close hole cuts whose entry lands a hair off a vertex
A closest-point entry within Tolerance.Epsilon of a line or arc vertex made
ReindexAt start the contour at the vertex while the lead-in still ended at
the off-vertex point, so the cut closed short of its start and replay refused
the whole plate. Snap such entries onto the vertex before emitting the lead-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:20:09 -04:00
ajandClaude Opus 5.5 b341848e07 feat(desktop): preview a drawing set's drawings together at one scale
Preview on the Sets tab (button and right-click menu) downloads each
member's current revision and packs them into one view, labeled with
name and quantity, so part sizes compare at a glance. PlateView gains
DrawPartNumbers (default on) so the preview can hide sequence numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:12:58 -04:00
ajandClaude Opus 5.5 33f60d3dcb feat(engine): break equal-cost ties toward the larger free offcut
Without salvage credit (desktop Auto Nest on a single plate size) every
complete one-sheet layout costs the same, so engines kept whichever layout
came first. N26-23S nested 27.86" long when turning two parts fits it in
21.3".

NestJobCost.CompactnessOffcut measures the largest full-width/full-length
edge offcut as salvage does, whatever the salvage rate. Default and Circles
(CandidateScore), Rectangles and Circles sheet choice (SheetByStock) and
Irregular sheet and plan ranking use it only after cost, so no cost changes.
Sheet-level keys round to 1e-6 to keep ties platform-independent.

Rectangles layout digests are re-captured: each changed fixture keeps its
sheets, parts and cost and leaves a larger offcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:18:57 -04:00
ajandClaude Opus 5.5 ad3443f60d fix(benchmark): carry each nest's clamp rail into benchmark jobs
The benchmark built stock from spacing and quadrant only, so engines were
scored on clamp-free sheets and baselines were never checked against the
clamps they were made with. Stock now comes from NestStockBuilder.FromTemplate,
as in desktop Auto Nest: the source plate's rail is resolved for every
candidate size (explicit zones are offered unchanged), and baseline plates
keep their rail and removed clamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:18:56 -04:00
ajandClaude Opus 5.5 ebecd42433 feat(desktop): size grid columns to their first content, then leave them to the user
Grid columns no longer stretch to fill the control. GridStyle sizes each
column once to its header and the first rows shown (capped at 400 px),
then the widths are the user's to resize.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:31:27 -04:00
ajandClaude Opus 5.5 3f5d402489 feat(desktop): open the Drawing Sets window as a maximized MDI child
File > Drawing Library, Drawing Sets, Releases and database-mode Import
BOM now open the window inside the main form, maximized, instead of as a
modal dialog. A second menu click activates the open window on the tab
asked for. Close now closes the window, since a DialogResult button no
longer does outside a modal dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:09:46 -04:00
ajandClaude Sonnet 5.5 a6056a6065 feat(desktop): Drawing Library is a tab of the Drawing Sets window
The drawing library browser becomes DrawingLibraryView, a control. The Drawing
Sets window hosts it as its Library tab beside Sets and Releases, and File >
Drawing Library opens the window on it. A library drawing's Releases item
switches to the Releases tab filtered to it. Nest > Add from Drawing Library
stays a dialog, now a thin wrapper around the same view.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:54:54 -04:00
ajandClaude Opus 5.5 dfa6ccb954 fix(desktop): give each UI thread its own plate-drawing pens and brushes
ColorScheme.Default and LayoutPart kept single static pens, brushes and a
font that every plate view and drawing preview painted with. GDI+ objects
are not thread-safe, so two views painting at once on different UI threads
(parallel WinForms test collections) failed in PlateRenderer.DrawPlate with
"Object is currently in use elsewhere" and crashed the test host.

The scheme now builds its pens and brushes per thread and rebuilds them
after a color change; LayoutPart's selection, lead-in and label objects are
thread-static. A regression test paints previews on four threads at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:14 -04:00
ajandClaude Opus 5.5 87672543cd feat(desktop): import a BOM into a drawing set
Drawing Sets > More > Import BOM reads a BOM and its drawing folder,
matches each drawing to the library by its D4 identity and geometry
(reuse, new revision, or new drawing) and opens the set editor with a
new set prefilled with the BOM quantities. Reading only queries the
library; Create Set lists the uploads and revisions and asks before
storing them, and the set is stored only by the editor's Save.

Parts with no material or thickness still import and are flagged for
Edit Drawing. DrawingPackage.GeometryKey compares geometry independent
of the fresh entity ids each DXF import assigns. In Database mode File >
Import BOM opens this flow instead of creating nests directly; File mode
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 a9198e5c25 feat(desktop): New, Edit and Make Nests lead the Sets tab; the rest goes in More
The set browser's six equal buttons become New Set..., Edit..., More and a
bold Make Nests.... Archive/Restore, History... and Releases... move into a
menu that the More button drops down and that is also the set list's
right-click menu (menu key and Shift+F10 open it; right-click selects the
row first). Access keys: &New Set, &Edit, M&ore, &Make Nests; the menu has
&Archive/&Restore, &History, Re&leases. Enabled rules are unchanged; More is
enabled while any of its items is. The picker hides More and has no menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 6a556b75ab feat(desktop): slim the set editor to drawing, material, quantity and note
The member grid shows one Material column (name, grade, thickness and
units, such as "Steel A36 0.25 in"), Qty and Note; Cut type appears only
when the set mixes cut types. Move Up/Down are gone: member order only
breaks ties (materials are grouped and sorted on release, nests list
drawings by name), and the stored order is kept as it was. The dialog
opens at 1040 px instead of 1264, with the library pane unchanged and
still resizable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 b376d217dd feat(desktop): one Drawing Sets window with Sets and Releases tabs
The drawing sets window now has two tabs. Releases hosts the release list
(paging, nests, Undo Release, New Release) as a SetReleasesView control that
replaces SetReleasesForm. File > Releases stays as a shortcut that opens the
window on that tab. A set's Releases (Sets tab) and a library drawing's
Releases (Drawing Library) open the tab filtered to it, with the filter shown
above the list and Clear Filter to list every release. Releases re-reads its
page whenever the tab is shown again, so a release made on Sets appears.

The set editor gets History... for a saved set; the Sets tab keeps its
History button until the browser's buttons are rearranged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 49bb7389f7 feat(desktop): preview the release and fold its options away
The release dialog now reads as a sentence ("Release set 'X' ×N") and
previews what Make Nests will do ("Creates 2 nests: ..."), with refused
materials counted and Fix Drawings reachable without selecting them.
An Options toggle holds the Release ticks, the Nest column and Include
archived drawings; collapsed, an option stays shown while it differs
from its default, and the Nest column shows whenever a Draft nest could
be merged into. Defaults, release, undo and retry are unchanged. The
Status column wraps so refusals are not cut off, and the retry status
reads "... Try Again records it; the nests are not made again."

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:13 -04:00
ajandClaude Opus 5.5 ee208b2766 feat(desktop): say "set" and "quantity" in the set and release dialogs
Wording only, no behavior change: "kit" becomes "set" or "Quantity",
the release button reads Make Nests... (Try Again after a failed
record), the releases list shows Material and Action, and the release
dialog's status lines are shorter. Help in docs/nest-storage.md follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:12 -04:00
ajandClaude Opus 5.5 0d1dade063 feat(desktop): open Edit Drawing from everywhere and warn before duplicates
Builds on the library's Edit Drawing (details as a new revision):
- warns before saving details that would duplicate another active library
  drawing (D4), naming them; a failed lookup also asks;
- File > Drawing Library, a nest-less mode of the library browser that lists
  every active drawing and edits on double-click;
- the set editor's member grid (double-click, right-click, button);
- the release dialog: Fix Drawings on a group refused for missing material or
  thickness or duplicate names, so those refusals are no longer a dead end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:12 -04:00
ajandClaude Opus 5.5 48863d51c8 feat(support): add slat support beds alongside the grid
Slats are strips of a set thickness running the full length or width of
the table, one every pitch. A part's contacts are the corners of where its
material crosses a slat, so the support check and support tabs work as on
the grid. Nest > Support Bed... picks grid or slats; nest files store
type "slats" and support-grid.json keeps both layouts' values. Draw
Support shows the slats cut off to the sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:12 -04:00
ajandClaude Opus 5.5 cd1961e26b fix(desktop): keep parts muted when Sequence by Path is restarted
Starting the tool while it was open built the new action before the old
one was disconnected, and the old one's teardown cleared the new muting.
The outgoing action now leaves the view alone once a successor owns it,
and opening the tool repaints the plate right away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:33:12 -04:00
aj 414479fc4e feat(library): edit a library drawing's details as a new revision
Edit Settings becomes Edit Drawing: name, customer, material, grade,
thickness and cut type are editable beside the nesting settings and
notes. Units stay read-only. The save is a metadata revision with the
same package, so nests keep the revision they hold, and the release
refusal for drawings without material or thickness now has a fix.
2026-10-24 10:02:23 -04:00
aj bc21382c5d fix(io): give re-imported unchanged DXFs the same entity ids
Entity ids came from a process-random sequence, so importing the same
DXF twice produced different source-entity ids and therefore a
different drawing library package (different SHA-256), and an importer
could not tell an unchanged file from a changed one.

CadImporter.Import now derives each entity id from the file's SHA-256
and the entity's position in the import. An unchanged file gives the
same ids and the same package bytes; any change to the file gives
entirely new ids, so suppression, roles and edge selections keyed by id
never move to a different entity.
2026-10-24 09:12:48 -04:00
ajandClaude Opus 5.5 ba2102c793 build(help): ship help v0.3.0 with the CAD Converter, Auto Nest, Plan the cutting and Check a nest guides
Auto Nest and the CAD Converter open these pages from F1 instead of the
short first-nest sections, and cutting planning and nest checks get their
first help pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:40:07 -04:00
ajandClaude Opus 5.5 4b312a552d build(help): ship help v0.2.0 with the Machines page
v0.2.0 adds the Machines page (nest machine, default machine, per-machine
support grid, machine lead-ins) and the machines, nest-machine and
support-grid topic IDs. desktop-help.md now points at the help repo's
version rules so the next release is numbered the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:54:55 -04:00
ajandClaude Opus 5.5 a3caff6e40 feat(status-bar): show the nest's machine and change it from the label
The assigned machine now drives the support grid, lead-ins and cut-offs, so
it should be visible without opening a menu. The status bar shows
"Machine: <name>" beside the material; left- or right-clicking it lists the
machines (the same list as Nest > Machine) above the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:40:07 -04:00
ajandClaude Opus 5.5 6b63d41030 feat(cutting): start plates from the assigned machine's leads and cut-off settings
Machine configurations already held a lead-in, lead-out, kerf and cut-off per
material thickness, but nothing read them: Plan Cutting and Place Lead-in
started from whatever was last applied (often another machine's or another
thickness's leads), and cut-offs used fixed view defaults. Now that nests carry
a machine, those values can be the starting point.

- MachineCuttingDefaults maps a machine's thickness settings for the nest's
  material/grade/thickness onto CuttingParameters and CutOffSettings,
  converting units. Clean Hole stays on holes; Line + Line is not mapped
  because the configuration lacks the second segment.
- Plates without their own parameters start from the last-applied ones with
  the machine's leads and kerf laid over them; plates with parameters keep them.
- The plate view's cut-off clearance, overtravel, minimum segment and direction
  follow the machine on open, on Nest > Machine and after Nest > Edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:27:58 -04:00
ajandClaude Opus 5.5 9c78b4ebec feat(machines): assign each nest a machine and keep the support grid per machine
The support grid was one shared support-grid.json, off by default, and a nest
had no machine at all, so a plasma table and a fiber laser could not have
different grids, and nothing could pick machine-specific behavior later
(lead-ins differ between laser and plasma; tabbing depends on the grid).

- Nest.Machine (id + name) persists as top-level `machine` in nest.json;
  malformed values load as none.
- MachineConfig.SupportGrid holds each machine's grid; a machine without one
  still falls back to the legacy support-grid.json.
- New and BOM import assign the Tools > Nest Defaults machine (first by name
  when none is chosen) and give the nest that machine's grid.
- Nest > Machine reassigns a nest and swaps in the new machine's grid;
  Nest > Support Grid saves machine defaults to the nest's machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:17:28 -04:00
ajandClaude Opus 5.5 993555dba6 feat(cutoffs): head-locked overcut at the far edge, reversible by Plan Cutting
Stock can arrive up to 1.5 in wider than nominal, so only the origin-side
edge is certain. A cut-off with a head-locked overcut cuts past the nominal
far edge with the head height locked, covering the oversize. With that lock,
Plan Cutting may start the far-edge segment at the far edge: overcut outward,
rapid back still locked, unlock, cut toward the origin. Without it the
direction stays fixed, as decided in Project Memory a20ea455.

- CutOff.HeadLockOvercut / FarEdgeFirst, persisted in .nest; HEAD HEIGHT
  LOCK/UNLOCK comment markers; far edge is quadrant-aware.
- Planner offers the reversed far-edge segment in the joint search, the
  safest route and segment insertion; Apply records the direction.
- Cincinnati CL and CI Fiber posts gain lock/unlock code settings and refuse
  head-locked cut-offs while either is empty.
- Automatic cut-off dialog setting (off by default, 1.5 in), kept between
  sessions and used for new cut-offs.

Lock codes and machine behaviour with the head locked are unconfirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:51:59 -04:00
ajandClaude Opus 5.5 13352b8acd build(desktop): install the pinned help into dev build output
Only the packaging scripts installed Help\, so F1 and the Help menu in a
dev build always said help was not installed. The build now downloads the
help.json release once into obj\ and copies it beside OpenNest.exe; CI and
publish output are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:58:03 -04:00
ajandClaude Opus 5.5 7ce5e73a82 refactor: share duplicated paging, HTTP and helper code
Roslyn duplicate scans found the same code copied across layers:
- Data: the three browse sessions share BrowseSession<TPage, TSort>;
  the remote clients share NestServerHttp (JSON options, If-Match,
  error text).
- WinForms: five paged lists share BrowsePager for loading text,
  navigation, sort glyphs and failure messages. The Add Drawings to
  Release, Drawing Library and set browser dialogs now dispose their
  browse sessions.
- Server: NestQueryRequest reuses DrawingLibraryRequest's helpers;
  revision history queries share count and paging binds.
- Engine: RemnantPlateNester delegates to DefaultPlateNester;
  CuttingPlanService reuses JointCuttingPlanSearch.Map.
- Core: LineArc and CleanHole lead-ins share LineArcApproach with
  unchanged arithmetic order, so output is bit-identical.
- IO: Dxf.GetGeometry overloads share one reader; .nest JSON test
  helpers move to NestArchiveJson.

Also fixes the production compiler warnings (CS8632, CS8603, CS0108)
and moves a test off an obsolete forwarding API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:45:36 -04:00
ajandClaude Opus 5.5 a00e2c3233 chore(server): add a script that redeploys a development build over SSH
No verified server image has been published yet, so the shop server runs
development builds. Building on the host meant cloning there and hand-editing
compose; the script builds with the local Docker engine, ships the image,
backs up the data volume and waits for the health check, printing what a
rollback across a schema upgrade needs. docs/nest-storage.md describes it
beside the Compose deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:44:31 -04:00
aj 0785bb84c9 fix(planning): learn a door's hole order when a hop crosses its own cut holes
Rapids may not cross holes already cut. On an instrument panel, mounting
holes either side of a cutout become unreachable from each other once
the cutout is cut, so the holes must come first. The contour-by-contour
search backtracks chronologically through every entry combination of
the last holes before it changes their order, and ran out of budget.
Supplied-blank doors fell to an unverified safest route.

Three changes:
- Contour recovery, the part solver that memoizes failed states, now
  runs for display-perimeter parts too: their holes are the whole part,
  with no outside cut to finish on.
- A door alone on its sheet gets the recovery pass first. Any other
  lone part keeps the plain search, whose routes are shorter.
- Recovery learns, as whole-part ordering does: a hop onto a hole that
  crosses holes of the chain teaches "cut it before them" (skipping any
  rule that closes a cycle) and restarts the chain, with holes still
  waiting for a prerequisite ranked last. Rules only order candidates;
  no hole is skipped. Until a rule is learned the chain is unchanged.
2026-10-23 07:43:39 -04:00
aj 45592a811c fix(planning): start an entry beside a contour vertex exactly at the vertex
Emission splits a contour at its entry with Arc/Line.SplitAt, which treat
a point within Tolerance.Epsilon (1e-5) of an endpoint as that endpoint
and start the contour there. The lead-in still ended at the entry point
itself, so an entry 1e-8..1e-5 from a vertex left that span uncut and the
plan's own verification refused it ("does not preserve complete directed
contours").

Imported PEP mounting holes hit this: two half circles whose stored centre
sits 5e-8 beside the chord's midpoint, so the closest entry to an approach
from straight below lands 5e-8 along the first arc from its start. Panel
doors were refused on every plate holding such a hole.

PreparedContours now snaps an entry 1e-8..1e-5 from its entity's start or
end to the exact vertex, for automatic entries, scrap pierce entries and
drawing pins alike, so the lead-in ends where the contour starts. Entries
already within 1e-8 are left as they were, so saved pinned programs
re-emit unchanged. Entries de-duplicate on the snapped point, and
near-corner insets keep the snap distance from both edge ends so none can
snap onto a reflex vertex.
2026-10-23 06:54:23 -04:00
ajandClaude Opus 5.5 39f28c7fa9 feat(server): report a nest's sheet sizes and part counts
GET /api/nests/{id}/sheets reads the stored archive's nest.json and returns
the sheet sizes it needs (grouped, width the shorter side, quantities summed)
and each drawing's required and nested counts, skipping the trailing empty
plate as plateCount does. MaterialLedger builds material holds from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 06:01:43 -04:00
ajandClaude Opus 5.5 07e7d7edcd perf(planning): plan parts with thousands of holes in seconds
A part with about 1,700 holes (P241017-01) never planned: material
capture paired every boundary with every other one and lead checks
scanned every boundary per lead, so both hit the 1M native query limit
and the plate fell back to an unverified best-effort plan.

- Material capture sweeps boundary extents along X and queries only
  pairs the lead checks' per-pair clearance cannot skip, in the full
  scan's order.
- Material with 64+ boundaries keeps a grid of boundary extents. Lead
  checks, contour-joint matching and replay's contour accounting query
  only nearby boundaries; ray parity reads only holes on the point's row.
- Only native queries that run are charged to the budget; skipped far
  material no longer costs its full boundary count.
- The search looks up chosen holes and route ranks instead of scanning.

With that the plate plans Ready, but in 189 s: every step re-emitted
and re-checked all holes already cut. On parts with 64+ contours a
middle hole step now checks only the contour it adds, continuing the
completed-contour state its prefix left (ReleasedContourState.Continue).
It falls back to the full prefix whenever it cannot continue exactly,
and the last contour still checks the whole part from before it.
Isolated contour emission clones only the chosen contour.

P241017-01 now plans Ready in 5.4 s with an identical route, expansions
and rapid travel. Core and engine suites pass; the problem-nest corpus
matches the unchanged commit's outcomes and expansions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 06:01:43 -04:00
aj bbadd6aa75 fix(cut-offs): accept a blank's outline a rounding step past the sheet
Automatic cut-offs refused any part reaching past the physical sheet by
more than Tolerance.Epsilon. Imported supplied blanks (panel doors,
remnants) equal their sheet but carry PEP's rounding: a 0.000006 outline
skew plus placements rounded to 0.001 put the never-cut outline 0.00001
to 0.00003 in past the sheet, so Plan Cutting refused every such nest.

A display perimeter may now extend up to DisplayPerimeterSheetAllowance
(0.0001 model units) past the sheet. Nothing cuts it, and a blank that
fills its sheet gets no cut-offs. Everything else on the part keeps the
ordinary tolerance: cuts, leads, scribes and travel, except a rapid that
ends on the outline itself (the move to its first corner, or an applied
program's display tail). A larger overhang is still refused.
2026-10-23 05:55:32 -04:00
ajandClaude Opus 5.5 c188014a30 fix(ci): expect a blank material grade on migrated legacy nests
Server schema v8 adds materialGrade, so the migrated legacy records now
report it and the server-image smoke's manifest comparison failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:17:17 -04:00
ajandClaude Opus 5.5 069efc2368 build(help): pin opennest-help v0.1.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:08:18 -04:00
ajandClaude Opus 5.5 ed92c37845 feat(desktop): add WebView2 help window with F1 topics and pinned help packaging
Help > OpenNest Help (F1) and context F1 in Auto Nest and the CAD Converter open
the bundled opennest-help site in a single WebView2 window on its own UI thread.
Both packaging scripts install the opennest-help release pinned in
scripts/help.json, verified by SHA-256. The missing lead-in finding now says
the contour is missing lead-in geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:08:18 -04:00
aj 00cb639c8e fix(posts): refuse unpostable blanks before writing and malformed tails
With the pre-post risks acknowledged, an unplanned blank reached the
Cincinnati and engraver posts, which created the output file before
refusing: an existing program at that path was emptied and the desktop
had no handler for the refusal. Both posts now check every part before
creating the file, and Post shows the refusal like the multi-file path.

A display tail marker is now honoured only as one marker ending the
part's own program, followed by a rapid and the outline alone. A marker
inside a sub-program or followed by real cuts is refused by posts and by
executed reads, so cuts after it can never be dropped from both
verification and output.
2026-10-22 20:30:33 -04:00
aj 9e4361fd11 fix(planning): refuse cutouts that cross a display perimeter
Enclosure was tested at one point per entity, so a round cutout starting
on the blank but running past its edge, or a cutout whose corners all sit
on an L-shaped blank while an edge crosses its notch, reached an
unverified plan that warning acceptance could apply, cutting into or past
the never-cut edge. Every cut is now sampled along its arcs inside the
outline and must not cross or touch it, and the material capture names the
display perimeter when a cut crosses it.
2026-10-22 20:30:33 -04:00
aj 3c3bab0a9b test(planning): plan a blank with support tabs and a pierce zone
A support bed that cannot hold a blank level and a pierce zone both steer perimeter-led planning paths. A blank has no cut perimeter, so both settings must still plan only its cutouts, with every cut inside them.
2026-10-22 20:18:47 -04:00
aj 6201065034 fix(posts): never post a display perimeter
The posts treat every non-lead feed move as a cut, so an applied blank's
display tail, or the outline of a blank posted without a plan, would have
been cut along the blank's edge. The pre-post review counted that outline
as an ordinary cutting contour.

Cincinnati, CI Fiber and the engraver now read part programs through
DisplayPerimeterTail.ForPosting: an applied program posts exactly what the
same program posts with its tail removed by hand, and an unplanned display
perimeter is refused with a message that names the part and points to Plan
Cutting. The pre-post review reports the unplanned outline by name.
2026-10-22 20:15:58 -04:00
aj 684207039a fix(leads): keep manual lead-ins off a display perimeter
Place Lead-in built its contours from cut-layer entities only, so on a
blank the largest cutout looked like the outside and took an external
lead pierced on the blank; committing then regenerated the part with the
outline as a cut perimeter. The legacy whole-part strategy did the same.

Both strategies now refuse a program with a display perimeter and leave
the part unchanged, and Place Lead-in explains that such a part's cutouts
get their leads from Plan Cutting instead of selecting it.
2026-10-22 20:09:23 -04:00
aj 8f568482bc fix(planning): keep a blank's outline on its applied program
An applied cutting program for a display-perimeter blank held only its
cutouts, so the part shrank to them: a 30 x 20 door reported an 11.5 x 20
extent, overlap and the plate view lost the blank, and running automatic
cut-offs afterwards added lines straight through it.

A complete emission now ends with a display tail: a marker comment, a
rapid to the outline and the outline on the display perimeter layer. The
part keeps the blank's bounds and material, so later cut-offs still have
nothing to cut and the door plans again as before. Nothing executes the
tail: executed reads stop at the marker, so search, replay and the
pre-post review see only the cuts, and cut time, the rapid display and the
cut simulation leave it out. DisplayPerimeterTail.ForPosting gives posts
the program without it and refuses an unplanned display perimeter.
2026-10-22 20:06:15 -04:00
aj 73e54fbd67 fix(planning): refuse misplaced display perimeters by name
Review found three blanks that slipped past or failed silently. Enclosure
was checked against the outline's bounding box, so a cutout in the notch of
an L-shaped blank reached an unverified plan that warning acceptance could
apply; every cut point is now tested inside the outline itself, and the
material capture names the display perimeter when no ring encloses the
rest. A blank whose only cutouts are tabbed left the search with nothing
to choose and ended without a finding; it is now refused with its reason,
as is a blank with nothing to cut, which used to get only the generic
noncutting message. None of these offers even an unverified plan.

Clean-bounds containment, seam overcut trimming and incremental closure
drift now treat the display perimeter like the other material boundaries.
2026-10-22 19:56:14 -04:00
aj b45e9df593 feat(io): save and reload display perimeters
A display perimeter written without its own tag reloaded as an ordinary
cut, so a saved blank would come back with its outline to be cut. Programs
now carry it as :DISPLAYPERIMETER and the reader maps it back, and stored
source entities keep the DISPLAY PERIMETER layer so editing the drawing
rebuilds the same never-cut outline.

Builds older than this read the unknown tag as a cut. Open nests with
display perimeters only in builds that include this change.
2026-10-22 19:34:52 -04:00
aj 6e3705eb89 feat(planning): cut holes inside a display perimeter that is never cut
Some jobs cut only cutouts into a supplied blank, such as instrument holes
in a purchased door that is the whole sheet. Its outline has to be known
to the planner (the blank is material that leads, rapids and cut-offs must
respect) but must never be pierced, led or cut.

A new DisplayPerimeter layer marks that outline. It is material, so
nesting, containment, lead clearance and automatic cut-offs all see the
blank; a door that fills its sheet leaves cut-offs nothing to cut. Cutting
preparation keeps it out of the contours entirely: with no cut perimeter
every contour is a hole with internal leads, and the search, best-effort
and safest-route paths stop offering a perimeter. Replay refuses any run
along the display perimeter and completes once every other ring is cut.

A display perimeter must be one whole closed contour enclosing every other
contour; a mixed ring, one inside another contour, or a second one is
refused with that reason. Saving, drawing and posting it are still to do.
2026-10-22 19:32:22 -04:00
ajandClaude Fable 5.1 c325a1745a fix(desktop): list the first sets in Add a set before anything is typed
The release dialog's Add a set box was a pure type-ahead: it queried the
server only after typing paused, so opening it, or clicking its arrow with
nothing typed, showed an empty list. From Drawing Sets > Release that was
easy to miss, but New Release starts with nothing listed, and an empty
dropdown there reads as a missing server connection. The dialog now reads
the first active sets by name when it is shown, and again when the list is
opened while still empty, without dropping the list open on its own; typing
narrows it as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 14:50:07 -04:00
ajandClaude Fable 5.1 dc4fc1d593 feat(desktop): list every release and start new ones from File > Releases
Releases could only be reached through one set or one library drawing, so
seeing what the shop released lately meant knowing which set to open first.
File > Releases shows every release on the server, newest first, naming what
each one had ("Kit A ×2, Kit B; Bracket ×4") in place of the per-set
revision and kits, with the same nest list and Undo Release. New Release
opens the release dialog empty, so a release can start from the list rather
than from a set; the plan builder now accepts no lines as an empty plan,
and the dialog's Release button stays disabled until a group is listed.
The list re-reads from the newest page once the dialog records a release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 13:27:20 -04:00
ajandClaude Opus 5.5 d4903a648c feat(desktop): pick sheet gauges and fractions from thickness dropdowns
Operators had to know that 16 ga is 0.0598 to enter a thickness. The Nest
Info and Save to Drawing Library thickness boxes now list steel gauges
(26-3 ga) and plate fractions (1/16"-2") with their decimals, and read a
typed gauge ("16ga", "#16"), any fraction ("3/32", "1-1/4") or a decimal.
Unreadable text blocks OK/Save instead of reading as zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:04:55 -04:00
ajandClaude Opus 5.5 9d69c89b21 fix(desktop): keep placement previews selected when Delete is pressed
Delete during placement went to the plate view's delete-selected path, which
treated the unplaced previews as selected parts and deselected them, so they
drew solid like placed parts. Route Delete to the clone action instead (its
own handler, which removes the originals when cloning placed parts, never ran)
and re-flag the previews as selected whenever they are reselected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:26:16 -04:00
ajandClaude Opus 5.5 939d7bfff2 feat(desktop): adjust nest quantities from the drawing library dialog
After adding library drawings the operator had to close the dialog to see what
the nest now needed, and fixing a quantity meant leaving it for Edit Drawing
one part at a time. The new In This Nest tab lists the nest's drawings (cutoffs
excluded) with their library revision and required, nested and remaining
quantities, refreshes after each add, and edits Required in place.

An edit applies to the nest immediately, like an add, and the nest window
refreshes its drawing list when the dialog closes. Out-of-range values are
refused both when the cell is validated and when they are applied, because
DataGridView only validates on leaving the cell and EndEdit commits without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:57:30 -04:00
ajandClaude Opus 5.5 8fd1fabc54 feat(desktop): add library drawings on their own to a release
The Release dialog lists sets and drawings in one grid (with a type
column; Kits / Qty is a set's kits or a drawing's quantity, up to
100,000). Add Drawings... browses the library as the set editor's pane
does and stays open for several adds; listed drawings are greyed and
skipped, archived ones refused. The Drawings tab gains Release... (when
opened from a nest) to start a release from one drawing, and
Releases... so a release of drawings alone can still be found and
undone, since it belongs to no set's history.

docs/nest-storage.md describes the desktop flow, the `drawings` wire
field, null item setId, the drawingId query/probe and schema v11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:55:40 -04:00
ajandClaude Opus 5.5 54b15cff19 feat(releases): release library drawings on their own beside sets
Operators want to release a few loose drawings without first building a
set for them. The release record gains a `drawings` list (drawingId,
quantity, joined drawingName) and nest items with no `setId` carry those
drawings' quantities, so grouping, merging and undo stay one path.

Schema v11 adds release_drawings and rebuilds release_nest_items with a
nullable setId (SQLite cannot drop NOT NULL), keeping every row. The
query gains `drawingId=`; a server from before this refuses that unknown
parameter with 400, which the client turns into ServerTooOldException.
SetReleaseSession asks before building anything, because the release is
posted only after its nests are saved and an old server would refuse it
then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:54:30 -04:00
ajandClaude Opus 5.5 f657292801 docs(readme): point to Releases and invite source builds
The old note steered readers away from the source archives, but building
from source is a supported way to run OpenNest and should be encouraged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:49:34 -04:00
ajandClaude Fable 5.1 8f27eac2ca feat(cutting): plan each scrap cut-off segment where the route passes it
Plates with staged automatic scrap cut-offs could not be planned: P260810-01
plate 1 ran out of budget even at 10x effort, while the same plate plans
Ready in 3.6k expansions without the lines. A cut-off was one fixed program
cut whole before every part its line passes through, which dragged the tour
to the middle of the sheet and left the head at the far edge of the line;
and cutting every line first (the rejected alternative) weakens the sheet
and breaks the containment operators rely on when they fast-forward a
changed program.

A cut-off stays one definition, but Plate.RegenerateCutOffs now makes one
part per trimmed segment, each with its own place in the cut sequence.
CutOffSegments gives each segment the span of the nominal line it stands
for (end of the segment before it to start of the one after), and the
dependency rules of the planner and Part Sequencing use that span, so a
segment precedes only the parts beside it. The free-order search plans the
parts as if the segments were not there, so adding lines no longer changes
a plate's route, then CutOffInsertion slides each segment into the latest
gap before its first part where the rapid into its pierce and the rapid
from its end cross nothing cut before; only when one fits nowhere is the
plate searched with the segments among the parts. The search looks through
segments wherever they sit (entries face the next part, hole routes rank
from the pre-segment position, rapids are still checked from the segment's
end), so an applied plan replanned with the order kept replays Ready.
Segments saved out of consecutive order are recorded per segment in the
.nest file (`segments`); older readers load them consecutively.

P260810-01 plate 1 now plans Ready in 3639 expansions with every check
passing; rapid travel 1836 against 1547 without the lines. Plates without
cut-offs keep their PlanningCharacterizationTests digests, and the
problem-nest corpus has no plate below its baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 11:38:38 -04:00
ajandClaude Opus 5.5 ff46de69df feat(planning): offer the safest route when the strict search runs out
Cut planning should rank routes safest first, then fastest, but a plate whose
strict search hit its expansion budget was simply refused ("no plan found
within the search limit"), even when a route with a few reviewable rapid
crossings existed. Scrap cut-offs make this common: their trimmed segments
can wall off a column of parts, and rapids are straight.

When both the free-order and current-order searches stop at the limit, the
batch now walks a greedy safest route from each input, replays it with fresh
checks, and keeps the one with fewer violations (material first, then rapid
crossings, then the rest), then less rapid travel. A route that passes every
check is an ordinary Ready plan; otherwise it is an unverified BestEffort
that lists every violation and applies only with explicit acceptance. Routes
with any motion into material are never offered, so the strict refusal and
its lead-in advice stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:17 -04:00
ajandClaude Opus 5.5 1fc36e9e54 feat(desktop): open drawing sets from File without a nest
Releasing a set creates new nests or merges into draft ones, so tying the
set browser to the Drawing Library dialog (which needs an open nest) put it
behind a document it never uses. File > Drawing Sets... (Database mode)
now opens a standalone window with the set browser and Release, opening
released nests in their own tabs. The library dialog loses its Sets tab
but keeps Used in Sets and Add to Set on a drawing's menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:04 -04:00
ajandClaude Opus 5.5 099a020424 feat(desktop): preview the selected drawing in the drawing library
The library browser showed only a grid and a notes box, so picking the right
part meant reading names and guessing at the shape, and the add controls sat in
a crowded row with the pager. The Drawings tab now splits into the list (with
its pager underneath) and a details pane: a geometry preview, the drawing's
name, customer, material, thickness, cutting and revision, its notes, and the
quantity with a prominent Add to Nest button.

The preview downloads the listed revision's package after the selection settles
for 150 ms, shares a request already loading that revision, and caches drawings
per revision for the dialog's lifetime. It colors the drawing from the record's
settings without advancing the nest's part palette. It is best-effort: any
failure shows "Preview unavailable" and the dialog keeps working, and the UI
update is marshalled explicitly because an STA thread without a WinForms
synchronization context resumes the await on the thread pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:03 -04:00
ajandClaude Opus 5.5 c58ae6f9e1 fix(desktop): keep the plate list selection in sync with the header arrows
Navigating plates from the plate view header (or Ctrl+Left/Right) changed
the plate without updating the plate list, so its highlighted row pointed
at a different plate. Select, focus and scroll to the current plate's row
whenever the current plate changes, guarded so the list doesn't reload it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:03 -04:00
ajandClaude Opus 5.5 63771b0029 feat(desktop): open the drawing and shape libraries at the Open dialog's size
The library dialogs opened small and centered on the main window while File > Open
fills 90% of the owner's screen, so browsing them felt cramped next to it. The
screen-fit sizing moves into a shared DialogBounds helper that all three use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:37:02 -04:00
aj 09d58ce064 feat(posts): map Carbon Steel to the CI Fiber MSN material code
Steel is "Carbon Steel" in OpenNest, matching the Cincinnati post's cut
libraries. The CI Fiber material-code table only listed "Mild Steel", so a
Carbon Steel nest reached MSN only by falling through to the default code.
List "Carbon Steel" explicitly in the shipped JSON and the built-in
default, and keep "Mild Steel" for nests that already use it.

Tests check both names against the shipped config and the built-in
default; the Carbon Steel rows failed before this change. Posted output
is unchanged, since the default code was already MSN.
2026-10-21 18:36:35 -04:00
ajandClaude Opus 5.5 64eca9775f feat(sequencing): mute parts until a line or click sequences them in Sequence by Path
With every part drawn the same, it was hard to see which parts the drawn
lines had not reached yet. Parts no line reaches are now drawn muted, and
update live while dragging. Clicking a part adds it as a one-point line, so
a stray part can be picked without drawing a line to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:50:12 -04:00
ajandClaude Opus 5.5 d1c77b8ec7 fix(sequencing): cut parts no drawn line crosses last in Sequence by Path
Every part was claimed by its nearest line even when the line never reached
it, so a part beyond a line's start projected to distance 0 along it and was
cut near the front. Parts now belong only to lines crossing their bounding
box; uncrossed parts follow all the others, ordered by their nearest line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:50:11 -04:00
ajandClaude Opus 5.5 35359906fe feat(fill): add tilted parts beside a layout whose leftover strip is too narrow upright
Fill only tried 0 and 90 degrees for parts classified as rectangles, so a long
part filling a 60 x 120 plate four high at 90 degrees left a 55 x 59 strip empty
although two more fit tilted about 33 degrees (a reported nest: 4 placed, 6 fit).
The second tilted part only fits with its corner tucked beside the grid, in the
strip the grid's slack leaves, so a plain rectangular remnant fill finds one.

TiltedRemnant runs after the other strategies. It moves the best layout flush to
one end so leftover room becomes one remnant, and to either end of the other axis
so its slack forms one strip; then tiles the part at the most upright angles that
fit the remnant's height and keeps only copies at least the part spacing from the
layout. It is skipped when the part fits the remnant upright, for circles, for an
explicit rotation range and once the requested quantity is placed.

Fill benchmark (--parallel 1): the synthetic set is unchanged (all valid, same
cost and plates). The reported part needs 1 sheet instead of 2 for 6 parts and
2 instead of 3 for 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:49:59 -04:00
ajandClaude Opus 5.5 5ecdc98581 feat(server): add a company material list with list/add endpoints
Nests, drawings and releases store material and grade as free text, so
there was no canonical list for clients to suggest from or to fix
spelling against. Schema v10 adds materials and material_grades keyed by
SharedListNames.Key, with GET/POST /api/materials. Adding is idempotent
by key and keeps the first spelling.

The tables start empty: the materials already stored did not import
cleanly, so the list is filled through the API instead. Existing
free-text columns are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:32:41 -04:00
ajandClaude Opus 5.5 3ca7d6185f feat(desktop): nest released drawing sets with Auto Nest before saving
A release now ends in nests ready to cut rather than empty drafts, and a
merge no longer leaves the draft it cleared waiting for someone to run Auto
Nest. "Nest after release" (on by default) runs Auto Nest headless on every
released group, new or merged, before anything is uploaded.

NestRenest removes every plate and nests each drawing's required quantity
onto sheets from the nest's plate defaults and plate-size options. It keeps
a layout only when it is valid and keepable, so an invalid result never
reaches the server; the nest is saved without sheets and the operator is
told to run Auto Nest. Unplaced copies stay as remaining quantity, and the
status column lists sheets, utilization and what did not fit. A new Nesting
progress stage reports each group, and cancelling while nesting saves
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:55 -04:00
ajandClaude Opus 5.5 f6781d27c3 feat(desktop): release several sets, merge into drafts, and undo releases
The release dialog now lists the sets to release instead of one set with a
multiplier. More are added by typing a name (the matching active sets fill
the list as you type) and each set has its own kit count. Its groups span
all the sets, and each group can make a new Draft nest or merge into a Draft
nest of the same material, grade and thickness, listed with its sheet count.
The status column says that a merge discards the draft's layout and that
Undo Release restores it.

A draft open in a tab of this desktop is refused, because nest tabs do not
track unsaved edits and the merge would leave them behind. The main window
tells the dialog which server nests are open.

The release history shows what each release did to each nest (created, or
merged from one revision to another, and what undo did) and gains Undo
Release. When nests changed after the release, it names them and offers to
undo the others, leaving those as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:54 -04:00
ajandClaude Opus 5.5 74203483c5 feat(data): release several sets in one plan and merge groups into draft nests
The release plan now takes several sets, each with its own kit count. A
drawing listed by more than one set becomes one item with the quantities
summed and each set's share kept, so the release can record what every kit
put into every nest. FindDraftsAsync lists the Draft nests of a group's
material through the new nest query filters.

The session can merge a group into a chosen draft instead of making a new
nest. It downloads the draft at the revision it was chosen at, refuses it
unless it is still a Draft of the group's material, thickness, units and cut
type, adds the items, and removes every plate so the whole demand is nested
again. The save is guarded by that revision, so a draft someone saved
meanwhile fails its group instead of being overwritten. An optional renest
step, run before anything is uploaded, will back "Nest after release"; if it
throws, the nest is still released without a layout.

The release record now carries every set line, merged nests with their
revisions before and after, and each set's drawing quantities.
SetReleaseText.UndoRefusal words a refused undo for the release history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:53 -04:00
ajandClaude Opus 5.5 4b604d3874 feat(server): filter nest queries by status, material, grade and thickness
A release that merges into an existing draft needs the drafts of the same
material group. The nest query can now filter exactly on status, material,
grade and thickness. Material and grade compare like the shared material
lists (case and spacing ignored) through a SQLite function, so no key columns
or backfill are needed; a blank grade asks for nests without one, and
thickness matches at the four decimals drawings are stored with.

The client sends the filters only when set, so plain browsing keeps working
against servers that do not know them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:52 -04:00
ajandClaude Opus 5.5 28db6ae807 feat(server): release several drawing sets at once, merge into drafts, and undo
A release can now cover several sets, each with its own kit count, so parts
from different kits share sheets. It can also merge into an existing draft
nest instead of always creating one. Schema v9 replaces the single-set
release tables: releases, their set lines, their nests (created or merged,
with the nest revision before and after) and the drawing quantities each set
added to each nest, so a kit stays traceable once kits share a nest. The v8
migration turns each existing release into one set line with created nests.

Undo runs on the server in one transaction: created nests are soft-deleted
and merged nests restored to their pre-merge revision, but only while each
still holds what the release wrote. Otherwise nothing changes and the
changed nests are listed, so the operator can skip them. A nest restored to
that revision counts as unchanged, which lets releases sharing a nest be
undone newest first.

The old /api/sets/{id}/releases and /api/nests/{id}/release routes are
replaced by /api/releases; no tagged desktop used them. The desktop still
releases one set at a time until the release dialog is reworked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:52 -04:00
ajandClaude Opus 5.5 19809c61d0 feat(sets): browse the library inside the set editor
Adding drawings to a set meant opening the library dialog, then the set
editor, then a second copy of the library in pick mode, then backing out
again. The set editor now has the library as its left pane (unfiltered,
searchable, sortable, paged): select drawings and Add to Set, double-click
or press Enter. Members already in the set are greyed. The library
dialog's pick mode had no other caller and is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:41:53 -04:00
ajandClaude Opus 5.5 382abf702b test(server): upload a real archive from the drawing set nest fixture
The drawing set fixtures landed on master while uploads still accepted
any bytes; the server now rejects a file without a readable nest.json,
so the fixture's placeholder payload made every release test that
posts a nest fail with 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:18:38 -04:00
ajandClaude Opus 5.5 2963764215 feat(io): let NestWriter keep a nest's own modified date
Write always stamped DateLastModified with the save time. That is right
for an ordinary save, but a converter carrying another system's date
cannot keep it: every PEP nest imported into the server recorded the
import time as its modified date.

PreserveDateLastModified (init, default false) writes the nest's own
date instead; every existing caller keeps stamping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:14:06 -04:00
ajandClaude Opus 5.5 64e24fc6c8 feat(server): take nest record counts, material and thickness from the archive
The server stored whatever plate count, part count, material and
thickness the client claimed, so a record could disagree with its own
archive, and a bulk importer had to read each written .nest back in full
just to describe it (about a quarter of the PEP archive import's time).

Uploads (POST and PUT /file) now read only the archive's nest.json -
never programs or geometry, and without referencing OpenNest.IO - and
overwrite those fields, following NestReader's fallbacks for thickness,
material, parts with a missing drawing and the trailing new-plate slot.
A file without a readable nest.json is 400 and stores nothing, so the
server can guarantee the record describes the bytes; nest.json
decompression stops at 64 MB. A metadata-only PUT keeps the stored
counts because the archive is unchanged; material and thickness stay
editable there, since the desktop applies the record's values on open.

Server tests that uploaded placeholder bytes now upload small
deterministic archives, and the container smoke's near-limit probe is a
padded real archive. New tests pin the server's values to a full
NestReader read of the same archive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:14:06 -04:00
ajandClaude Opus 5.5 0eb6d5886a perf(geometry): flatten Shape.ToPolygon by chord tolerance by default
ToPolygon() used 1000 segments for every arc and circle regardless of
size: no more accurate for large arcs and needlessly dense for small
ones, which made every Drawing area and canonical-angle computation on
an arc-heavy part handle hundreds of thousands of vertices.

The parameterless overload now delegates to ToPolygonWithTolerance with
DefaultChordTolerance = 0.0001. That is ten times tighter than the
spacing and overlap checks' 0.001 because callers use this polygon for
area, which an inscribed polygon always under-reads: at 0.001, seven
area-sensitive cut-off, splitter and circle-packing tests drifted past
their precision; at 0.0001 all pass. ToPolygon(int) keeps the fixed
segment count for any caller that wants it.

With the one-pass Cleanup, a 300-nest PEP archive import on the forge
server went from 46.1 s to 15.5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:13:37 -04:00
ajandClaude Opus 5.5 9ab6fbbeaf perf(geometry): compact Polygon.Cleanup in one pass
Cleanup removed each duplicate vertex with List.RemoveAt from the middle
of the list, so a polygon cost O(duplicates x vertices). Shape.ToPolygon
emits a duplicate at every entity junction and flattened arcs to 1000
points, so building the Drawing for a large arc-heavy part spent seconds
shifting memory: one 1 MB PEP nest took 12.7 s to convert and 11.2 s to
read back, a quarter of a 300-nest archive import's total time.

The single pass compares each vertex with its original predecessor, as
the back-to-front loop did, so tolerance-equal runs collapse identically
and the output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:13:37 -04:00
ajandClaude Opus 5.5 8c7dc41ce8 feat(planning): warn about tipping parts and retire legacy lead-in assignment
Plan Cutting is now the only automatic lead-in assignment, and parts the
support grid cannot hold level are reported wherever they lack a
steadying tab.

- Remove Assign Lead-ins (plate and nest), LeadInAssigner, the lead-in
  tool's Auto-Assign button and the Auto Tab size range; old settings
  with autoTab sizes still load
- Manual Place Lead-in tabs the clicked entry when support tabs are on
  and the grid cannot hold the part (SupportTabSettings)
- New PartSupport finding: Plan Cutting replay and best-effort plans
  warn when a drawing pin, a lead-in that does not fit or no single tab
  leaves a part unsteady; the pre-post review checks each placed
  program's outside tab gap. These warnings replace the advisory
  weak-tab notes; only the inside-corner tab note stays advisory, and
  never for a pin
- Pinned perimeters keep their pin but carry the support analysis
- Draw grid points as pixel-snapped squares without anti-aliasing
- Post fixtures build lead-ins through a test-only TestLeadIns helper
- Docs: support tabs, pre-post verification and planner notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:54:40 -04:00
ajandClaude Opus 5.5 c3881c5534 fix(support): center tied support tabs across the part's long side
A tab cannot lift a part's margin past its contacts' own hull edges, so a
whole stretch of outline often ties for the best tab point, and the first
sample found won, leaving the tab off-center. Keep the full outline search
for the margin, then among samples within Tolerance.Epsilon of the best,
pick the one nearest the line through the center of gravity across the
long side of the outline's minimum-area bounding rectangle. A part held
only near one end is still tabbed at the far end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:39:05 -04:00
ajandClaude Opus 5.5 7bf753ce17 feat(storage): record material grade on server nests and pick it in Nest Info
Nest records gain a materialGrade field (server schema v8, blank for existing
rows) that is searched, sorted and versioned like the other metadata. The
Nest Info dialog gets a grade picker listing the machine configs' grades for
the chosen material; density survives only an unchanged name and grade. A
blank recorded grade keeps the archive's grade while the name matches, so
records saved before v8 do not lose it on open. Saved Nests shows a Grade column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:37:57 -04:00
ajandClaude Opus 5.5 11b6032459 feat(desktop): redesign the pre-post verification form for quick reading
Replace the plain-text report with a colored status banner, per-check
count tiles and a findings list grouped by check type, with the selected
finding shown in full below it. A Copy Report button keeps the full text
report available. Consent, cancellation and failure blocking are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:37:57 -04:00
ajandClaude Opus 5.5 0ca4e64189 fix(bestfit): drop saved pair sets whose canonical frame changed
Saved best-fit pairs are in the drawing's canonical frame, but the canonical
angle is recomputed on load and was not saved with the set. When that
computation changes (RotatingCalipers checking every hull edge, marks no
longer counted as material), old pairs were replayed in a turned frame and
could overlap.

Each saved set now records canonicalAngle, and BestFitCacheStore.Import
skips sets whose angle no longer matches the recomputed one (1e-9 rad,
modulo a full turn). Sets from older files have no angle and are dropped;
their pairs are recomputed when next needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:04:23 -04:00
ajandClaude Opus 5.5 f901840ed0 fix(flatbar): stop the swap optimization trading identical copies
TryImprove kept a swap when the trial run looked fuller than before, but
the trial charges each type's self-nesting gain again even when the run
already holds that type. Trading a placed copy for an identical unplaced
one therefore looked fuller by the gain and repeated forever: FlatBar ran
into the benchmark's 5-minute timeout on tabbed_cog_layout once the
all-edges MBR fix gave one part new rotations (it finished in 7 s before).

The swap is now judged on the merged run, so every kept swap really
leaves the run fuller and the loop terminates. Swaps the old test kept
that do fill the run are still kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:41:27 -04:00
ajandClaude Opus 5.5 059a873d32 fix(geometry): check every hull edge for the minimum bounding rectangle
MinimumBoundingRectangle(Polygon) evaluated only the first (n + 1) / 2
edges, assuming opposite edges give the same rectangle. That holds only
for centrally symmetric hulls: for the triangle (0,0), (10,0), (12,1) it
returned area 12 instead of 10, depending on vertex order.

Every edge is now a candidate. A second-half edge replaces the best only
when smaller beyond rounding, so symmetric hulls (rectangles, best-fit
pairs, whose MBR angle PairEvaluator uses directly) keep their previous
orientation instead of flipping 180 degrees on floating-point noise.

On the Desktop problem nests, 8 of 160 drawing entries now get a smaller
MBR (0.004% to 3.2%) and a turned canonical frame; none changed without a
real area gain. The problem-nest corpus is unchanged against f51e5aef.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:41:27 -04:00
ajandClaude Opus 5.5 0922e41401 fix(desktop): mute sheet cut-offs the cut simulator has not reached
Cut-off lines are drawn after the parts in their own red dashed pen, so they
stayed red over their muted parts. Draw them gray while the torch has not
started their part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:32:44 -04:00
ajandClaude Opus 5.5 ed9c1f9df6 feat(desktop): make the toolbar sequencing button run Sequence by Path
The click-each-part Manual Sequence tool stays under Plate > Sequence Parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:46:03 -04:00
ajandClaude Opus 5.5 50f966913d feat(desktop): show planning time and replan when inputs change mid-plan
The cut planner status shows the run's elapsed time, and its inputs stay
enabled while planning: changing one cancels the run and starts a new one.
Cut-off settings open once the cancelled run releases the plates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:38:38 -04:00
ajandClaude Opus 5.5 96f02c665c feat(desktop): preview sequence numbers live while drawing a path
While a Sequence by Path line is being dragged, the plate view shows the
numbers the finished line would give, without reordering the plate, so
reorder listeners (drawing list, overlap overlay, simulator) are not hit
on every mouse move. The plate is reordered once on release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:26:55 -04:00
ajandClaude Opus 5.5 aeb90d8a86 feat(desktop): sequence parts along lines drawn over the plate
Plate > Sequence Parts > Sequence by Path: each left-drag adds a line,
cut in drawing order; the jump between lines is a rapid and claims no
parts. Each part belongs to the line passing closest to its center and
is ordered by where that nearest point falls along the line. Cut-first
parts stay ahead and cutoff dependency rules still apply. A small window
offers Undo Line, Clear (restores the starting order) and Done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:12:04 -04:00
ajandClaude Opus 5.5 f51e5aefdd feat(planning): keep support tabs out of inside corners and report weak tabs
The best tab point used to land in inside corners: equally good points
run along a straight edge, vertices were tried first and the first tie
won. Operators cannot grind a nub flush there.

- SupportAnalyzer finds inside corners (inward-turning vertex runs of at
  least 30 degrees, so chorded fillets count) and keeps tab points a
  clearance from them; of equally good points it picks the one farthest
  from an inside corner. Plan Cutting and the overlay use twice the tab
  size; AcceptsTabAt rejects closer points
- When no steadying entry has a lead-in that fits, the search falls back
  to entries clear of inside corners, then to every entry, each ordered
  by how well the tab steadies the part, keeping only the steadiest that
  fit
- Replay notes parts whose planned tab cannot hold them level or sits in
  an inside corner; the notes are advisory and leave a Ready plan Ready
- Console/MCP plate summaries print a ready plan's notes
- docs/support-tabs.md and cutting-planner notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:18:16 -04:00
ajandClaude Opus 5.5 a7c0747e11 feat(planning): tab parts the laser support grid cannot hold level
Model the machine's support bed as a grid of contact points measured
from the plate origin, and check each part's center of gravity against
the convex hull of the grid points under its material. The bed is
extensible so straight and CAD-imported curved slats can follow.

- Core: SupportBed/SupportGrid, SupportMaterial and SupportAnalyzer
  (margin with registration tolerance, best single tab point)
- Nest-wide support bed that every plate follows, saved in
  plateDefaults.supportBed; machine defaults in support-grid.json
- CuttingParameters.SupportTabs: Plan Cutting tabs only unsupported
  parts and limits their perimeter entries to points where the tab
  steadies the part, falling back to any fitting entry
- Desktop: Nest > Support Grid..., View > Draw Support overlay and a
  "Tab parts the support grid cannot hold level" tab option
- docs/support-tabs.md, nest file format and planner notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:55:24 -04:00
ajandClaude Opus 5.5 b65876c031 feat(desktop): add a nest's library drawings to a drawing set
Operators often build a nest first and only then want it kept as a
reusable kit. The drawing list's new "Add Library Drawings to Set..."
takes every drawing copied from this server's library, sums copies of
one library drawing, and opens the set editor on a chosen or new set
for review and Save. Unlinked, other-server and zero-quantity drawings
are named as skipped instead of silently dropped.

Members the set already lists keep the set's quantity: a nest released
from a set carries its kit multiplier, so copying its quantities back
would scale the kit on every round trip. Members are added at the
library's latest revision (S1), with a note when the nest holds an
older one or the drawing is archived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:24:10 -04:00
ajandClaude Opus 5.5 cc731ff3d6 feat(desktop): release drawing sets into draft nests and track them
A set is only useful on the floor once it becomes nests. Release on the
Sets tab previews one row per material group with a kit multiplier, an
archived-drawing toggle and a tick per group, then builds each ticked
group's nest as File > New would, in the group's units, uploads it as a
Draft, opens it in its own tab bound to the server record and records the
release. Every group's outcome stays on screen, and a release whose
nests exist but whose record failed can be recorded again.

Releases shows each release's nests and how far they have got ("2 of 3
cut"), and Nest Info names the release a nest came from, so a kit run can
be followed across its nests. Plate defaults convert between inches and
millimeters so a group drawn in other units still gets sensible plates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:12:11 -04:00
ajandClaude Opus 5.5 1ccba9b910 feat(desktop): browse and edit drawing sets in the drawing library
Operators need to build kits from library drawings before they can be
released. The Drawing Library dialog gains a Sets tab beside Drawings,
so sets live where their drawings are browsed: list, search, archive,
restore, history, and an editor whose Add Drawings reuses the library in
a multi-select pick mode. The Drawings tab's menu shows which sets use a
drawing and adds it to a chosen or new set.

DrawingSetEditSession keeps a new set's id across a save whose reply was
lost, so a retry adopts the stored set instead of creating it twice, and
guards existing-set saves with the revision they were read at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:53:22 -04:00
ajandClaude Opus 5.5 af6975b9f1 feat(data): add the drawing set client and release session
The desktop needs a cross-platform way to read and save sets and to turn
one into nests before any UI is built on it. RemoteDrawingSetStore covers
every /api/sets route with the library client's checks and conflict
handling. SetReleasePlan groups a set by material, grade, thickness, units
and cut type without I/O, so the release dialog can preview refusals
(duplicate names, drawings without material) before anything is created.

SetReleaseSession builds every group's nest through LibraryDrawingInsert
before uploading any, so a refusal leaves nothing on the server; uploads
then never cancel mid-flight, a lost reply is reconciled by the nest's
client-allocated id, and the release is posted only when every chosen
nest exists, with a same-id retry when that post fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:48:23 -04:00
ajandClaude Opus 5.5 44666b289d feat(server): record set releases and the nests they made
Releasing a drawing set makes one nest per material group, so a kit run
spans several nests. The server now records which set revision was
released, the multiplier and the nests it made, so a run can be tracked
across its nests and a nest can say where it came from.

The client uploads every nest first and posts the release last: a failure
part-way leaves ordinary nests and no release, never a release pointing at
missing nests. For the same reason a release is accepted for an older set
revision or an archived set, since the nests already exist by then. The
release id is client-allocated and a nest belongs to at most one release,
so a retried post gets 409 with the release that already holds it.

Releases are append-only and read joined to each nest's current metadata,
so status and deletion are always current. The tables join schema v7, as
the set tables have not shipped yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:27:27 -04:00
ajandClaude Opus 5.5 be057bf337 feat(server): store versioned drawing sets
A drawing set is a named list of library drawings with quantities (a kit,
an assembly, a recurring order) that will later be released into one nest
per material. This is the server half: sets get the same revision,
If-Match and archive contract as /api/drawings so the client and tests
follow a known shape.

Schema v7 adds drawing_sets, drawing_set_revisions and
drawing_set_members. Members reference drawings by id and mean each
drawing's current revision, so reads join the current drawing record:
the client can group members by material without one request per drawing,
and the joined record is ignored on save so a read record can be sent
back unchanged. Every change keeps its own member list, and
/api/drawings/{id}/sets answers "used in" from current revisions only.

The drawing query's enum, integer and history-paging parsing is now shared
so both APIs refuse bad input identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:51:56 -04:00
ajandClaude Opus 5.5 28ee04a480 feat(nests): add a Draft nest status
Nests released from drawing sets need a state before Quote: created but not
yet nested or checked. Draft is appended to NestStatus (value 3) because the
server still accepts integer statuses from existing callers, so existing
numbers must not shift; NestStatuses.WorkflowOrder puts it first wherever the
operator picks a status.

Older desktops read Draft as Quote (the documented unknown-status fallback),
and an older server refuses "draft", so the server must be upgraded before
desktops that can set it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:31:58 -04:00
aj 1b323e2380 docs(posts): describe CI Fiber M50 as Table In, the pallet swap
The pallet-change setting and post doc said M50 moves the cut sheet out,
borrowed from the CL-series manual. Cincinnati's CNC Codes Handbook calls
M50 "Table In" and M51 "Table Out". On the CI Fiber shuttle, Table In brings
the waiting pallet into the laser, which sends the cut one out, so M50 is
still the swap. Say that, note that it expects the waiting pallet to be
loaded, and note that newer Cincinnati samples end without a pallet code.
Default (M50) and posted output are unchanged.
2026-10-20 10:04:59 -04:00
aj 2d74d09023 chore(posts): stop naming the CI Fiber customer sample part
The Cincinnati sample used by the CI Fiber tests is a customer production
part. Its geometry and NC were never tracked, but comments named its part
number and fixture file names and quoted its date and a pierce coordinate.
Replace them with generic wording and placeholder values; the fixture stays
outside the repository, configured only in the gitignored test-config.json.
Comment-only change.
2026-10-20 08:01:18 -04:00
aj cc2b3d1b81 test(posts): post the CI Fiber sample's own lead-ins and match it line for line
The sample regression fixture now carries each part's pierce, lead-in and
cut moves from the Cincinnati machine program as its saved cutting program.
A new test posts those saved programs without reassigning lead-ins and
requires the program body to equal the sample exactly: L2/L4 macro, G41/G42
side, lead-ins and every cut move (21,911 lines).

Mutation checks: dropping the largest-contour perimeter rule from the kerf
fix fails it at the first perimeter (L4 posted as L2), and forcing G41 fails
it at the first perimeter's G42. The existing tests still replace the saved
programs with OpenNest's own lead-ins first; their comment now says so
instead of describing the sample's leads wrongly.
2026-10-20 07:36:01 -04:00
ajandClaude Opus 5.5 63233db108 fix(posts): put the CI Fiber kerf on the scrap side for either winding
The CI Fiber post selected G42 for every perimeter. Cincinnati's CNC Codes
Handbook defines G42 as "product remains on the left", and imported
perimeters run clockwise with the part on the right, so the controller
offset every outside edge into the part: parts came out a kerf undersize.
Cincinnati's LaserNST samples only use G42 because they cut perimeters
counter-clockwise.

Winding is normalized only at DXF import, so the post can't assume a
direction either. It now takes the largest contour as the perimeter (as
ShapeProfile does) and picks G41 or G42 from each contour's actual winding.
That also stops a reversed drawing from swapping the L2/L4 lead-in macros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:08:11 -04:00
ajandClaude Opus 5.5 abfc1a714a feat(posts): trigger the Cincinnati safety head raise by rapid length
The safety raise (M47 P<SafetyHeadraiseDistance>) used to fire only when
the next part had a different drawing name, which has nothing to do with
crash risk: a long rapid between copies of the same part got a plain M47,
while a short hop between neighbouring different drawings got the full
raise. Sheets are rarely flat (5/8" plate can be ~1.5" out of flat within
supplier tolerance), and longer rapids cross more of that bow.

Add SafetyHeadraiseRapidDistance (program units, default 12"): any rapid to
the next pierce longer than it takes the safety raise, regardless of
drawing. Unknown travel never does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:08:10 -04:00
aj efa4844049 test(benchmark): prove parallelism reach with a rendezvous, not overlapping sleeps
Run_NeverExceedsMaxParallelism_ButReachesIt asserted two 200 ms solves
overlapped, which depends on the thread pool adding a second worker in
time; hosted Windows release run 39400265218 saw only one. Each fake solve
now waits (up to 10 s) until two solves have entered, so a parallel runner
always reaches the limit and a serial one fails once after the timeout.
Forcing MaxDegreeOfParallelism = 1 fails it (Expected 2, Actual 1).

Project Memory b534b51c.
2026-10-20 06:02:16 -04:00
1142 changed files with 103444 additions and 11364 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"vpk": {
"version": "1.2.0",
"commands": [
"vpk"
],
"rollForward": false
}
}
}
+8
View File
@@ -5,6 +5,14 @@
!OpenNest.Data/** !OpenNest.Data/**
!OpenNest.Core/ !OpenNest.Core/
!OpenNest.Core/** !OpenNest.Core/**
!OpenNest.Server.Worker/
!OpenNest.Server.Worker/**
!OpenNest.Api/
!OpenNest.Api/**
!OpenNest.Engine/
!OpenNest.Engine/**
!OpenNest.IO/
!OpenNest.IO/**
**/bin/** **/bin/**
**/obj/** **/obj/**
**/data/** **/data/**
+3
View File
@@ -20,3 +20,6 @@
*.PDF diff=astextplain *.PDF diff=astextplain
*.rtf diff=astextplain *.rtf diff=astextplain
*.RTF diff=astextplain *.RTF diff=astextplain
# Container scripts run in Linux images.
*.sh text eol=lf
+3 -1
View File
@@ -19,9 +19,11 @@ jobs:
matrix: matrix:
project: project:
- OpenNest.Tests - OpenNest.Tests
- OpenNest.Desktop.Tests
- OpenNest.Engine.Tests - OpenNest.Engine.Tests
- OpenNest.IO.Tests - OpenNest.IO.Tests
- OpenNest.Server.Tests - OpenNest.Server.Tests
- OpenNest.FrontEnd.Tests
steps: steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: with:
@@ -70,7 +72,7 @@ jobs:
windows-desktop: windows-desktop:
# WinForms tests need a Windows desktop runtime; the Linux job can only # WinForms tests need a Windows desktop runtime; the Linux job can only
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop. # cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
runs-on: windows-2022 runs-on: windows-2022
timeout-minutes: 20 timeout-minutes: 20
defaults: defaults:
+128
View File
@@ -0,0 +1,128 @@
name: Development build
# Every master push whose cross-platform tests pass becomes the rolling
# `dev-build` pre-release: X.Y.Z-dev.N, where X.Y.Z is the minor after the
# newest release tag and N the commit count. Installed copies take it only
# with Tools > Options > Include development builds.
on:
workflow_run:
workflows: ['Cross-platform tests']
types: [completed]
branches: [master]
permissions:
contents: read
concurrency:
group: dev-build
cancel-in-progress: true
jobs:
package:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: windows-2022
timeout-minutes: 30
defaults:
run:
shell: pwsh
env:
DOTNET_CLI_TELEMETRY_OPTOUT: '1'
DOTNET_NOLOGO: '1'
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '8.0.x'
- name: Resolve development version
id: version
run: |
$releases = git tag --list 'v*' | Where-Object { $_ -match '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } |
ForEach-Object { [version]$_.Substring(1) } | Sort-Object
if (-not $releases) { throw 'No vX.Y.Z release tag found.' }
$latest = @($releases)[-1]
$count = (git rev-list --count $env:SOURCE_SHA).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Cannot count commits.' }
$version = "$($latest.Major).$($latest.Minor + 1).0-dev.$count"
Write-Host "Development version $version (after v$latest)"
"version=$version" >> $env:GITHUB_OUTPUT
- name: Publish and verify Windows package
run: ./scripts/Publish-Windows.ps1 -Version ${{ steps.version.outputs.version }}
- name: Upload development build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: OpenNest-dev-build
path: |
artifacts/*.zip
artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error
retention-days: 7
compression-level: 0
publish:
needs: package
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
actions: read
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SOURCE_SHA: ${{ github.event.workflow_run.head_sha }}
VERSION: ${{ needs.package.outputs.version }}
steps:
- name: Skip if master has moved on
id: current
run: |
head=$(gh api "repos/$GH_REPO/commits/master" --jq .sha)
if [ "$head" != "$SOURCE_SHA" ]; then
echo "master is at $head; its own run publishes the newer build."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
- name: Download development build
if: steps.current.outputs.skip != 'true'
run: gh run download "$GITHUB_RUN_ID" -n OpenNest-dev-build -D dev
- name: Verify checksums
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
sha256sum -c ./*.zip.sha256
(cd velopack && sha256sum -c "../OpenNest.v$VERSION.velopack.sha256")
- name: Replace the dev-build pre-release
if: steps.current.outputs.skip != 'true'
working-directory: dev
run: |
if gh release view dev-build >/dev/null 2>&1; then
gh release delete dev-build --cleanup-tag --yes
fi
cat > notes.md <<EOF
Automatic development build of master at $SOURCE_SHA.
It may contain unfinished features. Installed copies receive it only with
**Tools > Options > Include development builds**; full releases are unaffected.
EOF
gh release create dev-build ./*.zip ./*.sha256 velopack/* \
--target "$SOURCE_SHA" --prerelease --latest=false \
--title "OpenNest $VERSION (development build, ${SOURCE_SHA:0:8})" --notes-file notes.md
- name: Read back the pre-release
if: steps.current.outputs.skip != 'true'
run: |
gh release view dev-build --json isPrerelease,isDraft,tagName,assets \
--jq '"prerelease=\(.isPrerelease) draft=\(.isDraft) assets=\(.assets|length)"' | tee readback.txt
grep -qx 'prerelease=true draft=false assets=8' readback.txt
[ "$(gh api "repos/$GH_REPO/git/ref/tags/dev-build" --jq .object.sha)" = "$SOURCE_SHA" ]
+9 -2
View File
@@ -6,6 +6,10 @@ on:
- 'OpenNest.Server/**' - 'OpenNest.Server/**'
- 'OpenNest.Data/**' - 'OpenNest.Data/**'
- 'OpenNest.Core/**' - 'OpenNest.Core/**'
- 'OpenNest.Server.Worker/**'
- 'OpenNest.Api/**'
- 'OpenNest.Engine/**'
- 'OpenNest.IO/**'
- 'OpenNest.Server.Tests/**' - 'OpenNest.Server.Tests/**'
- 'scripts/Server.Tests/**' - 'scripts/Server.Tests/**'
- 'scripts/Test-ServerContainer.sh' - 'scripts/Test-ServerContainer.sh'
@@ -21,6 +25,10 @@ on:
- 'OpenNest.Server/**' - 'OpenNest.Server/**'
- 'OpenNest.Data/**' - 'OpenNest.Data/**'
- 'OpenNest.Core/**' - 'OpenNest.Core/**'
- 'OpenNest.Server.Worker/**'
- 'OpenNest.Api/**'
- 'OpenNest.Engine/**'
- 'OpenNest.IO/**'
- 'OpenNest.Server.Tests/**' - 'OpenNest.Server.Tests/**'
- 'scripts/Server.Tests/**' - 'scripts/Server.Tests/**'
- 'scripts/Test-ServerContainer.sh' - 'scripts/Test-ServerContainer.sh'
@@ -114,8 +122,7 @@ jobs:
publish: publish:
if: >- if: >-
github.repository == 'ajisaacs/OpenNest' && github.repository == 'ajisaacs/OpenNest' &&
((github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 40 timeout-minutes: 40
permissions: permissions:
+6 -1
View File
@@ -68,7 +68,7 @@ jobs:
# mini dump in TestResults, instead of holding the runner until the # mini dump in TestResults, instead of holding the runner until the
# job timeout. # job timeout.
run: | run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) { foreach ($project in @('OpenNest.Tests', 'OpenNest.Desktop.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults dotnet test "$project/$project.csproj" -c Release --no-build --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory TestResults
if ($LASTEXITCODE -ne 0) { throw "$project failed." } if ($LASTEXITCODE -ne 0) { throw "$project failed." }
} }
@@ -96,6 +96,11 @@ jobs:
path: | path: |
artifacts/*.zip artifacts/*.zip
artifacts/*.sha256 artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
compression-level: 0 compression-level: 0
+3
View File
@@ -225,3 +225,6 @@ OpenNest.Tests/test-config.json
# Vendor programming manuals: keep reference copies outside source control. # Vendor programming manuals: keep reference copies outside source control.
CINCINNATI LASER PROGRAMMING MANUAL.pdf CINCINNATI LASER PROGRAMMING MANUAL.pdf
TF5200_programming_manual_en.pdf TF5200_programming_manual_en.pdf
# Python bytecode
__pycache__/
+26 -8
View File
@@ -7,7 +7,7 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search. - Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`. - Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. - Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. User guides and their screenshots live in the separate `ajisaacs/opennest-help` repo: update or recapture them when a UI change alters a documented screen, and never rename a `HelpTopics` ID.
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`. - Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior. - Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
@@ -22,13 +22,17 @@ dotnet build OpenNest.sln
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
dotnet test OpenNest.Desktop.Tests/OpenNest.Desktop.Tests.csproj -c Release
dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release dotnet test OpenNest.Server.Tests/OpenNest.Server.Tests.csproj -c Release
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj -c Release
# Windows runtime tests # Windows runtime tests
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
``` ```
After changing planning, contour preparation or geometry checks, run the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); no plate may rank below its baseline. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`. On Windows, prefer `scripts/Test-Hidden.ps1 <dotnet test args>` for WinForms tests: it runs them on a hidden desktop so no windows appear or take focus; see [hidden-desktop tests](docs/hidden-desktop-tests.md).
Only Plan Cutting changes need the [problem-nest corpus](docs/problem-nests.md) (`Category=ProblemNests`, opt-in via `OPENNEST_PROBLEM_NESTS_SERVER`); never run it for nesting engines, fills, UI, posts or file formats. Limit it to the areas the change touches with `OPENNEST_PROBLEM_NESTS_CHECKS` (table in that doc); `part-order` and `entries` plan every nest. No plate may rank below its baseline, and none may join or climb the near-budget list compared with master's run. Keep desktop-dependent tests in `OpenNest.WinForms.Tests`, never add a WinForms reference to `OpenNest.Tests`. Optional CHR fixtures use local `OpenNest.Tests/test-config.json` and skip when absent. On Linux, build Windows projects with `-p:EnableWindowsTargeting=true`; this is not Windows runtime verification. The headless console builds independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror. Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish-Windows.ps1`; workflow artifacts are candidates, not published releases. GitHub (`ajisaacs/OpenNest`) is the primary repository; Gitea is a read-only backup mirror.
@@ -37,33 +41,47 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter. - `OpenNest.Core`: domain (`Nest -> Plate -> Part -> Drawing -> CNC.Program`), geometry, cutting strategies and diagnostics. Angles are radians; use `Tolerance.Epsilon` for geometry comparisons. `OpenNest.Math` shadows `System.Math`, so qualify the latter.
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based. - `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution. - Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks. - `OpenNest.IO`: ACadSharp and SVG import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json`. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. - `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (in Database mode, the server's shared copy once it holds one). WinForms `Properties.Settings` persist to `%APPDATA%\OpenNest\user-settings.json` through `AppDataSettingsProvider`, never inside the Velopack install folder; `OpenNest.WinForms.Tests` redirects them to a temp file, so a test run never writes the user's settings. Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md).
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection. Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time. - `OpenNest.Desktop` (net8.0): toolkit-neutral desktop logic shared by the WinForms app and `OpenNest.Avalonia`, the cross-platform desktop in progress. It must not reference WinForms/WindowsDesktop, `System.Drawing.Common` or Avalonia (`OpenNest.Desktop.Tests` guards this, including transitive packages); put toolkit-specific rendering, dialogs and settings behind adapters in the front ends.
- `OpenNest.Server`: nest storage HTTP service, kept off Engine and IO. `OpenNest.Server.Worker`: the server nesting worker, shipped in the same image.
- `OpenNest.Console`, `OpenNest.Mcp`, `OpenNest.Api`: front ends; `OpenNest.Benchmark`: whole-job engine comparisons; `OpenNest.Gpu`: GPU evaluators; `OpenNest.Training`: ML data collection and the Python sketch-search prototype (`sketch-search/README.md`). Benchmark timing comparisons require `--parallel 1`; validate layouts and fulfillment, not just elapsed time.
## Geometry and ownership safeguards ## Geometry and ownership safeguards
- Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts. - Marks are not material: use `SpecialLayers.IsMaterial` when deriving nesting/collision geometry; exclude rapid and scribe moves without removing them from display, cutting time or posts.
- Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check and the pre-post part-spacing review share Core's `SpacingOutline` for candidates; the review then confirms each one on finely flattened native contours, so it never warns where Auto Nest's check would pass. Do not loosen tolerances to hide failures. - Engine/validator collision paths retain the hand-written `Collision` kernel; its GPU port is on hold. Material-overlap diagnostics use Clipper2 per pair for connected shared-material islands and interior markers; see [material-overlap diagnostics](docs/geometry/visual-overlap-check.md). Polygon consumers use `ClipperBridge`; directional-distance consumers retain native-arc offsets. Validation uses `OffsetForValidation` and `NestTolerances.SpacingSlack`, not conservative display/preparation padding; Auto Nest's layout check, the runner's candidate validation and the pre-post part-spacing review all find spacing candidates with Core's `SpacingOutline` and confirm them on `NativeContours` (the outline circumscribes arcs by more than the slack), so they agree; `NestLayoutCheck.Clears` skips the confirmation and stays stricter for engine certification. Do not loosen tolerances to hide failures.
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime. - `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime.
- `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization. - `FillScore` ranks count, utilization, compactness; exact ties keep the current layout. Custom comparers remain authoritative. Preserve extents' negative/nonfinite-input fallback, pair preparation and adjusted-column overlap checks. Do not remove bounds recomputations without threshold/rounding characterization.
- `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks. - `ObservableList` events own drawing/plate quantity tracking; avoid double accounting. Cutoff parts are excluded from quantity, utilization and overlap checks.
- `Plate.Outline`/`NestPlateStock.Outline` (`SheetOutline`) make a sheet irregular; `Size` stays its bounding box. Only Irregular fills it (`PackBoundary` eroded by the largest edge spacing); every other engine and fill strategy must place nothing on it, validation must reject parts outside it or over its obstacles, and automatic cut-offs refuse it. See [irregular stock](docs/irregular-stock.md).
- Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names. - Cutoffs persist as definitions on `Plate.CutOffs`; apply through `RegenerateCutOffs`, never preview parts. Preserve sequence positions. Batch planning must finish before mutation and roll back on failure. Use `PlateSequencing.Apply` for automatic cutoff dependencies, with nominal spans/reference identity rather than trimmed geometry/names.
- A cutoff starts only at the origin-side edge: stock can be oversize, so the far edge is unknown. Only a head-locked overcut (`CutOff.HeadLockOvercut`, `HeadHeightLock` markers) may start its far-edge segment there; posts refuse it without lock codes.
- Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests. - Fixed emissions (locked programs, drawing-pinned contours) are accepted as given: their violations warn on an UNVERIFIED proposal and never steer the search, while every planner-chosen motion stays strict. Plates without them must keep the `PlanningCharacterizationTests` digests.
- A `HeadRaise` marker (a rapid made head-up over cut parts) is the safest-route fallback's last resort, added only after the strict search fails; the strict search never marks a rapid. Every post must raise before a marked rapid or refuse the nest.
- An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation. - An empty diagnostic is not a clear result unless `IsComplete`. Posting must run checks before writing CNC output; warnings require explicit per-attempt consent, never a persisted bypass. Keep inputs stable through analysis/cancellation.
- Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction. - Preserve symbolic G-code variable definitions/references in file round trips. Keep training bitmaps by default; inference checks predictor availability before scalar-only extraction.
Read the relevant contract before changing its behavior: Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md) - [Nest file format](docs/nest-file-format.md)
- [SVG import and export](docs/svg-import-export.md): SVG numbers are not drawing units; import never guesses a scale
- [Irregular stock](docs/irregular-stock.md): outlined sheets, what honors the outline and what refuses it
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md) - [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
- [Lead-in placement](docs/geometry/lead-in-placement.md) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback - [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in and the stop at a feature's center line) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material - [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them - [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
- [Dimensions](docs/dimensions.md): CAD Converter annotations that store placement only, are measured from the cut geometry when shown and never affect the program
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
- [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md) - [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md)
- [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md) - [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md)
- [Pre-post verification](docs/post-verification.md) - [Pre-post verification](docs/post-verification.md)
- [Machine cutting defaults](docs/machine-cutting-defaults.md): the nest's machine supplies leads, kerf and cut-off settings for plates without their own parameters
- [Shared settings](docs/shared-settings.md): in Database mode machines, post settings, clamps and nest defaults come from the server once it holds them; offline uses the last copy read, read-only
- [Server nesting](docs/server-nesting.md): Database-mode Auto Nest queued to the server's worker container (`OPENNEST_ROLE=worker`), one job at a time with no time limit, streaming progress and the best plan so far over SSE; the desktop replays the recorded engine answers to validate and bind locally. My Server Jobs reopens results in later sessions, and each result is kept at most once (`If-Match` Keep). The HTTP server stays off Engine/IO
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits - [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md) - [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal. - [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`. - [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, topic IDs and the pinned help release both packaging scripts install.
+102
View File
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Remote;
using OpenNest.IO;
namespace OpenNest.Api;
/// <summary>
/// A whole-job nest request as one upload for the nest server: a ZIP holding
/// <c>job.json</c> (<see cref="NestJobDocument"/>) and <c>drawings.nest</c>, a nest archive of
/// exactly the items' drawings in the order the document names them. Drawings travel as the
/// nest file format stores them, so the server nests what the client has, edits included.
/// </summary>
public static class NestJobPackage
{
public const string JobEntry = "job.json";
public const string DrawingsEntry = "drawings.nest";
public static byte[] Write(NestPipelineRequest request, Units units = Units.Inches)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Items);
var drawings = new List<Drawing>();
foreach (var item in request.Items)
{
if (item?.Drawing != null && !drawings.Any(d => ReferenceEquals(d, item.Drawing)))
drawings.Add(item.Drawing);
}
var document = NestJobDocument.From(request, drawings);
// A throwaway nest holds the drawings only; the caller's nest is never written.
var nest = new Nest { Units = units };
foreach (var drawing in drawings)
nest.Drawings.Add(drawing);
using var buffer = new MemoryStream();
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
using (var writer = new StreamWriter(zip.CreateEntry(JobEntry).Open(), new UTF8Encoding(false)))
writer.Write(document.ToJson());
using var archive = zip.CreateEntry(DrawingsEntry, CompressionLevel.NoCompression).Open();
new NestWriter(nest).Write(archive);
}
return buffer.ToArray();
}
/// <summary>
/// The package's <c>job.json</c> alone, without reading its drawings: a desktop reviewing the
/// job in a later session binds it to its own drawings instead
/// (<see cref="RemoteNestSubmission.Reopen"/>). Throws <see cref="FormatException"/> when it is not a package.
/// </summary>
public static NestJobDocument ReadDocument(byte[] package)
{
ArgumentNullException.ThrowIfNull(package);
try
{
using var zip = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
var jobEntry = zip.GetEntry(JobEntry) ?? throw new FormatException($"The nest job has no {JobEntry}.");
using var reader = new StreamReader(jobEntry.Open(), Encoding.UTF8);
return NestJobDocument.FromJson(reader.ReadToEnd());
}
catch (InvalidDataException ex)
{
throw new FormatException("The nest job is not a readable ZIP package.", ex);
}
}
/// <summary>Reads a package back into a request; throws <see cref="FormatException"/> when it is not one.</summary>
public static NestPipelineRequest Read(byte[] package)
{
ArgumentNullException.ThrowIfNull(package);
try
{
using var zip = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
var jobEntry = zip.GetEntry(JobEntry) ?? throw new FormatException($"The nest job has no {JobEntry}.");
var drawingsEntry = zip.GetEntry(DrawingsEntry) ?? throw new FormatException($"The nest job has no {DrawingsEntry}.");
string json;
using (var reader = new StreamReader(jobEntry.Open(), Encoding.UTF8))
json = reader.ReadToEnd();
var document = NestJobDocument.FromJson(json);
// NestReader needs a seekable stream.
using var archive = new MemoryStream();
using (var entry = drawingsEntry.Open())
entry.CopyTo(archive);
archive.Position = 0;
var nest = new NestReader(archive).Read();
return document.ToRequest(nest.Drawings.ToList());
}
catch (InvalidDataException ex)
{
throw new FormatException("The nest job is not a readable ZIP package.", ex);
}
}
}
+1
View File
@@ -7,5 +7,6 @@ public class NestRequestPart
public string DxfPath { get; init; } public string DxfPath { get; init; }
public int Quantity { get; init; } = 1; public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true; public bool AllowRotation { get; init; } = true;
/// <summary>Nesting order: higher numbers nest first.</summary>
public int Priority { get; init; } = 0; public int Priority { get; init; } = 0;
} }
+9
View File
@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OpenNest.Avalonia.App"
RequestedThemeVariant="Light">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
</Application.Styles>
</Application>
+33
View File
@@ -0,0 +1,33 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace OpenNest.Avalonia
{
public partial class App : Application
{
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var window = new MainWindow();
desktop.MainWindow = window;
var args = desktop.Args ?? System.Array.Empty<string>();
if (args.Length > 0)
window.OpenNest(args[0]);
// --auto-nest: run Auto Nest on the opened nest (with --snapshot, before rendering).
if (System.Array.IndexOf(args, "--auto-nest") >= 0)
window.AutoNestOnOpen();
// --snapshot <png> [plate]: render the window to an image (status line in <png>.txt) and exit.
var snapshot = System.Array.IndexOf(args, "--snapshot");
if (snapshot >= 0 && snapshot + 1 < args.Length)
window.SaveSnapshotAndClose(args[snapshot + 1],
snapshot + 2 < args.Length && int.TryParse(args[snapshot + 2], out var plate) ? plate : 1);
}
base.OnFrameworkInitializationCompleted();
}
}
}
+388
View File
@@ -0,0 +1,388 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Skia;
using OpenNest.Avalonia.Rendering;
using OpenNest.CNC;
using OpenNest.Geometry;
using SkiaSharp;
using Control = Avalonia.Controls.Control;
using Vector = OpenNest.Geometry.Vector;
namespace OpenNest.Avalonia.Controls
{
/// <summary>
/// Draws one plate with Skia: pans with a left or middle drag, zooms at the cursor with the
/// wheel, fits on a double click, and highlights the part under the cursor.
/// </summary>
public sealed class PlateCanvas : Control
{
private static readonly SKColor BackgroundColor = new(169, 169, 169);
private static readonly SKColor PlateFillColor = new(245, 245, 245);
private static readonly SKColor PlateOutlineColor = new(128, 128, 128);
private static readonly SKColor EdgeSpacingColor = new(180, 180, 180);
private static readonly SKColor LeadColor = new(255, 0, 0);
private static readonly SKColor EtchColor = new(0, 150, 60);
private static readonly SKColor SelectedColor = new(150, 200, 255, 90 * 255 / 100);
// Paths are built once per program and only read afterwards, so the render thread may draw
// them while the UI thread builds others. Keyed by reference: a rotation replaces the program.
private readonly Dictionary<CNC.Program, ProgramPaths> paths = new();
private Plate plate;
private double scale = 1;
private Point origin;
private Point? dragStart;
private bool fitPending = true;
public Plate Plate
{
get => plate;
set
{
plate = value;
HoveredPart = null;
SelectedPart = null;
fitPending = true;
InvalidateVisual();
}
}
public Part HoveredPart { get; private set; }
public Part SelectedPart { get; private set; }
/// <summary>The cursor in plate coordinates, or null when it is outside the view.</summary>
public Vector? CursorWorld { get; private set; }
public event EventHandler ViewChanged;
public PlateCanvas()
{
ClipToBounds = true;
Focusable = true;
}
public void ClearCache() => paths.Clear();
public void ZoomToFit()
{
if (!Fit())
return;
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>Fits the view to the plate and its parts without requesting a redraw.</summary>
private bool Fit()
{
if (plate == null || Bounds.Width <= 0 || Bounds.Height <= 0)
return false;
var extents = PlateRect(plate);
foreach (var part in plate.Parts)
extents = Union(extents, part.BoundingBox);
const double margin = 20;
var sx = (Bounds.Width - 2 * margin) / System.Math.Max(extents.Length, 1e-6);
var sy = (Bounds.Height - 2 * margin) / System.Math.Max(extents.Width, 1e-6);
scale = System.Math.Min(sx, sy);
origin = new Point(
Bounds.Width / 2 - (extents.Left + extents.Length / 2) * scale,
Bounds.Height / 2 + (extents.Bottom + extents.Width / 2) * scale);
fitPending = false;
return true;
}
public double Zoom => scale;
private Vector ToWorld(Point p) => new((p.X - origin.X) / scale, (origin.Y - p.Y) / scale);
private SKMatrix WorldMatrix =>
new((float)scale, 0, (float)origin.X, 0, (float)-scale, (float)origin.Y, 0, 0, 1);
private static Box PlateRect(Plate 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;
return new Box(left, bottom, plate.Size.Length, plate.Size.Width);
}
private static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
return new Box(left, bottom,
System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
}
private ProgramPaths PathsFor(Part part)
{
if (!paths.TryGetValue(part.Program, out var result))
paths[part.Program] = result = ProgramPaths.Build(part.Program);
return result;
}
public override void Render(DrawingContext context)
{
// A redraw may not be requested during a render, so tell listeners afterwards.
if (fitPending && Fit())
global::Avalonia.Threading.Dispatcher.UIThread.Post(() => ViewChanged?.Invoke(this, EventArgs.Empty));
var items = new List<PartItem>();
if (plate != null)
{
for (var i = 0; i < plate.Parts.Count; i++)
{
var part = plate.Parts[i];
items.Add(new PartItem(PathsFor(part), part.Location, part.BoundingBox,
ToSkColor(part.BaseDrawing.Color), (i + 1).ToString(),
ReferenceEquals(part, SelectedPart), ReferenceEquals(part, HoveredPart),
part.BaseDrawing.IsCutOff, part.HasManualLeadIns));
}
}
context.Custom(new PlateDrawOperation(new Rect(Bounds.Size), plate == null ? null : PlateRect(plate),
plate?.EdgeSpacing, WorldMatrix, items));
}
private static SKColor ToSkColor(System.Drawing.Color color) =>
color.IsEmpty ? new SKColor(130, 204, 130) : new SKColor(color.R, color.G, color.B, color.A);
protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
{
var at = e.GetPosition(this);
var factor = System.Math.Pow(1.2, e.Delta.Y);
// Keep the world point under the cursor fixed.
origin = new Point(at.X - (at.X - origin.X) * factor, at.Y - (at.Y - origin.Y) * factor);
scale *= factor;
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
e.Handled = true;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
Focus();
var point = e.GetCurrentPoint(this);
if (e.ClickCount == 2)
{
ZoomToFit();
return;
}
if (point.Properties.IsLeftButtonPressed)
{
SelectedPart = HitTest(ToWorld(point.Position));
InvalidateVisual();
ViewChanged?.Invoke(this, EventArgs.Empty);
}
if (point.Properties.IsLeftButtonPressed || point.Properties.IsMiddleButtonPressed)
{
dragStart = point.Position;
e.Pointer.Capture(this);
}
}
protected override void OnPointerMoved(PointerEventArgs e)
{
var at = e.GetPosition(this);
if (dragStart is { } start)
{
origin += at - start;
dragStart = at;
InvalidateVisual();
}
CursorWorld = ToWorld(at);
var hovered = dragStart == null ? HitTest(CursorWorld.Value) : HoveredPart;
if (!ReferenceEquals(hovered, HoveredPart))
{
HoveredPart = hovered;
InvalidateVisual();
}
ViewChanged?.Invoke(this, EventArgs.Empty);
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
dragStart = null;
e.Pointer.Capture(null);
}
protected override void OnPointerExited(PointerEventArgs e)
{
CursorWorld = null;
if (HoveredPart != null)
{
HoveredPart = null;
InvalidateVisual();
}
ViewChanged?.Invoke(this, EventArgs.Empty);
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key == Key.F)
{
ZoomToFit();
e.Handled = true;
}
}
protected override void OnSizeChanged(global::Avalonia.Controls.SizeChangedEventArgs e)
{
base.OnSizeChanged(e);
if (fitPending)
InvalidateVisual();
}
/// <summary>The topmost part whose material contains <paramref name="world"/>.</summary>
private Part HitTest(Vector world)
{
if (plate == null)
return null;
for (var i = plate.Parts.Count - 1; i >= 0; i--)
{
var part = plate.Parts[i];
var box = part.BoundingBox;
if (world.X < box.Left || world.X > box.Right || world.Y < box.Bottom || world.Y > box.Top)
continue;
var local = world - part.Location;
if (PathsFor(part).Fill.Contains((float)local.X, (float)local.Y))
return part;
}
return null;
}
private sealed record PartItem(ProgramPaths Paths, Vector Location, Box Bounds, SKColor Color,
string Label, bool Selected, bool Hovered, bool IsCutOff, bool ShowLeads);
private sealed class PlateDrawOperation : ICustomDrawOperation
{
private readonly Box plateRect;
private readonly Spacing? edgeSpacing;
private readonly SKMatrix matrix;
private readonly List<PartItem> items;
public PlateDrawOperation(Rect bounds, Box plateRect, Spacing? edgeSpacing, SKMatrix matrix,
List<PartItem> items)
{
Bounds = bounds;
this.plateRect = plateRect;
this.edgeSpacing = edgeSpacing;
this.matrix = matrix;
this.items = items;
}
public Rect Bounds { get; }
public bool HitTest(Point p) => Bounds.Contains(p);
public bool Equals(ICustomDrawOperation other) => false;
public void Dispose()
{
}
public void Render(ImmediateDrawingContext context)
{
var lease = context.TryGetFeature<ISkiaSharpApiLeaseFeature>();
if (lease == null)
return;
using var api = lease.Lease();
var canvas = api.SkCanvas;
canvas.Save();
canvas.ClipRect(new SKRect(0, 0, (float)Bounds.Width, (float)Bounds.Height));
canvas.Clear(BackgroundColor);
if (plateRect != null)
Draw(canvas);
canvas.Restore();
}
private void Draw(SKCanvas canvas)
{
// Hairline strokes (width 0) stay one pixel wide at any zoom.
using var fill = new SKPaint { Style = SKPaintStyle.Fill, IsAntialias = true };
using var stroke = new SKPaint { Style = SKPaintStyle.Stroke, StrokeWidth = 0, IsAntialias = true };
canvas.Save();
var world = matrix;
canvas.Concat(ref world);
var plate = new SKRect((float)plateRect.Left, (float)plateRect.Bottom,
(float)plateRect.Right, (float)plateRect.Top);
fill.Color = PlateFillColor;
canvas.DrawRect(plate, fill);
if (edgeSpacing is { } spacing)
{
stroke.Color = EdgeSpacingColor;
canvas.DrawRect(new SKRect(plate.Left + (float)spacing.Left,
plate.Top + (float)spacing.Bottom, plate.Right - (float)spacing.Right,
plate.Bottom - (float)spacing.Top), stroke);
}
stroke.Color = PlateOutlineColor;
canvas.DrawRect(plate, stroke);
var view = canvas.LocalClipBounds;
foreach (var item in items)
{
var b = item.Bounds;
if (b.Right < view.Left || b.Left > view.Right || b.Top < view.Top || b.Bottom > view.Bottom)
continue;
canvas.Save();
canvas.Translate((float)item.Location.X, (float)item.Location.Y);
fill.Color = item.Selected ? SelectedColor
: item.Hovered ? Lighten(item.Color) : item.Color;
canvas.DrawPath(item.Paths.Fill, fill);
stroke.Color = EtchColor;
canvas.DrawPath(item.Paths.Etch, stroke);
stroke.Color = item.Selected ? new SKColor(30, 90, 200) : Darken(item.Color);
canvas.DrawPath(item.Paths.Cut, stroke);
// As on the desktop, only manually placed lead-ins are shown.
if (item.ShowLeads)
{
stroke.Color = LeadColor;
canvas.DrawPath(item.Paths.Lead, stroke);
}
canvas.Restore();
}
canvas.Restore();
DrawLabels(canvas);
}
// Ids are drawn in screen space and only where the part is big enough on screen to hold one.
private void DrawLabels(SKCanvas canvas)
{
using var text = new SKPaint { Color = SKColors.Black, IsAntialias = true, TextSize = 12 };
foreach (var item in items)
{
if (item.IsCutOff)
continue;
var center = matrix.MapPoint((float)item.Bounds.Center.X, (float)item.Bounds.Center.Y);
var width = item.Bounds.Length * matrix.ScaleX;
var height = item.Bounds.Width * -matrix.ScaleY;
var measured = text.MeasureText(item.Label);
if (measured + 4 > width || text.TextSize + 4 > height)
continue;
canvas.DrawText(item.Label, center.X - measured / 2, center.Y + text.TextSize / 3, text);
}
}
private static SKColor Darken(SKColor c) =>
new((byte)(c.Red * 0.55), (byte)(c.Green * 0.55), (byte)(c.Blue * 0.55), 255);
private static SKColor Lighten(SKColor c) =>
new((byte)(c.Red + (255 - c.Red) / 2), (byte)(c.Green + (255 - c.Green) / 2),
(byte)(c.Blue + (255 - c.Blue) / 2), c.Alpha);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:c="using:OpenNest.Avalonia.Controls"
xmlns:local="using:OpenNest.Avalonia"
x:Class="OpenNest.Avalonia.MainWindow"
Title="OpenNest (Avalonia prototype)"
Width="1400" Height="860">
<DockPanel>
<Menu DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
<Separator />
<MenuItem Header="E_xit" Click="OnExit" />
</MenuItem>
<MenuItem Header="_Nest">
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
<MenuItem Header="_Next Plate" InputGesture="PageDown" Click="OnNextPlate" />
</MenuItem>
</Menu>
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
<Button Content="Fit" Click="OnFit" />
<Button Content="Previous" Click="OnPreviousPlate" />
<Button Content="Next" Click="OnNextPlate" />
<Button x:Name="AutoNestButton" Content="Auto Nest" Click="OnAutoNest" />
</StackPanel>
</Border>
<Border DockPanel.Dock="Bottom" Padding="6,3" Background="#F0F0F0">
<TextBlock x:Name="Status" Text="Open a .nest file to begin." />
</Border>
<Grid ColumnDefinitions="260,4,*,4,320">
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Plates" />
<ListBox x:Name="PlateList" SelectionChanged="OnPlateSelected" />
</DockPanel>
<GridSplitter Grid.Column="1" ResizeDirection="Columns" />
<c:PlateCanvas Grid.Column="2" x:Name="Canvas" />
<GridSplitter Grid.Column="3" ResizeDirection="Columns" />
<DockPanel Grid.Column="4">
<TextBlock DockPanel.Dock="Top" Margin="6,6,6,2" FontWeight="SemiBold" Text="Drawings on plate" />
<DataGrid x:Name="DrawingGrid" x:DataType="local:DrawingRow" IsReadOnly="True"
AutoGenerateColumns="False" GridLinesVisibility="Horizontal" CanUserSortColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Drawing" Binding="{Binding Name}" Width="*" />
<DataGridTextColumn Header="Qty" Binding="{Binding Count}" />
<DataGridTextColumn Header="Size" Binding="{Binding Size}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Grid>
</DockPanel>
</Window>
+255
View File
@@ -0,0 +1,255 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using OpenNest.Desktop;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Forms;
using OpenNest.IO;
namespace OpenNest.Avalonia
{
public sealed record DrawingRow(string Name, int Count, string Size);
public partial class MainWindow : Window
{
private Document document;
private string fileName;
private string notice;
private CancellationTokenSource nesting;
private Task autoNest = Task.CompletedTask;
private Nest nest => document?.Nest;
public MainWindow()
{
InitializeComponent();
// As the desktop app: plug-in engines first, then the saved engine selection.
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
var engineWarning = EngineSelection.LoadSavedSelection();
if (!string.IsNullOrEmpty(engineWarning))
notice = engineWarning;
Canvas.ViewChanged += (_, _) => UpdateStatus();
KeyBindings.Add(new KeyBinding
{
Gesture = new KeyGesture(Key.O, KeyModifiers.Control),
Command = new Command(() => OnOpen(null, null)),
});
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageUp), Command = new Command(() => StepPlate(-1)) });
KeyBindings.Add(new KeyBinding { Gesture = new KeyGesture(Key.PageDown), Command = new Command(() => StepPlate(1)) });
}
public void OpenNest(string path)
{
if (nesting != null)
return;
try
{
document = new Document { Nest = new NestReader(path).Read() };
}
catch (Exception ex)
{
Status.Text = $"Could not open {Path.GetFileName(path)}: {ex.Message}";
return;
}
fileName = Path.GetFileName(path);
Title = $"{fileName} - OpenNest (Avalonia prototype)";
notice = null;
Canvas.ClearCache();
RefreshPlateList(0);
}
private void RefreshPlateList(int selectIndex)
{
PlateList.ItemsSource = nest.Plates
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
.ToList();
PlateList.SelectedIndex = nest.Plates.Count > 0 ? System.Math.Clamp(selectIndex, 0, nest.Plates.Count - 1) : -1;
}
/// <summary>Runs Auto Nest once the window opens, for --auto-nest.</summary>
public void AutoNestOnOpen() => Opened += (_, _) => autoNest = RunAutoNestAsync();
public void SaveSnapshotAndClose(string path, int plateNumber)
{
Opened += async (_, _) =>
{
if (nest?.Plates.Count > 0)
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
await autoNest;
await System.Threading.Tasks.Task.Delay(500);
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
bitmap.Render(this);
bitmap.Save(path);
// The status line beside the image, so headless checks can read it.
File.WriteAllText(path + ".txt", Status.Text);
Close();
};
}
private async void OnOpen(object sender, RoutedEventArgs e)
{
if (nesting != null)
return;
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open Nest",
AllowMultiple = false,
FileTypeFilter = new[] { new FilePickerFileType("Nest files") { Patterns = new[] { "*.nest" } } },
});
var path = files.FirstOrDefault()?.TryGetLocalPath();
if (path != null)
OpenNest(path);
}
private void OnExit(object sender, RoutedEventArgs e) => Close();
private void OnAutoNest(object sender, RoutedEventArgs e)
{
// Stop always cancels and discards the whole proposal.
if (nesting != null)
{
nesting.Cancel();
return;
}
autoNest = RunAutoNestAsync();
}
/// <summary>
/// Nests every drawing's remaining quantity on sheets like the shown plate through the
/// shared pipeline, off the UI thread. Open is refused until it finishes, so the nest
/// and its drawings stay stable from the request to the commit.
/// </summary>
private async Task RunAutoNestAsync()
{
if (nest == null || nesting != null)
return;
var engineName = EngineSelection.EngineName;
var template = Canvas.Plate ?? nest.Plates.FirstOrDefault() ?? nest.PlateDefaults.CreateNew();
var request = AutoNestJob.ForRemaining(nest, template, engineName);
if (request == null)
{
ShowNotice("Auto Nest: nothing has a remaining quantity to nest.");
return;
}
nesting = new CancellationTokenSource();
var token = nesting.Token;
SetNesting(true);
ShowNotice($"{engineName}: starting...");
var progress = new Progress<NestProgress>(p =>
{
if (!token.IsCancellationRequested)
ShowNotice(p.Description ?? $"{engineName}: plate {p.PlateNumber}");
});
try
{
var jobProgress = JobEngineNest.CreateProgress(engineName, progress);
var result = await Task.Run(() => NestPipeline.Run(request, jobProgress, token));
// Engines that ignore cancellation still have their proposal discarded.
token.ThrowIfCancellationRequested();
var firstNew = nest.Plates.Count(p => p.Parts.Count > 0);
var kept = AutoNestJob.Keep(nest, result);
if (kept.Nested)
RefreshPlateList(firstNew);
ShowNotice(kept.Nested ? $"{engineName}: {kept.Note}" : AutoNestJob.RefusalReason(result));
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
ShowNotice("Auto Nest stopped; nothing was changed.");
}
catch (Exception ex)
{
ShowNotice($"Auto Nest failed: {ex.Message}");
}
finally
{
nesting.Dispose();
nesting = null;
SetNesting(false);
}
}
private void SetNesting(bool running)
{
AutoNestButton.Content = running ? "Stop" : "Auto Nest";
AutoNestMenuItem.Header = running ? "_Stop Auto Nest" : "_Auto Nest";
OpenButton.IsEnabled = !running;
OpenMenuItem.IsEnabled = !running;
}
private void ShowNotice(string text)
{
notice = text;
UpdateStatus();
if (Canvas.Plate == null)
Status.Text = text;
}
private void OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
private void OnNextPlate(object sender, RoutedEventArgs e) => StepPlate(1);
private void StepPlate(int step)
{
if (nest == null || nest.Plates.Count == 0)
return;
PlateList.SelectedIndex = System.Math.Clamp(PlateList.SelectedIndex + step, 0, nest.Plates.Count - 1);
}
private void OnPlateSelected(object sender, SelectionChangedEventArgs e)
{
var index = PlateList.SelectedIndex;
var plate = nest != null && index >= 0 ? nest.Plates[index] : null;
Canvas.Plate = plate;
DrawingGrid.ItemsSource = plate?.Parts
.Where(part => !part.BaseDrawing.IsCutOff)
.GroupBy(part => part.BaseDrawing)
.Select(group => new DrawingRow(group.Key.Name, group.Count(), SizeOf(group.First())))
.OrderByDescending(row => row.Count)
.ToList();
UpdateStatus();
}
private static string SizeOf(Part part) =>
$"{part.BoundingBox.Length:0.###} x {part.BoundingBox.Width:0.###}";
private void UpdateStatus()
{
if (Canvas.Plate == null)
return;
var text = $"{fileName} Plate {PlateList.SelectedIndex + 1} of {nest.Plates.Count} Zoom {Canvas.Zoom:0.##} px/unit";
if (Canvas.CursorWorld is { } at)
text += $" X {at.X:0.000} Y {at.Y:0.000}";
var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
if (part != null)
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
if (notice != null)
text += $" | {notice}";
Status.Text = text;
}
private sealed class Command : System.Windows.Input.ICommand
{
private readonly Action action;
public Command(Action action) => this.action = action;
public event EventHandler CanExecuteChanged { add { } remove { } }
public bool CanExecute(object parameter) => true;
public void Execute(object parameter) => action();
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Avalonia</RootNamespace>
<AssemblyName>OpenNest.Avalonia</AssemblyName>
<Nullable>disable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.0" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
</ItemGroup>
</Project>
+18
View File
@@ -0,0 +1,18 @@
using System;
using Avalonia;
namespace OpenNest.Avalonia
{
internal static class Program
{
[STAThread]
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}
+175
View File
@@ -0,0 +1,175 @@
using System;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using SkiaSharp;
namespace OpenNest.Avalonia.Rendering
{
/// <summary>
/// A program's display geometry as Skia paths in the program frame, before placement.
/// A Skia port of the desktop's GraphicsHelper.GetDisplayPaths, without tab bridging.
/// </summary>
internal sealed class ProgramPaths : IDisposable
{
public SKPath Cut { get; } = new() { FillType = SKPathFillType.EvenOdd };
public SKPath Lead { get; } = new();
public SKPath Etch { get; } = new();
/// <summary>The material outline to fill: cuts and tabs, without open break cuts.</summary>
public SKPath Fill { get; } = new() { FillType = SKPathFillType.EvenOdd };
public SKRect Bounds { get; private set; }
public static ProgramPaths Build(CNC.Program program)
{
var paths = new ProgramPaths();
var position = Vector.Zero;
paths.AddProgram(program, ref position);
var bounds = paths.Cut.Bounds;
if (!paths.Etch.IsEmpty)
bounds = paths.Cut.IsEmpty ? paths.Etch.Bounds : SKRect.Union(bounds, paths.Etch.Bounds);
paths.Bounds = bounds;
return paths;
}
private SKPath TargetFor(LayerType layer) => layer switch
{
LayerType.Leadin or LayerType.Leadout => Lead,
LayerType.Scribe => Etch,
LayerType.Display or LayerType.Tab => null,
_ => Cut,
};
private void AddProgram(CNC.Program program, ref Vector position)
{
// Sub-program offsets are relative to the frame origin at entry, not the tool position.
var frameOrigin = position;
var mode = program.Mode;
SKPath open = null;
for (var i = 0; i < program.Length; ++i)
{
var code = program[i];
switch (code)
{
case LinearMove line:
{
var end = mode == Mode.Incremental ? line.EndPoint + position : line.EndPoint;
var target = line.Suppressed ? null : TargetFor(line.Layer);
if (target != null)
{
Continue(ref open, target, position);
target.LineTo((float)end.X, (float)end.Y);
}
if (!line.Suppressed && line.Layer != LayerType.Break)
AddFillLine(line.Layer, position, end);
position = end;
break;
}
case ArcMove arc:
{
var end = arc.EndPoint;
var center = arc.CenterPoint;
if (mode == Mode.Incremental)
{
end += position;
center += position;
}
var target = arc.Suppressed ? null : TargetFor(arc.Layer);
if (target != null)
{
Continue(ref open, target, position);
AddArc(target, position, end, center, arc.Rotation);
}
if (!arc.Suppressed && arc.Layer != LayerType.Break)
AddFillArc(arc.Layer, position, end, center, arc.Rotation);
position = end;
break;
}
case RapidMove rapid:
{
var end = mode == Mode.Incremental ? rapid.EndPoint + position : rapid.EndPoint;
if (end.DistanceTo(position) > 0.001)
open = null;
position = end;
break;
}
case SubProgramCall call when call.Program != null:
open = null;
position = new Vector(frameOrigin.X + call.Offset.X, frameOrigin.Y + call.Offset.Y);
AddProgram(call.Program, ref position);
open = null;
break;
}
}
}
// Starts a new contour unless the move continues the one already open on the same path.
private SKPath openFill;
private static void Continue(ref SKPath open, SKPath target, Vector from)
{
if (!ReferenceEquals(open, target) || target.LastPoint != new SKPoint((float)from.X, (float)from.Y))
target.MoveTo((float)from.X, (float)from.Y);
open = target;
}
private bool IsMaterial(LayerType layer) =>
layer is not (LayerType.Scribe or LayerType.Leadin or LayerType.Leadout or LayerType.Display);
private void AddFillLine(LayerType layer, Vector from, Vector to)
{
if (!IsMaterial(layer))
return;
Continue(ref openFill, Fill, from);
Fill.LineTo((float)to.X, (float)to.Y);
}
private void AddFillArc(LayerType layer, Vector from, Vector to, Vector center, RotationType rotation)
{
if (!IsMaterial(layer))
return;
Continue(ref openFill, Fill, from);
AddArc(Fill, from, to, center, rotation);
}
private static void AddArc(SKPath path, Vector from, Vector to, Vector center, RotationType rotation)
{
var startAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(from.Y - center.Y, from.X - center.X)));
var endAngle = Angle.NormalizeDeg(Angle.ToDegrees(System.Math.Atan2(to.Y - center.Y, to.X - center.X)));
var radius = (float)center.DistanceTo(to);
var oval = new SKRect((float)center.X - radius, (float)center.Y - radius,
(float)center.X + radius, (float)center.Y + radius);
if (startAngle.IsEqualTo(endAngle))
{
// A full circle: two half sweeps keep the contour starting at the current point.
var sweep = rotation == RotationType.CCW ? 180f : -180f;
path.ArcTo(oval, (float)startAngle, sweep, false);
path.ArcTo(oval, (float)startAngle + sweep, sweep, false);
return;
}
if (rotation == RotationType.CCW && endAngle < startAngle)
endAngle += 360.0;
else if (rotation == RotationType.CW && startAngle < endAngle)
startAngle += 360.0;
path.ArcTo(oval, (float)startAngle, (float)(endAngle - startAngle), false);
}
public void Dispose()
{
Cut.Dispose();
Lead.Dispose();
Etch.Dispose();
Fill.Dispose();
}
}
}
+40 -11
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@@ -37,9 +38,19 @@ namespace OpenNest.Benchmark
public double PartSpacing { get; init; } public double PartSpacing { get; init; }
public int Quadrant { get; init; } public int Quadrant { get; init; }
/// <summary>Source plate's machine clamp rail, resolved for each candidate size as Auto Nest does; null when none.</summary>
public ClampRail ClampRail { get; init; }
/// <summary>Source plate's explicit clamp zones when it has no rail, offered unchanged on every size.</summary>
public IReadOnlyList<ClampZone> ClampZones { get; init; } = Array.Empty<ClampZone>();
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary> /// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
public double SalvageRate { get; init; } public double SalvageRate { get; init; }
/// <summary>Manifest "offcutCut": which edge strips count as a sheet's offcut;
/// null for all.</summary>
public CutOffAxis? OffcutCut { get; init; }
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary> /// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; } public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
public List<DrawingRequest> Requests { get; init; } public List<DrawingRequest> Requests { get; init; }
@@ -60,7 +71,8 @@ namespace OpenNest.Benchmark
/// (unlimited quantity - the engine under test decides how many of each /// (unlimited quantity - the engine under test decides how many of each
/// size it actually uses, and how demand splits across plates). The /// size it actually uses, and how demand splits across plates). The
/// engine owns its own multi-plate/size strategy; this harness no /// engine owns its own multi-plate/size strategy; this harness no
/// longer picks plate sizes on the engine's behalf. /// longer picks plate sizes on the engine's behalf. Stock comes from
/// the same template builder as desktop Auto Nest, so clamps match it.
/// </summary> /// </summary>
public NestJob BuildNestJob( public NestJob BuildNestJob(
int maxPlates, int maxPlates,
@@ -71,24 +83,41 @@ namespace OpenNest.Benchmark
var parts = Requests.Select(r => var parts = Requests.Select(r =>
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity) DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity)
); );
var stock = CandidateSizes.Select(size => new NestPlateStock( // No options would make the builder offer the template itself; an empty pool stays empty.
size.ToString(1), var stock = CandidateSizes.Count == 0
size, ? Array.Empty<NestPlateStock>()
null, : NestStockBuilder.FromTemplate(
PartSpacing, StockTemplate(),
EdgeSpacing, CandidateSizes.Select(size => new PlateOption { Width = size.Width, Length = size.Length }).ToList()
Quadrant );
));
return new NestJob( return new NestJob(
parts, parts,
stock, stock,
new NestJobOptions( new NestJobOptions(
"Fill", "Lattice",
maxPlates, maxPlates,
salvageRate ?? SalvageRate, salvageRate ?? SalvageRate,
minimumSalvageDimension ?? 0 minimumSalvageDimension ?? 0,
offcutCut: OffcutCut
) )
); );
} }
/// <summary>A plate carrying this job's spacing, quadrant and clamps, sized to the largest candidate.</summary>
private Plate StockTemplate()
{
var template = new Plate(CandidateSizes.MaxBy(size => size.Width * size.Length))
{
PartSpacing = PartSpacing,
EdgeSpacing = EdgeSpacing,
Quadrant = Quadrant,
};
if (ClampRail != null)
template.SetClampRail(ClampRail);
else
foreach (var zone in ClampZones)
template.ClampZones.Add(zone);
return template;
}
} }
} }
+31 -14
View File
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.NestingEngines;
namespace OpenNest.Benchmark namespace OpenNest.Benchmark
{ {
@@ -28,6 +29,15 @@ namespace OpenNest.Benchmark
/// <summary>Wall-clock budget for one engine solving one job.</summary> /// <summary>Wall-clock budget for one engine solving one job.</summary>
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
/// <summary>How long past a --time-limit the harness waits before abandoning a solve.
/// </summary>
private static readonly TimeSpan TimeLimitGrace = TimeSpan.FromMinutes(1);
/// <summary>The longest --time-limit: the harness timeout above it must still fit a
/// cancellation timer.</summary>
internal static readonly TimeSpan MaxTimeLimit =
DeadlineNestingEngine.MaxLimit - TimeLimitGrace;
public static List<JobResult> Run( public static List<JobResult> Run(
List<BenchmarkJob> jobs, List<BenchmarkJob> jobs,
IReadOnlyList<NestingEngineInfo> engines, IReadOnlyList<NestingEngineInfo> engines,
@@ -35,7 +45,8 @@ namespace OpenNest.Benchmark
double? minimumSalvageDimension = null, double? minimumSalvageDimension = null,
string outputDirectory = null, string outputDirectory = null,
int maxParallelism = 1, int maxParallelism = 1,
System.IO.TextWriter progressLog = null System.IO.TextWriter progressLog = null,
TimeSpan? timeLimit = null
) )
{ {
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine))) var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
@@ -71,7 +82,8 @@ namespace OpenNest.Benchmark
salvageRate, salvageRate,
minimumSalvageDimension, minimumSalvageDimension,
outputDirectory, outputDirectory,
progressLog progressLog,
timeLimit
) )
); );
@@ -126,14 +138,7 @@ namespace OpenNest.Benchmark
.BaselinePlateRuns.Select( .BaselinePlateRuns.Select(
(run, index) => (run, index) =>
{ {
var stock = new NestPlateStock( var stock = DrawingJobMapper.FromPlate($"baseline-{index}", run.Plate, 1);
$"baseline-{index}",
run.Plate.Size,
1,
run.Plate.PartSpacing,
run.Plate.EdgeSpacing,
run.Plate.Quadrant
);
var placements = run var placements = run
.Parts.Select(part => .Parts.Select(part =>
{ {
@@ -221,13 +226,21 @@ namespace OpenNest.Benchmark
} }
} }
/// <summary>The harness's own limit on a solve: five minutes, or a --time-limit plus a
/// grace period, so the engine's limit always fires first.</summary>
private static TimeSpan HarnessTimeout(TimeSpan? timeLimit) =>
timeLimit is { } limit && limit + TimeLimitGrace > SolveTimeout
? limit + TimeLimitGrace
: SolveTimeout;
private static JobResult RunOne( private static JobResult RunOne(
BenchmarkJob job, BenchmarkJob job,
NestingEngineInfo engineInfo, NestingEngineInfo engineInfo,
double? salvageRate, double? salvageRate,
double? minimumSalvageDimension, double? minimumSalvageDimension,
string outputDirectory, string outputDirectory,
System.IO.TextWriter progressLog System.IO.TextWriter progressLog,
TimeSpan? timeLimit
) )
{ {
var requested = job.TotalRequestedQuantity; var requested = job.TotalRequestedQuantity;
@@ -241,7 +254,9 @@ namespace OpenNest.Benchmark
{ {
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension); var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
var engine = engineInfo.Factory(); var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(SolveTimeout); if (timeLimit is { } limit)
engine = new DeadlineNestingEngine(engine, limit);
using var cts = new CancellationTokenSource(HarnessTimeout(timeLimit));
var jobResult = engine.Solve(nestJob, log, cts.Token); var jobResult = engine.Solve(nestJob, log, cts.Token);
log?.Finished(jobResult, sw.ElapsedMilliseconds); log?.Finished(jobResult, sw.ElapsedMilliseconds);
@@ -363,7 +378,7 @@ namespace OpenNest.Benchmark
ElapsedMs = sw.ElapsedMilliseconds, ElapsedMs = sw.ElapsedMilliseconds,
}; };
} }
catch (OperationCanceledException) catch (Exception ex) when (ex is OperationCanceledException || ex is TimeoutException)
{ {
sw.Stop(); sw.Stop();
log?.Failed("timed out", sw.ElapsedMilliseconds); log?.Failed("timed out", sw.ElapsedMilliseconds);
@@ -375,7 +390,9 @@ namespace OpenNest.Benchmark
PartsRequested = requested, PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty, UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds, ElapsedMs = sw.ElapsedMilliseconds,
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)", Error = ex is TimeoutException
? ex.Message
: $"Timed out after {HarnessTimeout(timeLimit).TotalMinutes:0.#} minute(s)",
}; };
} }
catch (Exception ex) catch (Exception ex)
+64 -3
View File
@@ -52,9 +52,24 @@ namespace OpenNest.Benchmark
PartSpacing = partSpacingOverride ?? manifest.Spacing, PartSpacing = partSpacingOverride ?? manifest.Spacing,
Quadrant = manifest.Quadrant, Quadrant = manifest.Quadrant,
Requests = requests, Requests = requests,
OffcutCut = OffcutCut(manifest.OffcutCut, manifestPath),
}; };
} }
/// <summary>"horizontal" or "vertical" (any case); absent counts every edge strip.
/// </summary>
private static CutOffAxis? OffcutCut(string value, string manifestPath) =>
value?.ToLowerInvariant() switch
{
null => null,
"horizontal" => CutOffAxis.Horizontal,
"vertical" => CutOffAxis.Vertical,
_ => throw new InvalidOperationException(
$"Manifest '{manifestPath}' has offcutCut \"{value}\"; "
+ "use \"horizontal\" or \"vertical\"."
),
};
private static Manifest ReadManifest(string manifestPath) private static Manifest ReadManifest(string manifestPath)
{ {
try try
@@ -135,12 +150,18 @@ namespace OpenNest.Benchmark
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}"); throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly. // A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
if (!part.AllowRotation) // Quarter turns are a 90° sweep from 0 to 270°; automatic rotation allows any angle.
var rotations = RotationSet(part);
if (rotations != RotationChoice.Any)
{ {
var quarter = rotations == RotationChoice.QuarterTurns;
drawing.Constraints ??= new NestConstraints(); drawing.Constraints ??= new NestConstraints();
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI; drawing.Constraints.StepAngle = quarter
? OpenNest.Math.Angle.HalfPI
: OpenNest.Math.Angle.TwoPI;
drawing.Constraints.StartAngle = 0; drawing.Constraints.StartAngle = 0;
drawing.Constraints.EndAngle = 0; drawing.Constraints.EndAngle = quarter ? 3 * OpenNest.Math.Angle.HalfPI : 0;
drawing.Constraints.Allow180Equivalent = rotations == RotationChoice.HalfTurns;
} }
var constraints = drawing.Constraints; var constraints = drawing.Constraints;
@@ -156,12 +177,51 @@ namespace OpenNest.Benchmark
}; };
} }
private enum RotationChoice
{
None,
HalfTurns,
QuarterTurns,
Any,
}
/// <summary>
/// A part's "rotations" (degrees) must be {0}, {0, 180} or {0, 90, 180, 270}; without it,
/// "allowRotation" picks any angle (true, the default: automatic rotation) or none.
/// </summary>
private static RotationChoice RotationSet(ManifestPart part)
{
if (part.Rotations == null)
return part.AllowRotation ? RotationChoice.Any : RotationChoice.None;
if (!part.AllowRotation)
throw new InvalidOperationException(
$"Manifest part '{part.Dxf}': set \"rotations\" or \"allowRotation\", not both."
);
var set = part.Rotations
.Select(degrees => ((degrees % 360) + 360) % 360)
.Distinct()
.OrderBy(degrees => degrees)
.ToArray();
if (set.SequenceEqual(new[] { 0.0 }))
return RotationChoice.None;
if (set.SequenceEqual(new[] { 0.0, 180.0 }))
return RotationChoice.HalfTurns;
if (set.SequenceEqual(new[] { 0.0, 90.0, 180.0, 270.0 }))
return RotationChoice.QuarterTurns;
throw new InvalidOperationException(
$"Manifest part '{part.Dxf}': \"rotations\" must be [0], [0, 180] or "
+ $"[0, 90, 180, 270] (was [{string.Join(", ", part.Rotations)}])."
);
}
private class Manifest private class Manifest
{ {
public List<string> SheetSizes { get; set; } public List<string> SheetSizes { get; set; }
public double Spacing { get; set; } public double Spacing { get; set; }
public double EdgeSpacing { get; set; } public double EdgeSpacing { get; set; }
public int Quadrant { get; set; } = 1; public int Quadrant { get; set; } = 1;
public string OffcutCut { get; set; }
public List<ManifestPart> Parts { get; set; } public List<ManifestPart> Parts { get; set; }
} }
@@ -170,6 +230,7 @@ namespace OpenNest.Benchmark
public string Dxf { get; set; } public string Dxf { get; set; }
public int Quantity { get; set; } public int Quantity { get; set; }
public bool AllowRotation { get; set; } = true; public bool AllowRotation { get; set; } = true;
public List<double> Rotations { get; set; }
} }
} }
} }
+29 -5
View File
@@ -74,6 +74,8 @@ namespace OpenNest.Benchmark
EdgeSpacing = template.EdgeSpacing, EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing, PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant, Quadrant = template.Quadrant,
ClampRail = template.ClampRail,
ClampZones = template.ClampZones,
SalvageRate = nest.SalvageRate, SalvageRate = nest.SalvageRate,
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride), BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
Requests = requests, Requests = requests,
@@ -182,23 +184,45 @@ namespace OpenNest.Benchmark
plate.EdgeSpacing.Top plate.EdgeSpacing.Top
), ),
}; };
// Clamps are part of the sheet the layout was made on.
if (plate.ClampRail != null)
validationPlate.SetClampRail(plate.ClampRail, plate.RemovedClamps);
else
foreach (var zone in plate.ClampZones)
validationPlate.ClampZones.Add(zone);
for (var copy = 0; copy < plate.Quantity; copy++) for (var copy = 0; copy < plate.Quantity; copy++)
runs.Add((validationPlate, parts)); runs.Add((validationPlate, parts));
} }
return runs.Count > 0 ? runs : null; return runs.Count > 0 ? runs : null;
} }
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate( private static (
Nest nest Spacing EdgeSpacing,
) double PartSpacing,
int Quadrant,
ClampRail ClampRail,
IReadOnlyList<ClampZone> ClampZones
) ResolvePlateTemplate(Nest nest)
{ {
var source = nest.Plates?.FirstOrDefault(); var source = nest.Plates?.FirstOrDefault();
if (source != null) if (source != null)
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant); return (
source.EdgeSpacing,
source.PartSpacing,
source.Quadrant,
source.ClampRail,
source.ClampRail == null ? source.ClampZones.ToList() : Array.Empty<ClampZone>()
);
var defaults = nest.PlateDefaults; var defaults = nest.PlateDefaults;
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant); return (
defaults.EdgeSpacing,
defaults.PartSpacing,
defaults.Quadrant,
defaults.ClampRail,
Array.Empty<ClampZone>()
);
} }
private static List<Size> ResolveSheetSizes(Nest nest) private static List<Size> ResolveSheetSizes(Nest nest)
+41 -1
View File
@@ -18,6 +18,9 @@ static class BenchmarkConsole
if (options == null) if (options == null)
return 0; // --help was requested return 0; // --help was requested
if (options.Invalid)
return 1;
if (options.InputPath == null) if (options.InputPath == null)
{ {
PrintUsage(); PrintUsage();
@@ -124,7 +127,8 @@ static class BenchmarkConsole
options.MinimumSalvageDimension, options.MinimumSalvageDimension,
options.OutputDirectory, options.OutputDirectory,
options.Parallel, options.Parallel,
options.Progress ? Console.Out : null options.Progress ? Console.Out : null,
options.TimeLimit
); );
Report.PrintDetailed(results); Report.PrintDetailed(results);
@@ -197,6 +201,18 @@ static class BenchmarkConsole
); );
break; break;
case "--time-limit":
o.TimeLimit = i + 1 < args.Length ? ParseTimeLimit(args[++i]) : null;
if (o.TimeLimit == null)
{
Console.Error.WriteLine(
"Error: --time-limit needs a number of seconds from 0.001 to "
+ $"{BenchmarkRunner.MaxTimeLimit.TotalSeconds:0}"
);
o.Invalid = true;
}
break;
case "--progress": case "--progress":
o.Progress = true; o.Progress = true;
break; break;
@@ -235,6 +251,22 @@ static class BenchmarkConsole
return sizes.Distinct().ToList(); return sizes.Distinct().ToList();
} }
private static TimeSpan? ParseTimeLimit(string text)
{
if (
!double.TryParse(
text,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var seconds
)
|| !(seconds >= 0.001)
|| seconds > BenchmarkRunner.MaxTimeLimit.TotalSeconds
)
return null;
return TimeSpan.FromSeconds(seconds);
}
private static void PrintUsage() private static void PrintUsage()
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
@@ -330,6 +362,12 @@ static class BenchmarkConsole
Console.Error.WriteLine( Console.Error.WriteLine(
" --progress Log each solve's start, engine progress and finish" " --progress Log each solve's start, engine progress and finish"
); );
Console.Error.WriteLine(
" --time-limit <seconds> Stop each solve at this wall-clock limit and score"
);
Console.Error.WriteLine(
" the best valid plan reported; none = timed out"
);
Console.Error.WriteLine(" --help Show this message"); Console.Error.WriteLine(" --help Show this message");
} }
@@ -345,5 +383,7 @@ static class BenchmarkConsole
public double? MinimumSalvageDimension; public double? MinimumSalvageDimension;
public int Parallel = 3; public int Parallel = 3;
public bool Progress; public bool Progress;
public TimeSpan? TimeLimit;
public bool Invalid;
} }
} }
+104 -21
View File
@@ -35,7 +35,7 @@ static class NestConsole
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
Console.Error.WriteLine("Nesting cancelled; nothing saved."); Console.Error.WriteLine("Nesting canceled; nothing saved.");
return 2; return 2;
} }
catch (Exception ex) catch (Exception ex)
@@ -115,7 +115,7 @@ static class NestConsole
Console.Error.WriteLine( Console.Error.WriteLine(
engine.IsJobEngine engine.IsJobEngine
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines." ? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)" : $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as Irregular require --autonest)"
); );
return 1; return 1;
} }
@@ -132,14 +132,26 @@ static class NestConsole
if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null if (options.NoFill && (options.AutoNest || options.KeepParts || options.DrawingName != null
|| options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue || options.Quantity != 0 || options.Spacing.HasValue || options.PlateSize.HasValue
|| options.TemplateFile != null || options.InputFiles.Any(IsCadFile))) || options.OutlineFile != null || options.TemplateFile != null || options.InputFiles.Any(IsCadFile)))
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
"Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size or --template." "Error: --no-fill keeps the nest as loaded; it cannot be combined with CAD inputs, --autonest, --keep-parts, --drawing, --quantity, --spacing, --size, --outline or --template."
); );
return 1; return 1;
} }
if (options.OutlineFile != null && !options.AutoNest)
{
Console.Error.WriteLine("Error: --outline needs --autonest; single-plate fill strategies fill rectangles only.");
return 1;
}
if (options.OutlineFile != null && !File.Exists(options.OutlineFile))
{
Console.Error.WriteLine($"Error: Outline file not found: {options.OutlineFile}");
return 1;
}
if (options.InputFiles.Count == 0) if (options.InputFiles.Count == 0)
{ {
PrintUsage(); PrintUsage();
@@ -197,6 +209,12 @@ static class NestConsole
ApplyTemplate(plate, options); ApplyTemplate(plate, options);
ApplyOverrides(plate, options); ApplyOverrides(plate, options);
if (!options.AutoNest && PlateFillService.Refusal(plate) is { } refusal)
{
Console.Error.WriteLine($"Error: {refusal} Use --autonest.");
return 2;
}
var drawing = ResolveDrawing(nest, options); var drawing = ResolveDrawing(nest, options);
if (drawing == null) if (drawing == null)
@@ -257,6 +275,9 @@ static class NestConsole
_ => BendRepairUnits.Unspecified, _ => BendRepairUnits.Unspecified,
}; };
break; break;
case "--svg-units-per-inch" when i + 1 < args.Length:
o.SvgUnitsPerInch = double.Parse(args[++i], CultureInfo.InvariantCulture);
break;
case "--drawing" when i + 1 < args.Length: case "--drawing" when i + 1 < args.Length:
o.DrawingName = args[++i]; o.DrawingName = args[++i];
break; break;
@@ -276,6 +297,9 @@ static class NestConsole
if (Size.TryParse(args[++i], out var sz)) if (Size.TryParse(args[++i], out var sz))
o.PlateSize = sz; o.PlateSize = sz;
break; break;
case "--outline" when i + 1 < args.Length:
o.OutlineFile = args[++i];
break;
case "--check-overlaps": case "--check-overlaps":
o.CheckOverlaps = true; o.CheckOverlaps = true;
break; break;
@@ -384,9 +408,10 @@ static class NestConsole
return stock; return stock;
} }
static bool IsCadFile(string path) => static bool IsCadFile(string path) => CadFormats.IsCadFile(path);
path.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| path.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase); static Units DrawingUnits(Options options) =>
options.CadUnits == BendRepairUnits.Millimeters ? Units.Millimeters : Units.Inches;
static Nest LoadOrCreateNest(Options options) static Nest LoadOrCreateNest(Options options)
{ {
@@ -432,7 +457,7 @@ static class NestConsole
// DXF-only mode: create a fresh nest. // DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0) if (dxfFiles.Count == 0)
{ {
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified"); Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg/.svg) files specified");
return null; return null;
} }
@@ -479,8 +504,12 @@ static class NestConsole
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value, MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
} }
: null, : null,
DrawingUnits = DrawingUnits(options),
SvgUserUnitsPerInch = options.SvgUnitsPerInch,
} }
); );
foreach (var warning in result.Warnings)
Console.Error.WriteLine($"Warning: {Path.GetFileName(path)}: {warning}");
foreach (var report in result.BendRepairReports) foreach (var report in result.BendRepairReports)
Console.WriteLine( Console.WriteLine(
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})" $"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
@@ -526,6 +555,22 @@ static class NestConsole
if (options.PlateSize.HasValue && !hasDxfOnly) if (options.PlateSize.HasValue && !hasDxfOnly)
plate.Size = options.PlateSize.Value; plate.Size = options.PlateSize.Value;
if (options.OutlineFile != null)
{
// The outline is the sheet: its bounds replace any size given.
plate.Outline = SheetOutline.FromGeometry(
CadImporter.Import(
options.OutlineFile,
new CadImportOptions
{
DrawingUnits = DrawingUnits(options),
SvgUserUnitsPerInch = options.SvgUnitsPerInch,
}
).Entities
);
Console.WriteLine($"Outline: {options.OutlineFile} ({plate.Outline})");
}
} }
static Drawing ResolveDrawing(Nest nest, Options options) static Drawing ResolveDrawing(Nest nest, Options options)
@@ -589,17 +634,13 @@ static class NestConsole
if (options.AutoNest) if (options.AutoNest)
{ {
var nestItems = new List<NestItem>(); var nestItems = AutoNestItems(nest, plate, drawing, options);
var qty = options.Quantity > 0 ? options.Quantity : 1; if (nestItems.Count == 0)
if (options.DrawingName != null)
{ {
nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty }); Console.Error.WriteLine(
} "Error: other plates already hold every required part. Use --quantity to nest more. Nothing saved."
else );
{ return (false, sw.ElapsedMilliseconds, false, plates);
foreach (var d in nest.Drawings)
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
} }
Console.WriteLine( Console.WriteLine(
@@ -637,6 +678,38 @@ static class NestConsole
return (success, sw.ElapsedMilliseconds, accepted, plates); return (success, sw.ElapsedMilliseconds, accepted, plates);
} }
/// <summary>
/// The job's demand: --quantity of each drawing when given, otherwise each drawing's
/// required quantity less the copies on other plates (the target's parts are replaced).
/// As in Desktop Auto Nest, a drawing without a required quantity is left out, unless no
/// drawing has one: then one of each is nested.
/// </summary>
static List<NestItem> AutoNestItems(Nest nest, Plate target, Drawing drawing, Options options)
{
var drawings = (options.DrawingName != null ? new[] { drawing } : nest.Drawings.ToArray())
.Where(d => !d.IsCutOff)
.ToList();
var anyRequired = drawings.Any(d => d.Quantity.Required > 0);
int Demand(Drawing d)
{
if (options.Quantity > 0)
return options.Quantity;
if (!anyRequired)
return 1;
var elsewhere = nest.Plates
.Where(p => p != target)
.Sum(p => p.Parts.Count(part => part.BaseDrawing == d) * p.Quantity);
return d.Quantity.Required - elsewhere;
}
return drawings
.Select(d => new NestItem { Drawing = d, Quantity = Demand(d) })
.Where(item => item.Quantity > 0)
.ToList();
}
static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob( static (bool accepted, int committed, IReadOnlyList<Plate> plates) AutoNestJob(
Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token) Nest nest, Plate plate, List<NestItem> nestItems, Options options, CancellationToken token)
{ {
@@ -824,6 +897,7 @@ static class NestConsole
{ {
CuttingPlanPhase.Reordering => "finding a part order...", CuttingPlanPhase.Reordering => "finding a part order...",
CuttingPlanPhase.KeepingOrder => "keeping the current order...", CuttingPlanPhase.KeepingOrder => "keeping the current order...",
CuttingPlanPhase.SafestRoute => "finding the safest route...",
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...", CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
_ => value.Phase + "...", _ => value.Phase + "...",
}); });
@@ -980,7 +1054,7 @@ static class NestConsole
Usage: OpenNest.Console <input-files...> [options] Usage: OpenNest.Console <input-files...> [options]
Arguments: Arguments:
input-files One or more .nest nest files or .dxf/.dwg drawing files input-files One or more .nest nest files or .dxf/.dwg/.svg drawing files
Modes: Modes:
<nest.nest> Load nest and fill (existing behavior) <nest.nest> Load nest and fill (existing behavior)
@@ -989,12 +1063,19 @@ static class NestConsole
Options: Options:
--repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm --repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm
--cad-units inches|mm Explicit source coordinate units required for bend repair --cad-units inches|mm Explicit source coordinate units required for bend repair;
also the units SVGs import in (default inches)
--svg-units-per-inch N SVG units per inch, for a file without a real-world size
--drawing <name> Drawing name to fill with (default: first drawing) --drawing <name> Drawing name to fill with (default: first drawing)
--plate <index> Plate index to fill (default: 0) --plate <index> Plate index to fill (default: 0)
--quantity <n> Max parts to place (default: 0 = unlimited) --quantity <n> Max parts to place (default: 0 = unlimited). With --autonest: copies of
each drawing (default: its required quantity less copies on other
plates; one of each when no drawing has a required quantity)
--spacing <value> Override part spacing --spacing <value> Override part spacing
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode --size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--outline <dxf> Make the plate an irregular sheet: the file's largest closed contour is
the sheet, other closed contours are obstacles; sets the size. Only
--autonest with the Irregular (or Default) engine nests on it
--output <path> Output nest file path (default: <input>-result.nest) --output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing) --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
--autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance --autonest Validated whole-job nesting (one sheet unless --max-sheets); replaces only after acceptance
@@ -1036,6 +1117,7 @@ static class NestConsole
public int Quantity; public int Quantity;
public double? Spacing; public double? Spacing;
public Size? PlateSize; public Size? PlateSize;
public string OutlineFile;
public bool CheckOverlaps; public bool CheckOverlaps;
public bool NoSave; public bool NoSave;
public bool KeepParts; public bool KeepParts;
@@ -1058,5 +1140,6 @@ static class NestConsole
public bool AllPlates; public bool AllPlates;
public double? RepairBendsMillimeters; public double? RepairBendsMillimeters;
public BendRepairUnits CadUnits; public BendRepairUnits CadUnits;
public double? SvgUnitsPerInch;
} }
} }
+211 -33
View File
@@ -27,6 +27,14 @@ public sealed class AutomaticCutOffOptions
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing. /// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
/// </summary> /// </summary>
public double? MinimumStripLength { get; set; } public double? MinimumStripLength { get; set; }
/// <summary>
/// Whether to propose a full-length horizontal separator that keeps the unused band beyond the
/// parts along Y (away from the origin) when that band is a larger piece than the X tail.
/// The band must be at least MinimumTailLength wide. False preserves vertical-only proposals.
/// An existing full-length horizontal cut-off clear of the parts is honored either way.
/// </summary>
public bool KeepHorizontalRemnant { get; set; }
} }
public enum AutomaticCutOffDiagnosticCode public enum AutomaticCutOffDiagnosticCode
@@ -39,6 +47,8 @@ public enum AutomaticCutOffDiagnosticCode
NoSafeTailSeparator, NoSafeTailSeparator,
TailBelowMinimum, TailBelowMinimum,
ShiftedCut, ShiftedCut,
HorizontalRemnant,
NoSafeHorizontalSeparator,
} }
public sealed record AutomaticCutOffDiagnostic( public sealed record AutomaticCutOffDiagnostic(
@@ -77,6 +87,15 @@ 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>
/// Signed Y coordinate of the new or existing full-length horizontal separator the vertical
/// lines stop at, keeping the band beyond it; null when the lines cross the full sheet width.
/// </summary>
public double? HorizontalSeparatorY { get; internal set; }
/// <summary>Width along Y of the band kept beyond HorizontalSeparatorY, otherwise zero.</summary>
public double HorizontalRemnantWidth { get; internal set; }
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary> /// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
public double StripCutLength { get; internal set; } public double StripCutLength { get; internal set; }
@@ -90,6 +109,15 @@ public sealed class AutomaticCutOffPlan
public static class AutomaticCutOffPlanner public static class AutomaticCutOffPlanner
{ {
public const double GeometryTolerance = Tolerance.Epsilon; public const double GeometryTolerance = Tolerance.Epsilon;
/// <summary>
/// How far a display perimeter (a supplied blank's never-cut outline) may extend past the
/// physical sheet, in model units. Imported blanks equal their sheet but carry the source CAM's
/// rounding (PEP: a 0.000006 outline skew plus placements rounded to 0.001), a few hundred-
/// thousandths of an inch. Nothing cuts that outline, so it is accepted; anything cut, or a
/// larger overhang that signals a real placement error, is still refused.
/// </summary>
public const double DisplayPerimeterSheetAllowance = 1e-4;
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; public const int MaximumShiftCandidateCount = 20000;
@@ -115,7 +143,9 @@ public static class AutomaticCutOffPlanner
"Physical sheet bounds and overtravel must be finite.", nameof(plate)); "Physical sheet bounds and overtravel must be finite.", nameof(plate));
var sign = plate.Quadrant is 2 or 3 ? -1 : 1; var sign = plate.Quadrant is 2 or 3 ? -1 : 1;
var ySign = plate.Quadrant is 3 or 4 ? -1 : 1;
var occupied = 0.0; var occupied = 0.0;
var occupiedY = 0.0;
var hasParts = false; var hasParts = false;
foreach (var part in plate.Parts) foreach (var part in plate.Parts)
{ {
@@ -123,7 +153,9 @@ public static class AutomaticCutOffPlanner
if (part.BaseDrawing.IsCutOff) if (part.BaseDrawing.IsCutOff)
continue; continue;
occupied = System.Math.Max(occupied, ValidatePart(part, bounds, sign)); var (partX, partY) = ValidatePart(part, bounds, sign, ySign);
occupied = System.Math.Max(occupied, partX);
occupiedY = System.Math.Max(occupiedY, partY);
hasParts = true; hasParts = true;
} }
if (!hasParts) if (!hasParts)
@@ -131,16 +163,62 @@ public static class AutomaticCutOffPlanner
// Use distances from the coordinate origin, not the sheet's lower-left corner. // Use distances from the coordinate origin, not the sheet's lower-left corner.
var length = plate.Size.Length; var length = plate.Size.Length;
var width = plate.Size.Width;
occupied = System.Math.Min(occupied, length); occupied = System.Math.Min(occupied, length);
var separator = occupied + System.Math.Max(plate.PartSpacing, settings.PartClearance) occupiedY = System.Math.Min(occupiedY, width);
+ GeometryTolerance; var margin = System.Math.Max(plate.PartSpacing, settings.PartClearance) + GeometryTolerance;
var separator = occupied + margin;
var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance; var hasTailCandidate = double.IsFinite(separator) && separator < length - GeometryTolerance;
var diagnostics = new List<AutomaticCutOffDiagnostic>();
// A band beyond the parts along Y is kept by a full-length horizontal separator; the
// vertical lines then stop at it instead of slicing the band. A user's line clear of the
// parts is honored; a new one is proposed only when its band beats the X tail.
var horizontalDistance = plate.CutOffs
.Where(c => c?.Axis == CutOffAxis.Horizontal && Finite(c.Position) && FullLengthLimits(c, bounds, settings))
.Select(c => ySign * c.Position.Y)
.Where(distance => distance > occupiedY + GeometryTolerance && distance < width - GeometryTolerance
&& width - distance >= options.MinimumTailLength)
.Select(distance => (double?)distance)
.Min();
var horizontalExists = horizontalDistance.HasValue;
if (!horizontalExists && options.KeepHorizontalRemnant)
{
var proposed = occupiedY + margin;
var tailArea = hasTailCandidate && length - separator >= options.MinimumTailLength
? (length - separator) * width : 0;
if (double.IsFinite(proposed) && proposed < width - GeometryTolerance
&& width - proposed >= options.MinimumTailLength && (width - proposed) * length > tailArea)
horizontalDistance = proposed;
}
if (horizontalDistance.HasValue && !horizontalExists)
{
var y = ySign * horizontalDistance.Value;
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
var clamp = clamps.FirstOrDefault(zone =>
y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
if (limited || clamp != null)
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
(clamp != null ? $"A horizontal separator at Y = {y:G8} would cross clamp '{clamp.Id}'"
: $"A same-line manual cut at Y = {y:G8} has different limits")
+ "; vertical lines cross the full sheet width instead."));
horizontalDistance = null;
}
}
// A tail too short to keep stays attached to the skeleton, so the strip grid must // A tail too short to keep stays attached to the skeleton, so the strip grid must
// continue to the sheet end; stopping at the skipped separator would leave a final // continue to the sheet end; stopping at the skipped separator would leave a final
// strip longer than the spacing. // strip longer than the spacing. Beside a kept horizontal band the tail is a corner
var keepsTail = hasTailCandidate && length - separator >= options.MinimumTailLength; // piece, which must also be wide enough to keep.
bool KeepsTail(double? band) => hasTailCandidate && length - separator >= options.MinimumTailLength
&& (band == null || band.Value >= options.MinimumTailLength);
var keepsTail = KeepsTail(horizontalDistance);
var usedSpan = keepsTail ? separator : length; var usedSpan = keepsTail ? separator : length;
var candidateCount = System.Math.Ceiling(usedSpan / options.Spacing); // Bound the count for either outcome: the horizontal separator is verified only below.
var candidateCount = System.Math.Ceiling(System.Math.Max(usedSpan, KeepsTail(null) ? separator : length)
/ options.Spacing);
Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount, Require(double.IsFinite(candidateCount) && candidateCount <= MaximumCandidateCount,
$"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options)); $"Spacing would generate more than {MaximumCandidateCount} cut-off candidates.", nameof(options));
@@ -148,7 +226,31 @@ public static class AutomaticCutOffPlanner
ValidateExisting(plate, bounds, settings); ValidateExisting(plate, bounds, settings);
var cache = Plate.BuildPerimeterCache(plate); var cache = Plate.BuildPerimeterCache(plate);
var definitions = new List<CutOff>(); var definitions = new List<CutOff>();
var diagnostics = new List<AutomaticCutOffDiagnostic>(); CutOff horizontalSeparator = null;
if (horizontalDistance.HasValue && !horizontalExists)
{
horizontalSeparator = new CutOff(new Vector(0, ySign * horizontalDistance.Value), CutOffAxis.Horizontal);
horizontalSeparator.Regenerate(plate, settings, cache);
if (!HasUsableSegments(horizontalSeparator.Drawing.Program, false) ||
!IsFullSpanProgram(horizontalSeparator.Drawing.Program, false, bounds.Left, bounds.Right))
{
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeHorizontalSeparator,
"No full-length horizontal separator survives the current settings; "
+ "vertical lines cross the full sheet width instead."));
horizontalSeparator = null;
horizontalDistance = null;
keepsTail = KeepsTail(null);
usedSpan = keepsTail ? separator : length;
candidateCount = System.Math.Ceiling(usedSpan / options.Spacing);
}
}
// Vertical lines run from the origin-side edge up to a kept band's separator.
var horizontalY = horizontalDistance.HasValue ? ySign * horizontalDistance.Value : (double?)null;
var startLimit = ySign < 0 ? horizontalY : null;
var endLimit = ySign > 0 ? horizontalY : null;
var spanLow = startLimit ?? bounds.Bottom;
var spanHigh = endLimit ?? bounds.Top;
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 shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
@@ -162,7 +264,7 @@ public static class AutomaticCutOffPlanner
// Full-span definitions fix the strip boundaries, even when their emitted // Full-span definitions fix the strip boundaries, even when their emitted
// programs are segmented. Partial cuts remain obstacles, not boundaries. // programs are segmented. Partial cuts remain obstacles, not boundaries.
var boundaries = plate.CutOffs var boundaries = plate.CutOffs
.Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings)) .Where(c => c.Axis == CutOffAxis.Vertical && Spans(c))
.Select(c => sign * c.Position.X) .Select(c => sign * c.Position.X)
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance) .Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
.Distinct().OrderBy(distance => distance).Append(usedSpan); .Distinct().OrderBy(distance => distance).Append(usedSpan);
@@ -212,6 +314,15 @@ public static class AutomaticCutOffPlanner
+ "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.", + "and strip lines continue to the sheet end. Existing cut-offs are unchanged. No retained tail is claimed.",
X: sign * separator)); X: sign * separator));
if (horizontalSeparator != null)
definitions.Add(horizontalSeparator);
if (horizontalY.HasValue)
diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.HorizontalRemnant,
$"Vertical lines stop at the {(horizontalExists ? "existing" : "new")} horizontal separator at "
+ $"Y = {horizontalY.Value:G8}, keeping a {width - horizontalDistance.Value:G8}-wide band "
+ "along the sheet length."));
if (diagnostics.Any(d => d.IsBlocking)) if (diagnostics.Any(d => d.IsBlocking))
{ {
// A partial proposal must not accidentally be accepted around a manual conflict. // A partial proposal must not accidentally be accepted around a manual conflict.
@@ -219,12 +330,24 @@ public static class AutomaticCutOffPlanner
stripCutLength = 0; stripCutLength = 0;
separated = false; separated = false;
separatorX = null; separatorX = null;
horizontalY = null;
} }
// The legacy placement loop stays untouched. Its detached programs are already // The legacy placement loop stays untouched. Its detached programs are already
// regenerated, so reporting their actual new strip cutting needs no new search. // regenerated, so reporting their actual new strip cutting needs no new search.
if (!shifted) if (!shifted)
fixedGridCutLength = stripCutLength; fixedGridCutLength = stripCutLength;
// Lines are planned and measured without the head-locked overcut: it only extends the
// segment reaching the far edge past the sheet, where there is nothing to separate.
if (settings.NewCutOffHeadLockOvercut > 0)
{
foreach (var definition in definitions)
{
definition.HeadLockOvercut = settings.NewCutOffHeadLockOvercut;
definition.Regenerate(plate, settings, cache);
}
}
return new AutomaticCutOffPlan return new AutomaticCutOffPlan
{ {
Definitions = definitions.AsReadOnly(), Definitions = definitions.AsReadOnly(),
@@ -236,8 +359,22 @@ public static class AutomaticCutOffPlanner
TailSeparatorX = separatorX, TailSeparatorX = separatorX,
StripCutLength = stripCutLength, StripCutLength = stripCutLength,
FixedGridCutLength = fixedGridCutLength, FixedGridCutLength = fixedGridCutLength,
HorizontalSeparatorY = horizontalY,
HorizontalRemnantWidth = horizontalY.HasValue ? width - horizontalDistance.Value : 0,
}; };
CutOff NewVertical(double x) => new(new Vector(x, 0), CutOffAxis.Vertical)
{
StartLimit = startLimit,
EndLimit = endLimit,
};
// A full-span line, or one with the limits this plan gives its own vertical lines.
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
horizontalY.HasValue && cut.Gaps.Count == 0 &&
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
void AddShiftedGap(double start, double end, int count, double step) void AddShiftedGap(double start, double end, int count, double step)
{ {
var nominal = new List<double>(count); var nominal = new List<double>(count);
@@ -290,8 +427,7 @@ public static class AutomaticCutOffPlanner
RegisterCandidate(distance); RegisterCandidate(distance);
if (!measurements.TryGetValue(distance, out var measured)) if (!measurements.TryGetValue(distance, out var measured))
{ {
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical) measured = NewVertical(sign * distance).MeasureSegments(plate, settings, cache);
.MeasureSegments(plate, settings, cache);
measurements.Add(distance, measured); measurements.Add(distance, measured);
} }
return measured; return measured;
@@ -302,7 +438,7 @@ public static class AutomaticCutOffPlanner
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 => !Spans(c)))
{ {
diagnostics.Add(new AutomaticCutOffDiagnostic( diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.LimitedCutOffConflict, AutomaticCutOffDiagnosticCode.LimitedCutOffConflict,
@@ -324,7 +460,7 @@ public static class AutomaticCutOffPlanner
{ {
diagnostics.Add(new AutomaticCutOffDiagnostic( diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.ClampBlockedCut, AutomaticCutOffDiagnosticCode.ClampBlockedCut,
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.", $"The proposed cut crosses clamp '{blocked.Id}' (including its clearance); it was skipped.",
X: x)); X: x));
if (isSeparator) if (isSeparator)
WarnNoSeparator(x); WarnNoSeparator(x);
@@ -335,9 +471,9 @@ public static class AutomaticCutOffPlanner
// 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();
var candidate = existing == null var candidate = existing == null
? new CutOff(new Vector(x, 0), CutOffAxis.Vertical) ? NewVertical(x)
: new CutOff(existing.Position, existing.Axis) : new CutOff(existing.Position, existing.Axis)
{ StartLimit = existing.StartLimit, EndLimit = existing.EndLimit }; { StartLimit = existing.StartLimit, EndLimit = existing.EndLimit, Gaps = existing.Gaps };
candidate.Regenerate(plate, settings, cache); candidate.Regenerate(plate, settings, cache);
var program = candidate.Drawing.Program; var program = candidate.Drawing.Program;
var usable = HasUsableSegments(program); var usable = HasUsableSegments(program);
@@ -353,7 +489,7 @@ public static class AutomaticCutOffPlanner
if (isSeparator) if (isSeparator)
{ {
if (!usable || !IsFullSpanProgram(program, bounds)) if (!usable || !IsFullSpanProgram(program, true, spanLow, spanHigh))
{ {
WarnNoSeparator(x); WarnNoSeparator(x);
return; return;
@@ -361,7 +497,7 @@ public static class AutomaticCutOffPlanner
separated = true; separated = true;
separatorX = candidate.Position.X; separatorX = candidate.Position.X;
} }
else if (usable && !IsFullSpanProgram(program, bounds)) else if (usable && !IsFullSpanProgram(program, true, spanLow, spanHigh))
diagnostics.Add(new AutomaticCutOffDiagnostic( diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.SegmentedCut, AutomaticCutOffDiagnosticCode.SegmentedCut,
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.", "Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
@@ -386,7 +522,7 @@ public static class AutomaticCutOffPlanner
// Share the exact placement-frame predicate with candidate exclusion and final // Share the exact placement-frame predicate with candidate exclusion and final
// certification. Infeasible gaps still use nominal lines through AddCandidate. // certification. Infeasible gaps still use nominal lines through AddCandidate.
bool CrossesClamp(ClampZone zone, double x) => bool CrossesClamp(ClampZone zone, double x) =>
x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom; x >= zone.Bounds.Left - plate.ClampClearance && x <= zone.Bounds.Right + plate.ClampClearance;
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic( void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator, AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
@@ -408,18 +544,22 @@ public static class AutomaticCutOffPlanner
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0, double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
"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));
// Strips are planned across a rectangle's known span; an outlined sheet gets manual cut-offs only.
Require(plate.Outline == null, "Automatic cut-offs need a rectangular sheet; this plate has an outline.", nameof(plate));
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate)); Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 || Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
Nonnegative(plate.EdgeSpacing.Bottom)), Nonnegative(plate.ClampClearance)),
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate)); "Clamp zones and clamp 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(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
"A head-locked overcut must be finite and positive.", nameof(settings));
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings)); Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
Require(plate.Parts != null && plate.CutOffs != null, Require(plate.Parts != null && plate.CutOffs != null,
"Plate parts and cut-off collections are required.", nameof(plate)); "Plate parts and cut-off collections are required.", nameof(plate));
} }
private static double ValidatePart(Part part, Box sheet, int sign) private static (double X, double Y) ValidatePart(Part part, Box sheet, int sign, int ySign)
{ {
Require(Finite(part.Location) && double.IsFinite(part.Rotation), Require(Finite(part.Location) && double.IsFinite(part.Rotation),
"Part pose must be finite.", "plate"); "Part pose must be finite.", "plate");
@@ -432,19 +572,32 @@ public static class AutomaticCutOffPlanner
Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) && Require(ValidBox(cached) && Near(box.Left, cached.Left) && Near(box.Right, cached.Right) &&
Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top), Near(box.Bottom, cached.Bottom) && Near(box.Top, cached.Top),
"Part geometry has stale bounds; update it before planning.", "plate"); "Part geometry has stale bounds; update it before planning.", "plate");
Require(Inside(box, sheet), "Part geometry extends outside the physical sheet.", "plate"); var hasDisplayPerimeter = DisplayPerimeterTail.HasDisplayPerimeterMotion(part.Program);
Require(Inside(box, sheet, hasDisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
// Checking raw coordinates above prevents NaNs being hidden by min/max comparisons. // Checking raw coordinates above prevents NaNs being hidden by min/max comparisons.
// Checking converted entities catches overflowing incremental moves and curve bounds. // Checking converted entities catches overflowing incremental moves and curve bounds.
var hasMaterial = false; var hasMaterial = false;
var occupied = sign > 0 ? box.Right : -box.Left; var occupied = sign > 0 ? box.Right : -box.Left;
var occupiedY = ySign > 0 ? box.Top : -box.Bottom;
var roundoff = CutOff.GetBoundsRoundoff(part); var roundoff = CutOff.GetBoundsRoundoff(part);
foreach (var entity in ConvertProgram.ToGeometry(part.Program)) var entities = ConvertProgram.ToGeometry(part.Program);
var outline = entities.Where(e => e.Layer == SpecialLayers.DisplayPerimeter).ToList();
foreach (var entity in entities)
{ {
var entityBox = entity.BoundingBox; var entityBox = entity.BoundingBox;
Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate"); Require(ValidBox(entityBox), "Part contains invalid converted geometry.", "plate");
if (!SpecialLayers.IsMaterial(entity.Layer)) if (!SpecialLayers.IsMaterial(entity.Layer))
{
// Travel and marks (a scribe is lasered) keep the ordinary tolerance. Only a rapid
// ending on the outline (its first corner, or an applied program's display tail)
// shares the outline's allowance.
Require(Inside(entityBox.Translate(part.Location), sheet)
|| hasDisplayPerimeter && IsRapidOntoOutline(entity, outline, part.Location, sheet),
"Part geometry extends outside the physical sheet.", "plate");
continue; continue;
}
entityBox = entityBox.Translate(part.Location); entityBox = entityBox.Translate(part.Location);
// Permit only bounded floating-point roundoff, with the same conservative // Permit only bounded floating-point roundoff, with the same conservative
// padding in CutOff's broad phase and fallback. Geometry-scale protrusions // padding in CutOff's broad phase and fallback. Geometry-scale protrusions
@@ -452,12 +605,15 @@ public static class AutomaticCutOffPlanner
Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff && Require(entityBox.Left >= cached.Left - roundoff && entityBox.Right <= cached.Right + roundoff &&
entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff, entityBox.Bottom >= cached.Bottom - roundoff && entityBox.Top <= cached.Top + roundoff,
"Converted material extends outside cached part bounds; repair it before planning.", "plate"); "Converted material extends outside cached part bounds; repair it before planning.", "plate");
Require(Inside(entityBox, sheet), "Part geometry extends outside the physical sheet.", "plate"); Require(Inside(entityBox, sheet,
entity.Layer == SpecialLayers.DisplayPerimeter ? DisplayPerimeterSheetAllowance : GeometryTolerance),
"Part geometry extends outside the physical sheet.", "plate");
occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left); occupied = System.Math.Max(occupied, sign > 0 ? entityBox.Right : -entityBox.Left);
occupiedY = System.Math.Max(occupiedY, ySign > 0 ? entityBox.Top : -entityBox.Bottom);
hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0; hasMaterial |= entityBox.Length > 0 || entityBox.Width > 0;
} }
Require(hasMaterial, "Real parts must contain material geometry.", "plate"); Require(hasMaterial, "Real parts must contain material geometry.", "plate");
return occupied; return (occupied, occupiedY);
} }
private static void ValidateProgram(Program program, HashSet<Program> path) private static void ValidateProgram(Program program, HashSet<Program> path)
@@ -498,11 +654,13 @@ public static class AutomaticCutOffPlanner
} }
} }
// A gapped line never runs full span, whatever its limits.
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) => private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
cut.Gaps.Count == 0 &&
Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) && Near(cut.StartLimit ?? bounds.Bottom, bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel); Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), bounds.Top + settings.Overtravel);
private static bool HasUsableSegments(Program program) private static bool HasUsableSegments(Program program, bool vertical = true)
{ {
if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0) if (program.Codes.Count == 0 || program.Codes.Count % 2 != 0)
return false; return false;
@@ -510,30 +668,50 @@ public static class AutomaticCutOffPlanner
{ {
if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to || if (program.Codes[i] is not RapidMove from || program.Codes[i + 1] is not LinearMove to ||
!Finite(from.EndPoint) || !Finite(to.EndPoint) || !Finite(from.EndPoint) || !Finite(to.EndPoint) ||
!Near(from.EndPoint.X, to.EndPoint.X) || (vertical ? !Near(from.EndPoint.X, to.EndPoint.X) : !Near(from.EndPoint.Y, to.EndPoint.Y)) ||
System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y) <= GeometryTolerance) (vertical ? System.Math.Abs(from.EndPoint.Y - to.EndPoint.Y)
: System.Math.Abs(from.EndPoint.X - to.EndPoint.X)) <= GeometryTolerance)
return false; return false;
} }
return true; return true;
} }
private static bool IsFullSpanProgram(Program program, Box bounds) private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
cut.Gaps.Count == 0 &&
Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
private static bool IsFullSpanProgram(Program program, bool vertical, double low, double high)
{ {
// No gaps, bridges, or filtered middle segments can separate the tail. // No gaps, bridges, or filtered middle segments can separate the tail.
if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from || if (program.Codes.Count != 2 || program.Codes[0] is not RapidMove from ||
program.Codes[1] is not LinearMove to) program.Codes[1] is not LinearMove to)
return false; return false;
return System.Math.Min(from.EndPoint.Y, to.EndPoint.Y) <= bounds.Bottom + GeometryTolerance && var start = vertical ? from.EndPoint.Y : from.EndPoint.X;
System.Math.Max(from.EndPoint.Y, to.EndPoint.Y) >= bounds.Top - GeometryTolerance; var end = vertical ? to.EndPoint.Y : to.EndPoint.X;
return System.Math.Min(start, end) <= low + GeometryTolerance &&
System.Math.Max(start, end) >= high - GeometryTolerance;
} }
private static bool ValidBox(Box box) => box != null && Finite(box.Location) && private static bool ValidBox(Box box) => box != null && Finite(box.Location) &&
Nonnegative(box.Length) && Nonnegative(box.Width) && Nonnegative(box.Length) && Nonnegative(box.Width) &&
double.IsFinite(box.Right) && double.IsFinite(box.Top); double.IsFinite(box.Right) && double.IsFinite(box.Top);
private static bool Inside(Box box, Box sheet) => // A rapid that leaves the sheet only at an end lying on the display perimeter, within its allowance.
box.Left >= sheet.Left - GeometryTolerance && box.Right <= sheet.Right + GeometryTolerance && private static bool IsRapidOntoOutline(Entity entity, List<Entity> outline, Vector location, Box sheet)
box.Bottom >= sheet.Bottom - GeometryTolerance && box.Top <= sheet.Top + GeometryTolerance; {
if (entity.Layer != SpecialLayers.Rapid || entity is not Line rapid
|| !Inside(rapid.BoundingBox.Translate(location), sheet, DisplayPerimeterSheetAllowance))
return false;
bool Allowed(Vector point) =>
Inside(new Box(point.X + location.X, point.Y + location.Y, 0, 0), sheet)
|| outline.Any(e => e.ClosestPointTo(point).DistanceTo(point) <= GeometryTolerance);
return Allowed(rapid.StartPoint) && Allowed(rapid.EndPoint);
}
private static bool Inside(Box box, Box sheet, double tolerance = GeometryTolerance) =>
box.Left >= sheet.Left - tolerance && box.Right <= sheet.Right + tolerance &&
box.Bottom >= sheet.Bottom - tolerance && box.Top <= sheet.Top + tolerance;
private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y); private static bool Finite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0; private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
+11 -3
View File
@@ -21,12 +21,17 @@ namespace OpenNest.BestFit
IReadOnlyList<double> HullAngles IReadOnlyList<double> HullAngles
); );
/// <summary>The saved best-fit results for one drawing at one plate size and spacing.</summary> /// <summary>
/// The saved best-fit results for one drawing at one plate size and spacing.
/// <paramref name="CanonicalAngle"/> is the drawing's canonical angle (radians) when the
/// set was saved: the frame its pair geometry is in. Null means the frame is unknown.
/// </summary>
public sealed record SavedPairSet( public sealed record SavedPairSet(
double PlateWidth, double PlateWidth,
double PlateHeight, double PlateHeight,
double Spacing, double Spacing,
IReadOnlyList<SavedPairResult> Results IReadOnlyList<SavedPairResult> Results,
double? CanonicalAngle = null
); );
/// <summary> /// <summary>
@@ -45,7 +50,10 @@ namespace OpenNest.BestFit
IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys IReadOnlySet<(double Width, double Length, double Spacing)> plateKeys
); );
/// <summary>Restores saved sets for <paramref name="drawing"/>.</summary> /// <summary>
/// Restores saved sets for <paramref name="drawing"/>. Sets saved in a frame other
/// than the drawing's current canonical frame, or in an unknown one, are stale.
/// </summary>
void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets); void Import(Drawing drawing, IReadOnlyList<SavedPairSet> sets);
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ namespace OpenNest.Breakoffs
{ {
/// <summary> /// <summary>
/// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the /// Resolves and applies <see cref="Breakoff"/>s: the joining line becomes open cuts on the
/// break layer between tabs, each travelling with the scrap on its left so the cutting /// break layer between tabs, each traveling with the scrap on its left so the cutting
/// strategies pierce and lead in from the scrap. /// strategies pierce and lead in from the scrap.
/// </summary> /// </summary>
public static class BreakoffBuilder public static class BreakoffBuilder
+1 -1
View File
@@ -49,7 +49,7 @@ namespace OpenNest.Breakoffs
/// <summary> /// <summary>
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the /// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab stays on the
/// joining line and keeps a gap from its neighbours. /// joining line and keeps a gap from its neighbors.
/// </summary> /// </summary>
public double ClampTabCenter(int index, double center) public double ClampTabCenter(int index, double center)
{ {
+1 -1
View File
@@ -238,7 +238,7 @@ namespace OpenNest.CNC
builder.PartIndex = i; builder.PartIndex = i;
var mode = Mode.Absolute; var mode = Mode.Absolute;
var pos = part.Location; var pos = part.Location;
builder.AddProgram(part.Program, part.Location, ref mode, ref pos); builder.AddProgram(DisplayPerimeterTail.WithoutTail(part.Program), part.Location, ref mode, ref pos);
} }
var steps = ContourTiming.Finish(builder.Steps, plate, parameters); var steps = ContourTiming.Finish(builder.Steps, plate, parameters);
@@ -36,14 +36,45 @@ internal enum AutomaticEntryKind
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary> /// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
Pinned = 7, Pinned = 7,
/// <summary>
/// The perimeter point where a tab best keeps the part level on its support bed; only a
/// perimeter tabbed for support has it (see <see cref="PreparedContours.WithSupportTab"/>).
/// </summary>
SupportTab = 8,
/// <summary>
/// A cutout entry pierced where eroding the cutout leaves room (see
/// <see cref="PreparedContours.ErodedPierceEntries"/>): only offered once no configured
/// lead-in of the cutout fits.
/// </summary>
ErodedPierce = 9,
}
/// <summary>
/// Which automatic entries a perimeter tabbed for support offers (see
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalog.
/// </summary>
internal enum SupportEntries
{
/// <summary>Entries whose tab steadies the part, clear of inside corners, plus its best tab point.</summary>
Steadying,
/// <summary>Every entry clear of inside corners: the fallback when no steadying entry fits.</summary>
ClearOfInsideCorners,
/// <summary>Every entry: the last fallback, when nothing clear of inside corners fits.</summary>
Any,
} }
/// <summary> /// <summary>
/// One automatic start candidate: the owned contour choice plus its preference kind and a /// One automatic start candidate: the owned contour choice plus its preference kind and a
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as /// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
/// owner; nothing here exposes or mutates the underlying shape. /// owner; nothing here exposes or mutates the underlying shape. <paramref name="CornerReach"/>
/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
/// ranking can count it on both of that corner's sides.
/// </summary> /// </summary>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind) internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
{ {
/// <summary> /// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so /// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
@@ -7,7 +7,7 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
/// <summary> /// <summary>
/// Pure deterministic ordering of the automatic entry catalogue toward the next cut: /// Pure deterministic ordering of the automatic entry catalog toward the next cut:
/// circles rank by outgoing distance; other contours use facing sides, tier and travel, /// circles rank by outgoing distance; other contours use facing sides, tier and travel,
/// then a stable geometric key. It adds no /// then a stable geometric key. It adds no
/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility /// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
@@ -62,12 +62,12 @@ internal static class ContourEntryRanking
if (p.Y > maxY) maxY = p.Y; if (p.Y > maxY) maxY = p.Y;
} }
// Facing sides from the target relative to the centre, matching the source plan: // Facing sides from the target relative to the center, matching the source plan:
// horizontal right when the target is right of centre else left; vertical likewise. // horizontal right when the target is right of center else left; vertical likewise.
var centreX = minX + (maxX - minX) * 0.5; var centerX = minX + (maxX - minX) * 0.5;
var centreY = minY + (maxY - minY) * 0.5; var centerY = minY + (maxY - minY) * 0.5;
var facingRight = target != null && target.Value.X > centreX; var facingRight = target != null && target.Value.X > centerX;
var facingTop = target != null && target.Value.Y > centreY; var facingTop = target != null && target.Value.Y > centerY;
return candidates return candidates
.Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival))) .Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
if (target != null) if (target != null)
{ {
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two // Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
// sides within tolerance); count how many of them are facing sides. // sides within tolerance, a fillet's joint within its reach of the corner it
// rounds); count how many of them are facing sides.
var left = p.X - minX; var left = p.X - minX;
var right = maxX - p.X; var right = maxX - p.X;
var bottom = p.Y - minY; var bottom = p.Y - minY;
var top = maxY - p.Y; var top = maxY - p.Y;
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top)); var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++; var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++; if (facingRight && right <= near) facing++;
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++; if (!facingRight && left <= near) facing++;
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++; if (facingTop && top <= near) facing++;
if (!facingTop && bottom <= near) facing++;
} }
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0) var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ (target != null ? p.DistanceTo(target.Value) : 0.0); + (target != null ? p.DistanceTo(target.Value) : 0.0);
@@ -55,23 +55,24 @@ internal static class ContourProgramVerifier
var first = run.Curves[0]; var first = run.Curves[0];
var ringIndex = -1; var ringIndex = -1;
var entityIndex = -1; var entityIndex = -1;
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++) // The admitted slack is far below any extent margin, so only boundaries near the run's
// start can contain it; they come in ring order, as the full scan met them.
foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y)))
{ {
Query(token, ref budget);
var admitted = AdmittedClosureSlack(material.Rings[r]); var admitted = AdmittedClosureSlack(material.Rings[r]);
for (var e = 0; e < material.Rings[r].Length; e++) var curve = material.Rings[r][e];
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{ {
Query(token, ref budget); ringIndex = r;
var curve = material.Rings[r][e]; entityIndex = e;
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted) break;
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
ringIndex = r;
entityIndex = e;
break;
}
} }
} }
if (ringIndex < 0) return false; if (ringIndex < 0) return false;
// A display perimeter is never cut: any run along it is not this plan.
if (ringIndex == 0 && material.HasDisplayPerimeter) return false;
var tabs = material.Tabs[ringIndex]; var tabs = material.Tabs[ringIndex];
if (tabs.Any(tab => tab)) if (tabs.Any(tab => tab))
{ {
@@ -108,7 +109,7 @@ internal static class ContourProgramVerifier
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start), if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
true, tolerance, token, ref budget)) return false; true, tolerance, token, ref budget)) return false;
} }
return visited.Count == material.Rings.Count; return visited.Count == material.Rings.Count - (material.HasDisplayPerimeter ? 1 : 0);
} }
// Indexes of the curves that start a span between tabs: uncut-to-cut transitions. // Indexes of the curves that start a span between tabs: uncut-to-cut transitions.
@@ -22,20 +22,39 @@ internal sealed class PlannedPartProgram
public CuttingParameters Parameters { get; } public CuttingParameters Parameters { get; }
} }
/// <summary>
/// An owned cutoff segment program cut the other way at the far sheet edge
/// (<see cref="CutOff.ReverseAtFarEdge"/>); Apply installs it and records the direction on the
/// segment's cutoff definition, so regeneration keeps it.
/// </summary>
internal sealed class PlannedCutOffProgram
{
public PlannedCutOffProgram(Part segment, Program program)
{
Segment = segment;
Program = program;
}
public Part Segment { get; }
public Program Program { get; }
}
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary> /// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
internal sealed class PlateCuttingPlan internal sealed class PlateCuttingPlan
{ {
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order, public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null) IEnumerable<PlannedPartProgram> programs = null, IEnumerable<PlannedCutOffProgram> cutOffs = null)
{ {
Expected = expected; Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray()); Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray()); Programs = Array.AsReadOnly((programs ?? []).ToArray());
CutOffs = Array.AsReadOnly((cutOffs ?? []).ToArray());
} }
public PlateCuttingState Expected { get; } public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; } public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; } public IReadOnlyList<PlannedPartProgram> Programs { get; }
public IReadOnlyList<PlannedCutOffProgram> CutOffs { get; }
} }
public enum CuttingCommitStatus public enum CuttingCommitStatus
@@ -43,7 +62,7 @@ public enum CuttingCommitStatus
Applied, Applied,
Stale, Stale,
InvalidInput, InvalidInput,
Cancelled, Canceled,
Failed Failed
} }
@@ -88,7 +107,7 @@ internal static class CuttingPlanCommit
Action<Plate, Part> beforeInstall) Action<Plate, Part> beforeInstall)
{ {
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit."); return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
var scope = plans?.ToArray(); var scope = plans?.ToArray();
if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null)) if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
return Invalid(null, "A nonempty set of captured plate plans is required."); return Invalid(null, "A nonempty set of captured plate plans is required.");
@@ -107,7 +126,7 @@ internal static class CuttingPlanCommit
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit."); return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
} }
if (difference != null) if (difference != null)
return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate); return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
@@ -161,27 +180,49 @@ internal static class CuttingPlanCommit
} }
programs.Add((planned.Part, planned.Program, bounds, planned.Parameters)); programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
} }
var cutOffs = new List<(Part, Program, CutOff)>();
foreach (var planned in plan.CutOffs)
{
if (planned?.Segment == null || !onPlate.Contains(planned.Segment) || !targets.Add(planned.Segment))
return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
var definition = plate.CutOffs.FirstOrDefault(c => ReferenceEquals(c?.Drawing, planned.Segment.BaseDrawing));
// Only the placed far-edge segment, cut exactly the other way, may be installed.
if (definition == null || planned.Program == null || live.Contains(planned.Program)
|| !installed.Add(planned.Program)
|| CutOff.ReverseAtFarEdge(planned.Segment.Program) is not { } expected
|| !ProgramContent.Equal(expected, planned.Program, token))
return Invalid(plate, "A cutoff segment can only be installed cut the other way at the far edge.");
cutOffs.Add((planned.Segment, planned.Program, definition));
}
var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance); var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
staged.Add(new(plate, order, programs, reordered || programs.Count != 0)); staged.Add(new(plate, order, programs, cutOffs, reordered || programs.Count != 0 || cutOffs.Count != 0));
} }
// Last cancellation point. The boundary below has no await, search or geometry work. // Last cancellation point. The boundary below has no await, search or geometry work.
if (token.IsCancellationRequested) if (token.IsCancellationRequested)
return new(CuttingCommitStatus.Cancelled, "Cancelled before commit."); return new(CuttingCommitStatus.Canceled, "Canceled before commit.");
var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>(); var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States,
(CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
try try
{ {
foreach (var item in staged) foreach (var item in staged)
{ {
undo.Add((item.Plate, item.Plate.Parts.ToArray(), undo.Add((item.Plate, item.Plate.Parts.ToArray(),
item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray())); item.Programs.Select(p => p.Part).Concat(item.CutOffs.Select(c => c.Segment))
.Select(part => (part, part.CaptureCuttingState())).ToArray(),
item.CutOffs.Select(c => (c.CutOff, c.CutOff.FarEdgeFirst, c.CutOff.Drawing.Program)).ToArray()));
item.Plate.Parts.SetOrder(item.Order); item.Plate.Parts.SetOrder(item.Order);
foreach (var (part, program, bounds, parameters) in item.Programs) foreach (var (part, program, bounds, parameters) in item.Programs)
{ {
beforeInstall?.Invoke(item.Plate, part); beforeInstall?.Invoke(item.Plate, part);
part.InstallPlannedProgram(program, bounds, parameters); part.InstallPlannedProgram(program, bounds, parameters);
} }
foreach (var (segment, program, cutOff) in item.CutOffs)
{
beforeInstall?.Invoke(item.Plate, segment);
cutOff.InstallFarEdgeSegment(segment, program);
}
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -190,6 +231,11 @@ internal static class CuttingPlanCommit
{ {
foreach (var (part, state) in undo[i].States) foreach (var (part, state) in undo[i].States)
part.RestoreCuttingState(state); part.RestoreCuttingState(state);
foreach (var (cutOff, farEdgeFirst, program) in undo[i].CutOffs)
{
cutOff.FarEdgeFirst = farEdgeFirst;
cutOff.Drawing.Program = program;
}
undo[i].Plate.Parts.SetOrder(undo[i].Order); undo[i].Plate.Parts.SetOrder(undo[i].Order);
} }
return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.", return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
@@ -208,5 +254,6 @@ internal static class CuttingPlanCommit
new(CuttingCommitStatus.InvalidInput, message, plate); new(CuttingCommitStatus.InvalidInput, message, plate);
private sealed record Staged(Plate Plate, Part[] Order, private sealed record Staged(Plate Plate, Part[] Order,
List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed); List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs,
List<(Part Segment, Program Program, CutOff CutOff)> CutOffs, bool Changed);
} }
@@ -27,10 +27,12 @@ public static class ExecutionMotionReader
var moves = new List<ExecutionMotion>(); var moves = new List<ExecutionMotion>();
var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance); var visiting = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var budget = 1000000; var budget = 1000000;
Walk(program, origin, previous); Walk(program, origin, previous, false, null);
return new OwnedExecution(moves); return new OwnedExecution(moves);
Vector Walk(Program current, Vector frame, Vector? arrival) // raiseArrival: a marked call raises the head for the rapid that arrives at its first motion.
// callCode: the part's own code that called this sub-program, or null for the part's own codes.
Vector Walk(Program current, Vector frame, Vector? arrival, bool raiseArrival, int? callCode)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
PostVerificationGeometry.Validate(frame); PostVerificationGeometry.Validate(frame);
@@ -41,13 +43,24 @@ public static class ExecutionMotionReader
var pos = frame; var pos = frame;
var first = true; var first = true;
var countBefore = moves.Count; var countBefore = moves.Count;
foreach (var code in current.Codes) var raised = HeadRaise.RaisedMask(current.Codes);
for (var codeIndex = 0; codeIndex < current.Codes.Count; codeIndex++)
{ {
var code = current.Codes[codeIndex];
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (--budget < 0) if (--budget < 0)
throw new ArgumentException("Program expansion exceeds the verification limit."); throw new ArgumentException("Program expansion exceeds the verification limit.");
if (code == null) if (code == null)
throw new ArgumentException("Program contains a missing instruction."); throw new ArgumentException("Program contains a missing instruction.");
// A display perimeter's tail is never executed: an applied program ends at its marker.
// Only a well-formed tail at the end of the part's own program may end the read.
if (code is Comment { Value: DisplayPerimeterTail.Marker })
{
if (!ReferenceEquals(current, program))
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
DisplayPerimeterTail.IndexOf(current);
break;
}
if (requireSupportedTypes) if (requireSupportedTypes)
{ {
var type = code.GetType(); var type = code.GetType();
@@ -61,7 +74,8 @@ public static class ExecutionMotionReader
throw new ArgumentException("Subprogram rotation is not finite."); throw new ArgumentException("Subprogram rotation is not finite.");
// Call rotation is baked into the shared program by its setter. Do not // Call rotation is baked into the shared program by its setter. Do not
// rotate again; offsets are frame-relative even in incremental mode. // rotate again; offsets are frame-relative even in incremental mode.
pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos); pos = Walk(call.Program, frame + call.Offset, first ? arrival : pos,
raised[codeIndex] || first && raiseArrival, callCode ?? codeIndex);
first = false; first = false;
continue; continue;
} }
@@ -82,7 +96,7 @@ public static class ExecutionMotionReader
PostVerificationGeometry.Validate(end); PostVerificationGeometry.Validate(end);
var rapid = motion is RapidMove; var rapid = motion is RapidMove;
if (first && !rapid) if (first && !rapid)
moves.Add(new(arrival, frame, true, LayerType.Display, null)); moves.Add(new(arrival, frame, true, LayerType.Display, null, raiseArrival, callCode: callCode));
var start = first && rapid ? arrival : pos; var start = first && rapid ? arrival : pos;
var layer = motion switch var layer = motion switch
{ {
@@ -97,7 +111,8 @@ public static class ExecutionMotionReader
var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end, var curve = rapid ? null : PostVerificationGeometry.Curve.Create(pos, end,
motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null, motion is ArcMove arcMove ? reference + arcMove.CenterPoint : null,
motion is ArcMove { Rotation: RotationType.CW }); motion is ArcMove { Rotation: RotationType.CW });
moves.Add(new(start, end, rapid, layer, curve)); moves.Add(new(start, end, rapid, layer, curve, raised[codeIndex] || first && rapid && raiseArrival,
ReferenceEquals(current, program) ? codeIndex : null, callCode));
pos = end; pos = end;
first = false; first = false;
} }
@@ -49,8 +49,8 @@ internal static class IncrementalClosureDrift
// The chains material capture closes: cut, display and tab moves between rapids and other layers. // The chains material capture closes: cut, display and tab moves between rapids and other layers.
private static bool IsContour(Motion motion) => !motion.Suppressed private static bool IsContour(Motion motion) => !motion.Suppressed
&& motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab } && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter }
or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }; or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter };
private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next) private static bool TryClose(Program program, List<Motion> run, Vector start, Vector end, int next)
{ {
@@ -79,7 +79,7 @@ internal static class IncrementalClosureDrift
var endPoint = last.EndPoint + correction; var endPoint = last.EndPoint + correction;
if (last is ArcMove arc) if (last is ArcMove arc)
{ {
// Keep the arc's centre (relative to its start) equidistant from its moved end. // Keep the arc's center (relative to its start) equidistant from its moved end.
var chord = endPoint.DistanceTo(new Vector()); var chord = endPoint.DistanceTo(new Vector());
if (chord <= Tolerance.Epsilon) if (chord <= Tolerance.Epsilon)
return false; return false;
@@ -12,8 +12,9 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class LeadMaterialSnapshot public sealed class LeadMaterialSnapshot
{ {
private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs, private LeadMaterialSnapshot(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, IReadOnlyList<bool[]> tabs,
string reason) string reason, bool displayPerimeter = false)
{ {
HasDisplayPerimeter = displayPerimeter;
Rings = rings; Rings = rings;
Tabs = tabs; Tabs = tabs;
Reason = reason; Reason = reason;
@@ -41,6 +42,12 @@ public sealed class LeadMaterialSnapshot
/// </summary> /// </summary>
internal IReadOnlyList<bool[]> Tabs { get; } internal IReadOnlyList<bool[]> Tabs { get; }
/// <summary>
/// True when the perimeter (<see cref="Rings"/>[0]) is a display perimeter: a supplied blank's
/// edge that bounds material but is never cut. Every other ring is a hole the program cuts.
/// </summary>
internal bool HasDisplayPerimeter { get; }
/// <summary>Capture a stable, clean, rotation-baked program, applying location once. /// <summary>Capture a stable, clean, rotation-baked program, applying location once.
/// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary> /// Unsupported or malformed geometry produces an incomplete snapshot; cancellation throws.</summary>
public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location, public static LeadMaterialSnapshot Capture(Program cleanProgram, Vector location,
@@ -53,6 +60,8 @@ public sealed class LeadMaterialSnapshot
var ringTabs = new List<bool[]>(); var ringTabs = new List<bool[]>();
var chain = new List<PostVerificationGeometry.Curve>(); var chain = new List<PostVerificationGeometry.Curve>();
var chainTabs = new List<bool>(); var chainTabs = new List<bool>();
var ringDisplay = new List<bool>();
var chainDisplay = new List<bool>();
foreach (var motion in execution.Motions) foreach (var motion in execution.Motions)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -63,7 +72,8 @@ public sealed class LeadMaterialSnapshot
continue; continue;
} }
// A tabbed cutout's tabs are never cut but still close its hole. // A tabbed cutout's tabs are never cut but still close its hole.
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab)) // A display perimeter is never cut but still bounds the blank's material.
if (motion.Layer is not (LayerType.Cut or LayerType.Display or LayerType.Tab or LayerType.DisplayPerimeter))
throw new ArgumentException("Material capture contains an unsupported layer."); throw new ArgumentException("Material capture contains an unsupported layer.");
var curve = motion.Curve; var curve = motion.Curve;
if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon) if (curve == null || !double.IsFinite(curve.Length) || curve.Length <= PostVerificationGeometry.Epsilon)
@@ -72,6 +82,7 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material contour is discontinuous."); throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve); chain.Add(curve);
chainTabs.Add(motion.Layer == LayerType.Tab); chainTabs.Add(motion.Layer == LayerType.Tab);
chainDisplay.Add(motion.Layer == LayerType.DisplayPerimeter);
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End)) if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish(); Finish();
} }
@@ -80,42 +91,46 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material has no closed contour."); throw new ArgumentException("Material has no closed contour.");
var budget = 1000000; var budget = 1000000;
// Certify simple rings and mutually disjoint boundaries before containment. // Certify simple rings and mutually disjoint boundaries before containment.
for (var r = 0; r < rings.Count; r++) foreach (var (r, i, s, j) in ContactCandidates(rings, token))
for (var s = r; s < rings.Count; s++) {
for (var i = 0; i < rings[r].Length; i++) token.ThrowIfCancellationRequested();
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++) if (--budget < 0)
{ throw new NotSupportedException("Material validation exceeds the native query limit.");
token.ThrowIfCancellationRequested(); var a = rings[r][i];
if (--budget < 0) var b = rings[s][j];
throw new NotSupportedException("Material validation exceeds the native query limit."); var contacts = a.Contacts(b, out var overlap);
var a = rings[r][i]; var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
var b = rings[s][j]; var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
var contacts = a.Contacts(b, out var overlap); && (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1)); || a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc // Native arc queries pad angles by 1e-5 radians, admitting
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon // intersections on an extension (distance grows with radius).
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon); // At a shared arc joint require both actual finite spans;
// Native arc queries pad angles by 1e-5 radians, admitting // overlap and genuine second intersections still refuse.
// intersections on an extension (distance grows with radius). if (arcJoint)
// At a shared arc joint require both actual finite spans; contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// overlap and genuine second intersections still refuse. // Native contacts at rounded arc/line joints can drift from the
if (arcJoint) // authored endpoint. Use the shared geometry tolerance only to
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray(); // recognize that joint; overlaps and other contacts still refuse.
// Native contacts at rounded arc/line joints can drift from the if (overlap || contacts.Any(p => !adjacent
// authored endpoint. Use the shared geometry tolerance only to || !(p.DistanceTo(a.End) <= Tolerance.Epsilon
// recognize that joint; overlaps and other contacts still refuse. && p.DistanceTo(b.Start) <= Tolerance.Epsilon)
if (overlap || contacts.Any(p => !adjacent && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon && p.DistanceTo(b.End) <= Tolerance.Epsilon)))
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon) throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon ? "A display perimeter must enclose every other contour."
&& p.DistanceTo(b.End) <= Tolerance.Epsilon))) : $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); }
} // A ring encloses a point only inside the union of its curves' extents (each at least
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
(extent, curve) => extent.Union(curve.Extent))).ToArray();
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
var outer = -1; var outer = -1;
for (var i = 0; i < rings.Count; i++) for (var i = 0; i < rings.Count; i++)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token))) if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
{ {
if (outer >= 0) if (outer >= 0)
throw new ArgumentException("Material perimeter is ambiguous."); throw new ArgumentException("Material perimeter is ambiguous.");
@@ -123,16 +138,23 @@ public sealed class LeadMaterialSnapshot
} }
} }
if (outer < 0) if (outer < 0)
throw new ArgumentException("Material must have exactly one enclosing perimeter."); throw new ArgumentException(ringDisplay.Contains(true)
? "A display perimeter must enclose every other contour."
: "Material must have exactly one enclosing perimeter.");
if (ringTabs[outer].Any(tab => tab)) if (ringTabs[outer].Any(tab => tab))
throw new ArgumentException("A perimeter cannot have tabs."); throw new ArgumentException("A perimeter cannot have tabs.");
var holes = rings.Where((_, i) => i != outer).ToArray(); if (ringDisplay.Where((_, i) => i != outer).Any(display => display))
for (var i = 0; i < holes.Length; i++) throw new ArgumentException("A display perimeter must enclose every other contour.");
for (var j = i + 1; j < holes.Length; j++) if (ringDisplay[outer] && rings.Count == 1)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token)) throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
var holes = holeRings.Select(i => rings[i]).ToArray();
for (var i = 0; i < holeRings.Length; i++)
for (var j = i + 1; j < holeRings.Length; j++)
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
throw new ArgumentException("Nested cutouts are not supported material."); throw new ArgumentException("Nested cutouts are not supported material.");
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null); new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
void Finish() void Finish()
{ {
@@ -145,10 +167,14 @@ public sealed class LeadMaterialSnapshot
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.") $"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly."); + " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
} }
if (chainDisplay.Contains(true) && chainDisplay.Contains(false))
throw new ArgumentException("A display perimeter must be one whole closed contour.");
rings.Add(chain.ToArray()); rings.Add(chain.ToArray());
ringTabs.Add(chainTabs.ToArray()); ringTabs.Add(chainTabs.ToArray());
ringDisplay.Add(chainDisplay.Count > 0 && chainDisplay[0]);
chain.Clear(); chain.Clear();
chainTabs.Clear(); chainTabs.Clear();
chainDisplay.Clear();
} }
} }
catch (Exception ex) when (ex is ArgumentException or NotSupportedException) catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
@@ -157,18 +183,182 @@ public sealed class LeadMaterialSnapshot
} }
} }
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token) /// <summary>
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token)); /// The boundary pairs certification queries natively, in the order of the full pairwise scan
/// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks'
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token) /// margin (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>) can report no
/// native contact, so it is left out; a sweep over X finds the rest without visiting every pair.
/// Material that is not well conditioned keeps every pair.
/// </summary>
private static List<(int R, int I, int S, int J)> ContactCandidates(
IReadOnlyList<PostVerificationGeometry.Curve[]> rings, CancellationToken token)
{ {
var inside = false; var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
foreach (var curve in ring) var whole = PostVerificationGeometry.Extent.None;
for (var r = 0; r < rings.Count; r++)
for (var i = 0; i < rings[r].Length; i++)
{
curves.Add((r, i, rings[r][i].Extent));
whole = whole.Union(rings[r][i].Extent);
}
var pairs = new List<(int R, int I, int S, int J)>();
if (!whole.IsWellConditioned)
{ {
token.ThrowIfCancellationRequested(); for (var a = 0; a < curves.Count; a++)
if (curve.CrossesRay(point)) for (var b = a + 1; b < curves.Count; b++)
inside = !inside; pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index));
} }
return inside; else
{
// No pair spans more than the whole material, so this bounds every pair's margin.
var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding
* System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY);
var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray();
for (var a = 0; a < byMinX.Length; a++)
{
token.ThrowIfCancellationRequested();
var first = byMinX[a];
for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++)
{
var second = byMinX[b];
if (first.Extent.IsClearOfQueriesFrom(second.Extent))
continue;
pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0
? (first.Ring, first.Index, second.Ring, second.Index)
: (second.Ring, second.Index, first.Ring, first.Index));
}
}
}
pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J)));
return pairs;
} }
/// <summary>Material with fewer boundaries is scanned whole, exactly as before the index.</summary>
private const int IndexedBoundaries = 64;
private BoundaryIndex index;
/// <summary>
/// The boundaries a native query from <paramref name="query"/> can report a contact with, in
/// ring order: every boundary but those whose extents are clear of it by the lead checks'
/// margin plus <paramref name="reach"/> (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>).
/// Small material returns every boundary.
/// </summary>
internal IEnumerable<PostVerificationGeometry.Curve> BoundariesNear(PostVerificationGeometry.Extent query,
double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]);
/// <summary>As <see cref="BoundariesNear"/>, each boundary as its ring and its index in that ring.</summary>
internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0)
{
if (BoundaryCount < IndexedBoundaries)
return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i)));
return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach);
}
internal bool ContainsMaterial(Vector point, CancellationToken token)
{
if (BoundaryCount < IndexedBoundaries)
return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y);
return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token));
}
/// <summary>
/// A uniform grid of boundary extents over the material, for material with many holes. Read
/// only once built, so concurrent searches may share it.
/// </summary>
private sealed class BoundaryIndex
{
private readonly (int Ring, int Index)[] positions;
private readonly PostVerificationGeometry.Extent[] extents;
private readonly List<int>[] cells;
private readonly List<int>[] bands;
private readonly PostVerificationGeometry.Extent[] ringExtents;
private readonly int ringCount;
private readonly PostVerificationGeometry.Extent whole;
private readonly int columns;
private readonly int rows;
private readonly double width;
private readonly double height;
internal BoundaryIndex(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, PostVerificationGeometry.Extent whole)
{
positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray();
extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray();
ringCount = rings.Count;
ringExtents = new PostVerificationGeometry.Extent[rings.Count];
this.whole = whole;
var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length)));
columns = side;
rows = side;
width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns;
height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows;
cells = new List<int>[columns * rows];
bands = new List<int>[rows];
if (!whole.IsWellConditioned)
return;
for (var ring = 1; ring < rings.Count; ring++)
{
var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent));
ringExtents[ring] = extent;
for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin);
band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++)
(bands[band] ??= new List<int>()).Add(ring);
}
for (var b = 0; b < positions.Length; b++)
{
var (x0, y0, x1, y1) = Cells(extents[b], 0);
for (var y = y0; y <= y1; y++)
for (var x = x0; x <= x1; x++)
(cells[y * columns + x] ??= new List<int>()).Add(b);
}
}
/// <summary>
/// The holes, in ring order, whose curves a ray along row <paramref name="y"/> can cross:
/// every hole but those whose extents are clear of the row (see
/// <see cref="PostVerificationGeometry.Extent.IsClearOfRow"/>).
/// </summary>
internal IEnumerable<int> HolesOnRow(double y)
{
if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit))
return Enumerable.Range(1, ringCount - 1);
return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y));
}
internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach)
{
// The margin covers every pair's span, and each skip is then decided for the pair alone.
// Material or a query that is not well conditioned (whose cells were never built, for
// the material) keeps every boundary.
var union = whole.Union(query);
var margin = PostVerificationGeometry.Extent.ClearMargin + reach
+ PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
if (!union.IsWellConditioned || !double.IsFinite(margin))
return positions;
var (x0, y0, x1, y1) = Cells(query, margin);
var found = new List<int>();
for (var y = y0; y <= y1; y++)
for (var x = x0; x <= x1; x++)
if (cells[y * columns + x] is { } cell)
found.AddRange(cell);
found.Sort();
var near = new List<(int Ring, int Index)>();
for (var i = 0; i < found.Count; i++)
if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach))
near.Add(positions[found[i]]);
return near;
}
// The cells an extent widened by margin covers, clamped to the grid.
private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => (
Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin));
private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1);
private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1);
}
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token) =>
PostVerificationGeometry.Inside(point, ring, token);
} }
@@ -125,7 +125,8 @@ public static class LeadPathValidator
var edge = execution.Motions[groupEdge]; var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start; var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End; var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
foreach (var boundary in target.Rings.SelectMany(r => r)) // A boundary clear of the contour's first motion contains none of its points.
foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (--budget < 0) if (--budget < 0)
@@ -190,10 +191,7 @@ public static class LeadPathValidator
// Every boundary of material this far away is farther than the zone. // Every boundary of material this far away is farther than the zone.
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y) if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
.IsClearOfQueriesFrom(material.Extent, pierceRadius)) .IsClearOfQueriesFrom(material.Extent, pierceRadius))
{
Charge(material.BoundaryCount);
continue; continue;
}
if (Clearance(material) is { } distance) if (Clearance(material) is { } distance)
return (Short(distance, "another placed part"), other); return (Short(distance, "another placed part"), other);
} }
@@ -232,11 +230,11 @@ public static class LeadPathValidator
} }
// Material too far from the lead for any native boundary query to report a contact // Material too far from the lead for any native boundary query to report a contact
// (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran. // (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
// Ray parity is still asked natively unless the lead lies beside the material's rows. // are charged, so many far parts or holes do not exhaust the budget. Ray parity is
// still asked natively unless the lead lies beside the material's rows.
bool FarInside(LeadMaterialSnapshot material) bool FarInside(LeadMaterialSnapshot material)
{ {
Charge(material.BoundaryCount);
var midpoint = move.Curve.Midpoint; var midpoint = move.Curve.Midpoint;
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token); return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
} }
@@ -289,19 +287,32 @@ public static class LeadPathValidator
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false) Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
{ {
foreach (var boundary in material.Rings.SelectMany(r => r)) // Boundaries no native query from the curve can reach report no contact; only the others run.
foreach (var boundary in material.BoundariesNear(curve.Extent))
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (--budget < 0) if (--budget < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit."); throw new NotSupportedException("Lead validation exceeds the native query limit.");
var contacts = curve.Contacts(boundary, out var overlap); var contacts = curve.Contacts(boundary, out var overlap);
if (overlap || contacts.Any(p => permittedJoint is not { } joint if (overlap || contacts.Any(p => permittedJoint is not { } joint || !AtJoint(p, joint, boundary)))
|| p.DistanceTo(joint) > PostVerificationGeometry.Epsilon))
return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}"; return $"boundary; allowed={permittedJoint}; contacts={string.Join(";", contacts)}; overlap={overlap}";
} }
// With all other boundary contacts excluded, the connected open path // With all other boundary contacts excluded, the connected open path
// has constant material membership. Use the native arc midpoint, not // has constant material membership. Use the native arc midpoint, not
// the chord midpoint or endpoints (which can both lie in scrap). // the chord midpoint or endpoints (which can both lie in scrap).
return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null; return !allowInterior && material.ContainsMaterial(curve.Midpoint, token) ? "interior" : null;
// A lead tangent to the boundary it joins (an arc lead-in meeting a straight edge) has a
// double root at the joint that rounding splits into two contacts a few 1e-8 apart; one
// within JointTolerance of a joint the boundary itself passes through is still the joint.
static bool AtJoint(Vector point, Vector joint, PostVerificationGeometry.Curve boundary)
{
var distance = point.DistanceTo(joint);
return distance <= PostVerificationGeometry.Epsilon
|| distance <= JointTolerance && boundary.Contains(joint);
}
} }
// As SlideContact's: far below any kerf, far above a tangent root's rounding drift.
private const double JointTolerance = 1e-6;
} }
@@ -2,7 +2,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
@@ -54,19 +53,6 @@ internal static class NestedPartContainment
/// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary> /// <summary>Material extent of a clean program at a location: rapids and scribe marks excluded.</summary>
internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) => internal static Box CleanBounds(Program clean, Vector location, CancellationToken token) =>
ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions ExecutionMotionReader.ReadSupported(clean, location, null, token).Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null) .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox(); .Select(m => m.Curve.ToEntity().BoundingBox).ToList().GetBoundingBox();
/// <summary>Whether a host's bounds admit an inner part's bounds as a containment candidate.</summary>
internal static bool Within(Box inner, Box outer) =>
inner.Left >= outer.Left - Tolerance.Epsilon && inner.Right <= outer.Right + Tolerance.Epsilon
&& inner.Bottom >= outer.Bottom - Tolerance.Epsilon && inner.Top <= outer.Top + Tolerance.Epsilon;
/// <summary>The union of two boxes.</summary>
internal static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
return new(left, bottom, System.Math.Max(a.Right, b.Right) - left, System.Math.Max(a.Top, b.Top) - bottom);
}
} }
@@ -37,7 +37,7 @@ internal static class NestedPartReview
continue; continue;
var program = FixedLeadReview.Clean(part); var program = FixedLeadReview.Clean(part);
cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token); cleanBounds[index] = NestedPartContainment.CleanBounds(program, part.Location, token);
hostBounds[index] = NestedPartContainment.Union(part.BoundingBox, cleanBounds[index]); hostBounds[index] = BoundingBox.Union(part.BoundingBox, cleanBounds[index]);
clean[index] = program; clean[index] = program;
} }
catch (Exception exception) when (IsInvalid(exception)) catch (Exception exception) when (IsInvalid(exception))
@@ -52,7 +52,7 @@ internal static class NestedPartReview
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (inner == host || clean[inner] == null || clean[host] == null if (inner == host || clean[inner] == null || clean[host] == null
|| !NestedPartContainment.Within(cleanBounds[inner], hostBounds[host])) || !BoundingBox.IsWithin(cleanBounds[inner], hostBounds[host]))
continue; continue;
var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token, var inside = NestedPartContainment.InsideCutout(Material(inner), Material(host), token,
out var ambiguity); out var ambiguity);
@@ -33,18 +33,18 @@ internal static class OwnedCuttingParameters
PierceDiameter = Dimension(source.PierceDiameter), PierceDiameter = Dimension(source.PierceDiameter),
ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn), ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn),
InternalLeadIn = CopyLeadIn(source.InternalLeadIn), InternalLeadIn = CopyLeadIn(source.InternalLeadIn),
ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn),
ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut), ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut),
InternalLeadOut = CopyLeadOut(source.InternalLeadOut), InternalLeadOut = CopyLeadOut(source.InternalLeadOut),
ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut),
RoundLeadInAngles = source.RoundLeadInAngles, RoundLeadInAngles = source.RoundLeadInAngles,
LeadInAngleIncrement = source.LeadInAngleIncrement, LeadInAngleIncrement = source.LeadInAngleIncrement,
AutoTabMinSize = Dimension(source.AutoTabMinSize),
AutoTabMaxSize = Dimension(source.AutoTabMaxSize),
TabConfig = source.TabConfig == null TabConfig = source.TabConfig == null
? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null ? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null
: CopyTab(source.TabConfig), : CopyTab(source.TabConfig),
TabsEnabled = source.TabsEnabled, TabsEnabled = source.TabsEnabled,
SupportTabs = source.SupportTabs,
NoBedTabMinWidth = Dimension(source.NoBedTabMinWidth),
NoBedTabMaxWidth = Dimension(source.NoBedTabMaxWidth),
TabHeldMaxLength = Dimension(source.TabHeldMaxLength),
Sequencing = new SequenceParameters Sequencing = new SequenceParameters
{ {
Method = sequence.Method, Method = sequence.Method,
@@ -71,7 +71,8 @@ internal static class OwnedCuttingParameters
NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(), NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(),
LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn
{ Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) }, { Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) },
ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) }, ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn
{ Radius = Dimension(n.Radius), SweepAngle = Sweep(n.SweepAngle) },
LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn
{ LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) }, { LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) },
LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn
@@ -137,4 +138,7 @@ internal static class OwnedCuttingParameters
// Approach angles are degrees, not lengths. Signed and periodic angles are valid. // Approach angles are degrees, not lengths. Signed and periodic angles are valid.
private static double Angle(double value) => double.IsFinite(value) private static double Angle(double value) => double.IsFinite(value)
? value : throw new ArgumentException("Angles must be finite."); ? value : throw new ArgumentException("Angles must be finite.");
private static double Sweep(double value) => double.IsFinite(value) && value > 0 && value <= 180
? value : throw new ArgumentException("Arc sweep must be in (0, 180] degrees.");
} }
@@ -38,20 +38,38 @@ public sealed class OwnedExecution
public sealed class ExecutionMotion public sealed class ExecutionMotion
{ {
internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer, internal ExecutionMotion(Vector? start, Vector end, bool rapid, LayerType layer,
PostVerificationGeometry.Curve curve) PostVerificationGeometry.Curve curve, bool headRaised = false, int? code = null, int? callCode = null)
{ {
Start = start; Start = start;
End = end; End = end;
Rapid = rapid; Rapid = rapid;
Layer = layer; Layer = layer;
Curve = curve; Curve = curve;
HeadRaised = headRaised;
Code = code;
CallCode = callCode;
} }
public Vector? Start { get; } public Vector? Start { get; }
public Vector End { get; } public Vector End { get; }
public bool Rapid { get; } public bool Rapid { get; }
public LayerType Layer { get; } public LayerType Layer { get; }
/// <summary>A rapid made with the head raised over parts already cut (<see cref="HeadRaise"/>).</summary>
public bool HeadRaised { get; }
/// <summary>
/// The index of the code this motion runs in the read program's own codes; null for a motion
/// inside a sub-program and for the rapid a program that opens with a cut is given.
/// </summary>
public int? Code { get; }
/// <summary>
/// For a motion inside a sub-program, the index of the read program's own call that runs it;
/// null for the program's own motions.
/// </summary>
internal int? CallCode { get; }
public double Length => Curve?.Length ?? 0; public double Length => Curve?.Length ?? 0;
internal PostVerificationGeometry.Curve Curve { get; } internal PostVerificationGeometry.Curve Curve { get; }
internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve); internal ExecutionMotion WithStart(Vector? start) => new(start, End, Rapid, Layer, Curve, HeadRaised, Code, CallCode);
} }
@@ -82,7 +82,8 @@ public sealed class PlateCuttingState
} }
var cutOffs = plate.CutOffs.Select(c => c == null var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.") ? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray(); : new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit, c.Gaps,
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings); return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters) void Fingerprint(CuttingParameters parameters)
@@ -155,7 +156,10 @@ public sealed class PlateCuttingState
if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing) if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X) || cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
|| !Bits(cutOff.Position.Y, record.Position.Y) || !Bits(cutOff.Position.Y, record.Position.Y)
|| !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)) || !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit)
// Gaps are replaced, never edited in place, so the reference decides.
|| !ReferenceEquals(cutOff.Gaps, record.Gaps)
|| !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
return $"Cutoff {i + 1} definition changed."; return $"Cutoff {i + 1} definition changed.";
} }
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation, // Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
@@ -204,5 +208,6 @@ public sealed class PlateCuttingState
List<ContourCutPin> Pins, string PinsFingerprint); List<ContourCutPin> Pins, string PinsFingerprint);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position, private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit); double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps, double HeadLockOvercut,
bool FarEdgeFirst);
} }
@@ -5,6 +5,7 @@ using System.Threading;
using OpenNest.CNC.CuttingStrategy; using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics; using OpenNest.Diagnostics;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Support;
namespace OpenNest.CNC.CuttingPlanning; namespace OpenNest.CNC.CuttingPlanning;
@@ -14,9 +15,11 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
internal PreparedContours Owner { get; init; } internal PreparedContours Owner { get; init; }
/// <summary> /// <summary>
/// A perimeter pierce the planner placed in open scrap for the pierce zone (see /// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
/// <see cref="PreparedContours.ScrapPierceEntry"/>): the lead-in runs straight from here to /// zone (see <see cref="PreparedContours.ScrapPierceEntry"/>), or a cutout's where eroding it
/// <see cref="Point"/> instead of the configured lead-in. Null for every other choice. /// leaves room (see <see cref="PreparedContours.ErodedPierceEntries"/>). The lead-in runs
/// straight from here to <see cref="Point"/> instead of the configured lead-in; a cutout so
/// pierced has no lead-out. Null for every other choice.
/// </summary> /// </summary>
public Vector? Pierce { get; internal init; } public Vector? Pierce { get; internal init; }
} }
@@ -36,15 +39,34 @@ public sealed class PreparedContours
// Closed cutouts with tab spans, in program order. Like break cuts they are not choices: // Closed cutouts with tab spans, in program order. Like break cuts they are not choices:
// every full or prefix emission cuts their pieces after the break cuts. // every full or prefix emission cuts their pieces after the break cuts.
private readonly List<Shape> tabbedCutouts; private readonly List<Shape> tabbedCutouts;
// Pierce-zone resolutions by contour, shared with every copy: the contours are the same by ordinal.
private readonly PierceZoneMemo pierceZones;
// By contour ordinal; all null without pins. A pinned contour has exactly one entry and // By contour ordinal; all null without pins. A pinned contour has exactly one entry and
// its own owned settings (the pin's lead-in, lead-out and tab). // its own owned settings (the pin's lead-in, lead-out and tab).
private readonly ContourChoice[] pinned; private readonly ContourChoice[] pinned;
private readonly CuttingParameters[] contourParameters; private readonly CuttingParameters[] contourParameters;
private readonly int[] pinNumbers; private readonly int[] pinNumbers;
// Set when the support bed cannot hold the part level: the perimeter is tabbed and its
// automatic entries keep to points where the tab steadies the part. World = local + origin.
private readonly PartSupport support;
private readonly Vector supportOrigin;
// The outside when it is a display perimeter: shown and material, never cut. It is not one of
// the contours, so every contour is a hole; a complete emission only appends it as the
// never-executed DisplayPerimeterTail. Null for an ordinary cut perimeter.
private readonly Shape displayOutline;
private bool hasCutPerimeter => displayOutline == null;
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts, private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters) CuttingParameters parameters)
: this(shapes, scribes, breakCuts, tabbedCutouts, parameters, displayOutline: null)
{ {
}
private PreparedContours(Shape[] shapes, List<Entity> scribes, List<Shape> breakCuts, List<Shape> tabbedCutouts,
CuttingParameters parameters, Shape displayOutline, PierceZoneMemo pierceZones = null)
{
this.pierceZones = pierceZones ?? new PierceZoneMemo();
this.displayOutline = displayOutline;
this.shapes = shapes; this.shapes = shapes;
this.scribes = scribes; this.scribes = scribes;
this.breakCuts = breakCuts; this.breakCuts = breakCuts;
@@ -55,7 +77,8 @@ public sealed class PreparedContours
} }
private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token) private PreparedContours(PreparedContours unpinned, IReadOnlyList<CutPinResolution> pins, CancellationToken token)
: this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters) : this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters,
unpinned.displayOutline, unpinned.pierceZones)
{ {
contourParameters = new CuttingParameters[shapes.Length]; contourParameters = new CuttingParameters[shapes.Length];
var rejected = new List<CutPinResolution>(); var rejected = new List<CutPinResolution>();
@@ -78,7 +101,9 @@ public sealed class PreparedContours
rejected.Add(pin); rejected.Add(pin);
continue; continue;
} }
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this }; var choice = new ContourChoice(contour, pin.EntityOrdinal,
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
{ Owner = this };
ValidateChoice(choice); ValidateChoice(choice);
pinned[contour] = choice; pinned[contour] = choice;
pinNumbers[contour] = pin.PinIndex + 1; pinNumbers[contour] = pin.PinIndex + 1;
@@ -87,17 +112,86 @@ public sealed class PreparedContours
RejectedPins = rejected.AsReadOnly(); RejectedPins = rejected.AsReadOnly();
} }
// A copy that tabs the perimeter for support; every pin keeps its entry, now owned here, and
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review.
private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
source.displayOutline, source.pierceZones)
{
for (var contour = 0; contour < Count; contour++)
{
pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null;
pinNumbers[contour] = source.pinNumbers[contour];
}
contourParameters = source.contourParameters == null
? new CuttingParameters[Count]
: (CuttingParameters[])source.contourParameters.Clone();
if (pinned[PerimeterOrdinal] == null)
{
var perimeter = OwnedCuttingParameters.Copy(source.ParametersFor(PerimeterOrdinal));
perimeter.TabsEnabled = true;
perimeter.TabConfig = OwnedCuttingParameters.CopyTab(tab);
contourParameters[PerimeterOrdinal] = perimeter;
}
RejectedPins = source.RejectedPins;
this.support = support;
supportOrigin = location;
}
/// <summary>
/// A copy whose perimeter is tabbed with <paramref name="tab"/> where it steadies the part
/// on its support bed: <paramref name="support"/> analyzed this part's material placed at
/// <paramref name="location"/>, and the perimeter's automatic entries keep to points it
/// accepts (<see cref="PartSupport.AcceptsTabAt"/>) plus its best tab point. A pinned
/// perimeter keeps its pin's entry, leads and tab (the drawing wins); the copy only keeps
/// the analysis, so replay can report a pin that leaves the part unsteady.
/// </summary>
public PreparedContours WithSupportTab(PartSupport support, Vector location, Tab tab)
{
ArgumentNullException.ThrowIfNull(support);
ArgumentNullException.ThrowIfNull(tab);
PostVerificationGeometry.Validate(location);
if (!support.IsComplete || support.BestTabPoint == null)
throw new ArgumentException("Support tabs need a complete analysis with a tab point.", nameof(support));
if (!hasCutPerimeter)
throw new ArgumentException("A display perimeter is never cut, so it cannot hold a support tab.", nameof(support));
return new PreparedContours(this, support, location, tab);
}
/// <summary>
/// The analysis of a part its support bed cannot hold level (see <see cref="WithSupportTab"/>),
/// or null; <see cref="SupportOrigin"/> places its local contours in its world frame.
/// </summary>
public PartSupport Support => support;
/// <summary>The part's location: world = local + this, for <see cref="Support"/>.</summary>
public Vector SupportOrigin => supportOrigin;
/// <summary>Whether the emitted perimeter is tabbed: by the plate settings, its pin or support.</summary>
internal bool PerimeterTabbed => hasCutPerimeter && ParametersFor(PerimeterOrdinal).TabsEnabled;
public int Count => shapes.Length; public int Count => shapes.Length;
/// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary> /// <summary>The pierce zone every lead-in pierce of these contours keeps; 0 when off.</summary>
internal double PierceZoneRadius => parameters.GetPierceZoneRadius(); internal double PierceZoneRadius => parameters.GetPierceZoneRadius();
public int PerimeterOrdinal => Count - 1;
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary> /// <summary>The pierce-zone resolutions these contours' emissions share; for tests.</summary>
internal PierceZoneMemo PierceZones => pierceZones;
/// <summary>
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside
/// is a display perimeter, so every contour below it is a hole.
/// </summary>
public int PerimeterOrdinal => hasCutPerimeter ? Count - 1 : Count;
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
public bool HasCutPerimeter => hasCutPerimeter;
/// <summary>Resolved pins this preparation could not honor: a tab on an internal contour.</summary>
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>(); public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary> /// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null; public bool IsPinned(int contourOrdinal) => !(contourOrdinal == PerimeterOrdinal && !hasCutPerimeter)
&& GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary> /// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null; public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
@@ -122,17 +216,15 @@ public sealed class PreparedContours
return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned; return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
} }
// The pin's leads replace every contour type's leads, since the type the emitter detects is // The pin's leads replace both lead sets, since the type the emitter detects is
// not the caller's concern; angle rounding is off because a pinned entry never moves. // not the caller's concern; angle rounding is off because a pinned entry never moves.
private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin) private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
{ {
var owned = OwnedCuttingParameters.Copy(shared); var owned = OwnedCuttingParameters.Copy(shared);
owned.ExternalLeadIn = pin.LeadIn; owned.ExternalLeadIn = pin.LeadIn;
owned.InternalLeadIn = pin.LeadIn; owned.InternalLeadIn = pin.LeadIn;
owned.ArcCircleLeadIn = pin.LeadIn;
owned.ExternalLeadOut = pin.LeadOut; owned.ExternalLeadOut = pin.LeadOut;
owned.InternalLeadOut = pin.LeadOut; owned.InternalLeadOut = pin.LeadOut;
owned.ArcCircleLeadOut = pin.LeadOut;
owned.RoundLeadInAngles = false; owned.RoundLeadInAngles = false;
owned.TabsEnabled = pin.HasTab; owned.TabsEnabled = pin.HasTab;
if (pin.HasTab) if (pin.HasTab)
@@ -155,6 +247,7 @@ public sealed class PreparedContours
var scribes = new List<Entity>(); var scribes = new List<Entity>();
var breakCuts = new List<Shape>(); var breakCuts = new List<Shape>();
var tabbedCutouts = new List<Shape>(); var tabbedCutouts = new List<Shape>();
Shape displayPerimeter = null;
Shape breakCut = null; Shape breakCut = null;
var current = new Shape(); var current = new Shape();
foreach (var entity in geometryProgram.ToGeometry()) foreach (var entity in geometryProgram.ToGeometry())
@@ -185,7 +278,8 @@ public sealed class PreparedContours
scribes.Add(entity); scribes.Add(entity);
continue; continue;
} }
if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab) if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display && entity.Layer != SpecialLayers.Tab
&& entity.Layer != SpecialLayers.DisplayPerimeter)
throw new ArgumentException("Preparation requires clean cut/display contours."); throw new ArgumentException("Preparation requires clean cut/display contours.");
if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length)) if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length))
throw new ArgumentException("Degenerate contour entity."); throw new ArgumentException("Degenerate contour entity.");
@@ -203,6 +297,28 @@ public sealed class PreparedContours
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity))) if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour(); FinishContour();
} }
if (displayPerimeter != null)
{
if (current.Entities.Count != 0)
throw new ArgumentException("Preparation requires nonempty closed contours.");
if (contours.Count == 0 && tabbedCutouts.Count == 0 && breakCuts.Count == 0)
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
// Every cut must lie on the blank: a non-rectangular blank's box is not enough.
// Each cut is sampled along its arcs and must not cross or touch the outline anywhere.
var outline = displayPerimeter.ToPolygon();
foreach (var shape in contours.Concat(tabbedCutouts).Concat(breakCuts))
if (shape.ToPolygon().Vertices.Any(vertex => !outline.ContainsPoint(vertex))
|| displayPerimeter.Intersects(shape))
throw new ArgumentException("A display perimeter must enclose every other contour.");
// The search plans a part through its closed contours; tabbed cutouts and break cuts
// alone leave it nothing to choose.
if (contours.Count == 0)
throw new ArgumentException("A display perimeter needs at least one closed cutout without tabs.");
foreach (var cut in breakCuts)
cut.UpdateBounds();
// Never a contour: nothing can choose, emit or lead it. Every contour is a hole.
return new(contours.ToArray(), scribes, breakCuts, tabbedCutouts, ownedParameters, displayPerimeter);
}
if (current.Entities.Count != 0 || contours.Count == 0) if (current.Entities.Count != 0 || contours.Count == 0)
throw new ArgumentException("Preparation requires nonempty closed contours."); throw new ArgumentException("Preparation requires nonempty closed contours.");
var perimeter = 0; var perimeter = 0;
@@ -223,7 +339,15 @@ public sealed class PreparedContours
void FinishContour() void FinishContour()
{ {
current.UpdateBounds(); current.UpdateBounds();
if (ContourCuttingStrategy.IsTabbedCutout(current)) if (current.Entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
{
if (!current.Entities.TrueForAll(e => e.Layer == SpecialLayers.DisplayPerimeter))
throw new ArgumentException("A display perimeter must be one whole closed contour.");
if (displayPerimeter != null)
throw new ArgumentException("A display perimeter must enclose every other contour.");
displayPerimeter = current;
}
else if (ContourCuttingStrategy.IsTabbedCutout(current))
{ {
if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab)) if (current.Entities.TrueForAll(e => e.Layer == SpecialLayers.Tab))
throw new ArgumentException("A tabbed cutout has nothing to cut."); throw new ArgumentException("A tabbed cutout has nothing to cut.");
@@ -285,8 +409,12 @@ public sealed class PreparedContours
void Add(int entityOrdinal, Vector point) void Add(int entityOrdinal, Vector point)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon)) if (entries.Count >= maxEntries)
entries.Add(Entry(contourOrdinal, entityOrdinal, point)); return;
// Compare the entry as emitted: snapping can bring two raw points together.
var entry = Entry(contourOrdinal, entityOrdinal, point);
if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
entries.Add(entry);
} }
} }
@@ -311,6 +439,7 @@ public sealed class PreparedContours
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point) public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{ {
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this }; var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
ValidateChoice(choice); ValidateChoice(choice);
return choice; return choice;
@@ -323,12 +452,63 @@ public sealed class PreparedContours
/// emission reads only this choice and the perimeter, and the lead validator checks the /// emission reads only this choice and the perimeter, and the lead validator checks the
/// lead as usual. A pinned perimeter keeps its pin's entry. /// lead as usual. A pinned perimeter keeps its pin's entry.
/// </summary> /// </summary>
internal ContourChoice ScrapPierceEntry(Vector pierce) internal ContourChoice ScrapPierceEntry(Vector pierce) => PiercedEntry(PerimeterOrdinal, pierce);
/// <summary>
/// Entries of cutout <paramref name="contourOrdinal"/> pierced where eroding it leaves room
/// (see <see cref="ErodedPierce"/>): the planner's fallback when no configured lead-in fits the
/// cutout. For each of the first <paramref name="max"/> <paramref name="targets"/> (its own
/// entries, usually ranked) the pierce is the eroded point nearest it, and the lead-in runs
/// straight from there to the target itself, then, as an alternative, to the cutout's native
/// point closest to the pierce. Neither has a lead-out. Nothing for the perimeter or a pinned
/// cutout; the lead validator still checks each one.
/// </summary>
internal IReadOnlyList<ContourChoice> ErodedPierceEntries(int contourOrdinal, IReadOnlyList<ContourChoice> targets,
int max, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
return Array.Empty<ContourChoice>();
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
var depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
ApplicableLeadInLength(contourOrdinal));
var found = ErodedPierce.Find(shape, depth,
targets.Select(t => t.Point).ToArray(), max);
var entries = new List<ContourChoice>();
for (var i = 0; i < found.Pierces.Count; i++)
{
token.ThrowIfCancellationRequested();
var pierce = found.Pierces[i];
Add(() => targets[i] with { Pierce = pierce });
Add(() => PiercedEntry(contourOrdinal, pierce));
}
return entries;
void Add(Func<ContourChoice> entry)
{
try
{
var choice = entry();
ValidateChoice(choice);
if (!entries.Any(e => e.Point == choice.Point && e.Pierce == choice.Pierce))
entries.Add(choice);
}
catch (ArgumentException)
{
// A pierce on the wall itself has no lead; the others still stand.
}
}
}
// A contour entry at the closest native point to a pierce off it, along a straight lead-in.
private ContourChoice PiercedEntry(int contourOrdinal, Vector pierce)
{ {
PostVerificationGeometry.Validate(pierce); PostVerificationGeometry.Validate(pierce);
var shape = GetShape(PerimeterOrdinal); var shape = GetShape(contourOrdinal);
var point = shape.ClosestPointTo(pierce, out var entity); var point = shape.ClosestPointTo(pierce, out var entity);
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point) point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
{ {
Owner = this, Owner = this,
Pierce = pierce, Pierce = pierce,
@@ -338,7 +518,7 @@ public sealed class PreparedContours
} }
/// <summary> /// <summary>
/// The uncapped preferred automatic start catalogue for one contour, in preference then /// The uncapped preferred automatic start catalog for one contour, in preference then
/// contour-travel order: convex corners of the contour's own winding, then straight-edge /// contour-travel order: convex corners of the contour's own winding, then straight-edge
/// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear /// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
/// line/line splits, circles and interior points never appear; each geometric point is /// line/line splits, circles and interior points never appear; each geometric point is
@@ -354,12 +534,12 @@ public sealed class PreparedContours
if (pinned[contourOrdinal] is { } pin) if (pinned[contourOrdinal] is { } pin)
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) }; return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
if (IsSingleCircle(shape)) if (IsSingleCircle(shape))
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue."); throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalog.");
return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token)); return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
} }
/// <summary> /// <summary>
/// The complete uncapped automatic start catalogue: the preferred points of /// The complete uncapped automatic start catalog: the preferred points of
/// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc /// <see cref="AutomaticEntryCandidates"/> followed by tier-3 fallbacks — native arc
/// midpoints, near-convex-corner points on straight edges, the eight compass points of a /// midpoints, near-convex-corner points on straight edges, the eight compass points of a
/// whole circle, and the exact target-facing closest point toward /// whole circle, and the exact target-facing closest point toward
@@ -367,12 +547,13 @@ public sealed class PreparedContours
/// A pure-circle contour therefore yields compass points instead of refusing. Every /// A pure-circle contour therefore yields compass points instead of refusing. Every
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the /// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
/// preferred tier; fallbacks never replace a preferred point at the same geometry. A /// preferred tier; fallbacks never replace a preferred point at the same geometry. A
/// pinned contour's catalogue is its pinned entry alone: nothing may relocate it. /// pinned contour's catalog is its pinned entry alone: nothing may relocate it.
/// </summary> /// </summary>
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks( internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
int contourOrdinal, int contourOrdinal,
Vector? lookAhead = null, Vector? lookAhead = null,
CancellationToken token = default) CancellationToken token = default,
SupportEntries supportEntries = SupportEntries.Steadying)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal); var shape = GetShape(contourOrdinal);
@@ -382,7 +563,45 @@ public sealed class PreparedContours
? new List<ContourEntryCandidate>() ? new List<ContourEntryCandidate>()
: PreferredCandidates(shape, contourOrdinal, token); : PreferredCandidates(shape, contourOrdinal, token);
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token)); all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
return MergeByGeometry(all); return SupportFiltered(contourOrdinal, all, supportEntries);
}
/// <summary>
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
/// its automatic catalog then keeps to steadying points unless asked for a fallback.
/// </summary>
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
/// <summary>
/// The support margin with this perimeter's tab at <paramref name="choice"/> (see
/// <see cref="PartSupport.MarginWith"/>); negative infinity when it is not tabbed for support.
/// </summary>
internal double SupportMarginAt(ContourChoice choice) =>
support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
/// <summary>Whether a support tab at <paramref name="choice"/> holds the part level (see <see cref="PartSupport.HoldsLevelWithTabAt"/>).</summary>
internal bool HoldsLevelAt(ContourChoice choice) =>
support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
/// <summary>Whether a support tab at <paramref name="choice"/> keeps the inside-corner clearance.</summary>
internal bool IsClearOfInsideCorners(ContourChoice choice) =>
support == null || support.IsClearOfInsideCorners(choice.Point + supportOrigin);
// A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains
// the outline's best tab point. The search's fallbacks, when none of them has a lead-in that
// fits, keep every candidate clear of inside corners and then every candidate, still tabbed.
private IReadOnlyList<ContourEntryCandidate> SupportFiltered(int contourOrdinal,
List<ContourEntryCandidate> candidates, SupportEntries entries)
{
if (support == null || contourOrdinal != PerimeterOrdinal || entries == SupportEntries.Any)
return MergeByGeometry(candidates);
if (entries == SupportEntries.ClearOfInsideCorners)
return MergeByGeometry(candidates.Where(c => IsClearOfInsideCorners(c.Choice)).ToList());
var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList();
var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin);
if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point))
kept.Add(new ContourEntryCandidate(best, AutomaticEntryKind.SupportTab));
return MergeByGeometry(kept);
} }
private static bool IsSingleCircle(Shape shape) => private static bool IsSingleCircle(Shape shape) =>
@@ -390,8 +609,8 @@ public sealed class PreparedContours
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary> /// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token) private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
=> CataloguePoints(shape, token) => CatalogPoints(shape, token)
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind)) .Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
.ToList(); .ToList();
/// <summary> /// <summary>
@@ -436,7 +655,7 @@ public sealed class PreparedContours
// endpoint and so never lands on the reflex/cusp vertex at the far end. // endpoint and so never lands on the reflex/cusp vertex at the far end.
if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle)) if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
{ {
foreach (var corner in CataloguePoints(shape, token) foreach (var corner in CatalogPoints(shape, token)
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner)) .Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -475,9 +694,10 @@ public sealed class PreparedContours
var point = inward ? line.StartPoint + offset : line.EndPoint - offset; var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
// Strictly inside the edge by projection parameter (distance alone loses the // Strictly inside the edge by projection parameter (distance alone loses the
// sign when a short edge is overshoot): never the corner, never the far // sign when a short edge is overshoot): never the corner, never the far
// endpoint (where a reflex vertex might sit). Short edges omit the point. // endpoint (where a reflex vertex might sit). Short edges omit the point. The
// margin is the vertex snap distance, so Entry cannot snap it onto either end.
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y; var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon) if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
return; return;
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner)); fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
} }
@@ -549,14 +769,16 @@ public sealed class PreparedContours
}; };
/// <summary> /// <summary>
/// The preferred catalogue points for one contour: for each entity, its convex corner /// The preferred catalog points for one contour: for each entity, its convex corner
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint, /// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute /// plus tangent line/arc joints. The joints of a corner fillet (see <see cref="FilletReach"/>)
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
/// nothing; a single whole circle yields no points at all. /// nothing; a single whole circle yields no points at all.
/// </summary> /// </summary>
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token) private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CatalogPoints(
Shape shape, CancellationToken token)
{ {
var found = new List<(int, Vector, AutomaticEntryKind)>(); var found = new List<(int, Vector, AutomaticEntryKind, double)>();
var count = shape.Entities.Count; var count = shape.Entities.Count;
if (count == 1 && shape.Entities[0] is Circle) if (count == 1 && shape.Entities[0] is Circle)
{ {
@@ -571,7 +793,7 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var entity = shape.Entities[i]; var entity = shape.Entities[i];
// Vertex reached by travelling along entity i (its end point), reported under // Vertex reached by traveling along entity i (its end point), reported under
// entity i. The closed contour guarantees every vertex appears exactly once // entity i. The closed contour guarantees every vertex appears exactly once
// this way; each is classified from the contour's own winding. // this way; each is classified from the contour's own winding.
var vertex = End(entity); var vertex = End(entity);
@@ -580,36 +802,72 @@ public sealed class PreparedContours
switch (corner.Kind) switch (corner.Kind)
{ {
case ContourCuttingStrategy.CornerKind.Convex: case ContourCuttingStrategy.CornerKind.Convex:
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner)); found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
break; break;
case ContourCuttingStrategy.CornerKind.Smooth case ContourCuttingStrategy.CornerKind.Smooth
when entity is Line && Next(i) is Arc: when entity is Line && Next(i) is Arc:
// A line leaving into an arc: the tangent joint. The reverse travel // A line leaving into an arc: the tangent joint. The reverse travel
// order classifies the same joint from the arc, matched below. // order classifies the same joint from the arc, matched below.
found.Add((i, vertex, AutomaticEntryKind.TangentJoint)); found.Add(Joint(i, vertex, (i + 1) % count));
break; break;
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins // Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
// of a plain straight edge are not preferred automatic starts here. // of a plain straight edge are not preferred automatic starts here.
case ContourCuttingStrategy.CornerKind.Smooth case ContourCuttingStrategy.CornerKind.Smooth
when entity is Arc && Next(i) is Line: when entity is Arc && Next(i) is Line:
found.Add((i, vertex, AutomaticEntryKind.TangentJoint)); found.Add(Joint(i, vertex, i));
break; break;
} }
} }
if (entity is Line line) if (entity is Line line)
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint)); found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
} }
return found; return found;
Entity Next(int index) => shape.Entities[(index + 1) % count]; Entity Next(int index) => shape.Entities[(index + 1) % count];
(int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
FilletReach(shape, arcOrdinal) is { } reach
? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
: (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
}
/// <summary>
/// How far the joints of the arc at <paramref name="arcOrdinal"/> lie from the sharp corner it
/// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
/// than a half circle the convex way, and reaching back along each edge no further than the
/// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
/// cut closes is least seen, instead of on a straight edge. Null for any other arc.
/// </summary>
private static double? FilletReach(Shape shape, int arcOrdinal)
{
var count = shape.Entities.Count;
if (shape.Entities[arcOrdinal] is not Arc arc
|| shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
|| shape.Entities[(arcOrdinal + 1) % count] is not Line after)
return null;
var sweep = arc.SweepAngle();
if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
return null;
if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
return null;
var winding = ContourCuttingStrategy.DetermineWinding(shape);
if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
(after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
return null;
var reach = arc.Radius * System.Math.Tan(sweep / 2);
return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
bool IsSmooth(Vector vertex, Entity entity) =>
ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
&& corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
} }
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary> /// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
public Program Emit(IReadOnlyList<ContourChoice> choices) public Program Emit(IReadOnlyList<ContourChoice> choices)
{ {
Validate(choices, true); Validate(choices, true);
return EmitPrefix(choices); return EmitOwned(choices, complete: true);
} }
/// <summary> /// <summary>
@@ -626,21 +884,28 @@ public sealed class PreparedContours
throw new ArgumentException("Foreign contour choice."); throw new ArgumentException("Foreign contour choice.");
ValidateChoice(choice); ValidateChoice(choice);
// Same owned clones as a real emission; the source shapes/settings are never used // Same owned clones as a real emission; the source shapes/settings are never used
// directly, so the probe cannot drift the preparation or mutate it. // directly, so the probe cannot drift the preparation or mutate it. Isolated emission
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated( // reads only the chosen contour and the contour count, so only that contour is cloned.
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice, var owned = new Shape[shapes.Length];
contourParameters); owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
return new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitCandidateIsolated(
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
} }
// Each prefix is a standalone owned program, including the same scribes and break cuts once. // Each prefix is a standalone owned program, including the same scribes and break cuts once.
internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices) internal Program EmitPrefix(IReadOnlyList<ContourChoice> choices)
{ {
Validate(choices, false); Validate(choices, false);
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared( return EmitOwned(choices, complete: false);
}
// Only a complete emission carries a display perimeter's tail: prefixes are search probes.
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitPrepared(
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices, shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(), contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(),
tabbedCutouts.Select(s => (Shape)s.Clone()).ToList()); tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
} complete ? (Shape)displayOutline?.Clone() : null);
/// <summary>Open break cuts this preparation cuts before its contours.</summary> /// <summary>Open break cuts this preparation cuts before its contours.</summary>
public int BreakCutCount => breakCuts.Count; public int BreakCutCount => breakCuts.Count;
@@ -661,6 +926,8 @@ public sealed class PreparedContours
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token); var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
// Only the outside is tabbed, with its own pin's tab when it is pinned. // Only the outside is tabbed, with its own pin's tab when it is pinned.
if (!hasCutPerimeter)
return new(this, choices, expected, 0);
var perimeter = ParametersFor(PerimeterOrdinal); var perimeter = ParametersFor(PerimeterOrdinal);
return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0); return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
} }
@@ -695,10 +962,8 @@ public sealed class PreparedContours
if (choice.Pierce is { } pierce) if (choice.Pierce is { } pierce)
{ {
PostVerificationGeometry.Validate(pierce); PostVerificationGeometry.Validate(pierce);
if (choice.ContourOrdinal != PerimeterOrdinal)
throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon) if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
throw new ArgumentException("A scrap pierce must lie off its contour."); throw new ArgumentException("A planner-placed pierce must lie off its contour.");
} }
} }
@@ -706,22 +971,25 @@ public sealed class PreparedContours
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal]; ? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
/// <summary> /// <summary>
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing /// Representative POINTS per contour ordinal (bounding-box center) for hole routing
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the /// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points. /// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
/// </summary> /// </summary>
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default) internal IReadOnlyList<Vector?> HoleCenters(CancellationToken token = default)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var centres = new Vector?[Count]; var centers = new Vector?[Count];
for (var contour = 0; contour < PerimeterOrdinal; contour++) for (var contour = 0; contour < PerimeterOrdinal; contour++)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
centres[contour] = GetShape(contour).BoundingBox.Center; centers[contour] = GetShape(contour).BoundingBox.Center;
} }
return centres; return centers;
} }
/// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
internal Box ContourBounds(int contourOrdinal) => GetShape(contourOrdinal).BoundingBox;
/// <summary> /// <summary>
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>. /// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict. /// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
@@ -733,6 +1001,33 @@ public sealed class PreparedContours
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point); && shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
} }
/// <summary>
/// Emission splits a contour at its entry with <see cref="Arc.SplitAt"/>/<see cref="Line.SplitAt"/>,
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
/// stored center sits beside the chord's midpoint) is never cut.
/// </summary>
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
{
if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
|| shape.Entities[entityOrdinal] is not (Line or Arc))
return point;
var entity = shape.Entities[entityOrdinal];
// Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
// programs emitted before this snap (saved pinned entries) re-emit unchanged.
bool Snaps(Vector vertex)
{
var distance = point.DistanceTo(vertex);
return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
}
var start = Start(entity);
if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
return Snaps(start) ? start : point;
var end = End(entity);
return Snaps(end) ? end : point;
}
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint(); private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint(); private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
@@ -12,19 +12,37 @@ public sealed class ReleasedContourState
{ {
private readonly List<Obstacle> obstacles = new(); private readonly List<Obstacle> obstacles = new();
private readonly bool reportMissingLeadIns; private readonly bool reportMissingLeadIns;
// Parts (by caller part number) whose single tab may not hold them level: once cut, their
// tabbed perimeter is an obstacle as if cut free, since the part may tip into the head's path.
private readonly Func<int, bool> tabMayNotHold;
// How the last analysed part ended: the contours it numbered, and whether nothing it left
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
// motions appended to it are judged. See Continue.
private int partContours;
private bool partClosed;
public ReleasedContourState() : this(true) { } public ReleasedContourState() : this(true) { }
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning /// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
/// may pierce directly on a contour and checks travel without requiring a lead.</param> /// may pierce directly on a contour and checks travel without requiring a lead.</param>
public ReleasedContourState(bool reportMissingLeadIns) public ReleasedContourState(bool reportMissingLeadIns) : this(reportMissingLeadIns, null) { }
/// <param name="tabMayNotHold">Part numbers whose support tab may not hold them level (no
/// single tab can steady them): the longest open cut a part leaves, its tabbed
/// perimeter, becomes an obstacle when the part ends, closed across its tab gap.</param>
public ReleasedContourState(bool reportMissingLeadIns, Func<int, bool> tabMayNotHold)
{ {
this.reportMissingLeadIns = reportMissingLeadIns; this.reportMissingLeadIns = reportMissingLeadIns;
this.tabMayNotHold = tabMayNotHold;
} }
public ReleasedContourState Copy() public ReleasedContourState Copy()
{ {
var copy = new ReleasedContourState(reportMissingLeadIns); var copy = new ReleasedContourState(reportMissingLeadIns, tabMayNotHold)
{
partContours = partContours,
partClosed = partClosed,
};
copy.obstacles.AddRange(obstacles); copy.obstacles.AddRange(obstacles);
return copy; return copy;
} }
@@ -45,9 +63,11 @@ public sealed class ReleasedContourState
/// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a /// As <see cref="Check(OwnedExecution, Vector?, int, bool, CancellationToken)"/>, but a
/// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is /// finding whose offending motion <paramref name="isFixed"/> owns (by motion index) is
/// returned apart: a fixed emission is accepted as given and only reported. The checker /// returned apart: a fixed emission is accepted as given and only reported. The checker
/// state advances exactly as it would without ownership. /// state advances exactly as it would without ownership. Crossings made by a rapid with the
/// head raised are returned apart as well, as Raised: they are the raise's reason, not a fault.
/// </summary> /// </summary>
internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed) Check( internal (IReadOnlyList<PostVerificationFinding> Strict, IReadOnlyList<PostVerificationFinding> Fixed,
IReadOnlyList<PostVerificationFinding> Raised) Check(
OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed, OwnedExecution execution, Vector? arrival, int partNumber, bool cutoff, Func<int, bool> isFixed,
CancellationToken token) CancellationToken token)
{ {
@@ -58,14 +78,39 @@ public sealed class ReleasedContourState
moves[0] = moves[0].WithStart(arrival); moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>(); var findings = new List<PostVerificationFinding>();
var fixedFindings = new List<PostVerificationFinding>(); var fixedFindings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings); var raisedFindings = new List<PostVerificationFinding>();
return (findings.AsReadOnly(), fixedFindings.AsReadOnly()); AnalyzeMoves(moves, cutoff, findings, 1, partNumber, token, isFixed, fixedFindings,
raisedFindings: raisedFindings);
return (findings.AsReadOnly(), fixedFindings.AsReadOnly(), raisedFindings.AsReadOnly());
}
/// <summary>
/// Checks <paramref name="execution"/> as the continuation of the part the last check ended
/// in, as if that part's whole execution had been checked with these motions appended: the
/// findings are those the appended motions would add, and the state advances the same way.
/// It needs that part to have ended with every cut closed and nothing deferred, and the
/// continuation to open with a rapid and end the same way; otherwise the result is null and
/// this state must be discarded, and the caller checks the whole execution instead. Fixed
/// emissions and cutoffs are not continued.
/// </summary>
internal IReadOnlyList<PostVerificationFinding> Continue(OwnedExecution execution, Vector arrival,
int partNumber, CancellationToken token)
{
ArgumentNullException.ThrowIfNull(execution);
PostVerificationGeometry.Validate(arrival);
if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
return null;
var moves = execution.Motions.ToArray();
moves[0] = moves[0].WithStart(arrival);
var findings = new List<PostVerificationFinding>();
AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours);
return partClosed ? findings.AsReadOnly() : null;
} }
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff, internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token, List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null, Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
bool proveOpenCuts = false) int contoursBefore = 0, List<PostVerificationFinding> raisedFindings = null)
{ {
var current = new CutGroup(); var current = new CutGroup();
var unfinished = new List<CutGroup>(); var unfinished = new List<CutGroup>();
@@ -78,7 +123,7 @@ public sealed class ReleasedContourState
CutGroup previous = null; CutGroup previous = null;
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>(); var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
var hasLead = false; var hasLead = false;
var contourNumber = 0; var contourNumber = contoursBefore;
var inBreakCut = false; var inBreakCut = false;
var reportedTab = false; var reportedTab = false;
for (var index = 0; index < moves.Count; index++) for (var index = 0; index < moves.Count; index++)
@@ -95,7 +140,7 @@ public sealed class ReleasedContourState
Finish(); Finish();
if (reportMissingLeadIns && !cutoff && !hasLead) if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
"A break cut has no nonzero placed lead-in motion.") "A break cut is missing lead-in geometry.")
{ Location = move.Curve.Start }, owned); { Location = move.Curve.Start }, owned);
inBreakCut = true; inBreakCut = true;
hasLead = false; hasLead = false;
@@ -127,16 +172,31 @@ public sealed class ReleasedContourState
// A rapid well clear of a contour's extent can neither cross nor touch it. // A rapid well clear of a contour's extent can neither cross nor touch it.
if (reach.IsClearOf(obstacle.Extent)) if (reach.IsClearOf(obstacle.Extent))
continue; continue;
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token)) // A rapid leaving an overcut departs from its retraced tail, which lies on the first
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, // cut only within the seam tolerance its arcs were matched at. Contact within that
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + // much of the departure is the departure itself.
$"of part {obstacle.Part}.") var from = start;
{ if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
Location = (start + move.End) * 0.5, && start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
RapidStart = start, from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
RapidEnd = move.End, if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token) continue;
}, owned); var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
+ (obstacle.TabMayNotHold
? $"the tabbed outside of part {obstacle.Part}, which its tab may not hold level."
: $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}."))
{
Location = (start + move.End) * 0.5,
RapidStart = start,
RapidEnd = move.End,
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
};
// The head clears a cut part on a raised rapid; the crossing is the raise's reason.
if (move.HeadRaised)
raisedFindings?.Add(crossing);
else
Add(crossing, owned);
} }
} }
else if (move.Layer == LayerType.Leadin) else if (move.Layer == LayerType.Leadin)
@@ -179,7 +239,7 @@ public sealed class ReleasedContourState
contourNumber++; contourNumber++;
if (reportMissingLeadIns && !cutoff && !hasLead) if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null, Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.") $"Cutting contour {contourNumber} is missing lead-in geometry.")
{ Location = move.Curve.Start }, owned); { Location = move.Curve.Start }, owned);
hasLead = false; hasLead = false;
// A rapid can pause/reposition without leaving any material gap. // A rapid can pause/reposition without leaving any material gap.
@@ -201,19 +261,24 @@ public sealed class ReleasedContourState
// A completed contour becomes an obstacle immediately, not at part end. // A completed contour becomes an obstacle immediately, not at part end.
if (PostVerificationGeometry.Closed(current.Curves)) if (PostVerificationGeometry.Closed(current.Curves))
Finish(); Finish();
else if (OvercutClosure(current.Curves) is { } closing)
{
current.OvercutEnd = current.Curves[^1].End;
current.Curves[^1] = closing;
Finish();
}
} }
} }
Finish(); Finish();
loose.Leads.AddRange(leadIns); loose.Leads.AddRange(leadIns);
// Open cuts that provably enclose nothing retain all their material, however many // Open cuts that provably enclose nothing retain all their material, however many
// pieces a tabbed cutout was cut in. Otherwise they need manual review. Planning proves // pieces a tabbed cutout was cut in, and so does a tabbed perimeter whose lead-out stays
// them too (its own tabbed cutouts are cut in pieces without lead-outs), but only // out of its tab: planning and pre-post review prove them alike. Otherwise they need
// pre-post review lets the proof clear a lead-out: planning keeps refusing to certify // manual review.
// its own tabs followed by a lead-out.
var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0) var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0)
&& unfinished.All(LeadsClearOpenEnds) && unfinished.All(LeadsClearOpenEnds)
&& OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token); && OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token);
for (var i = deferred.Count - 1; i >= 0 && !(retained && proveOpenCuts); i--) for (var i = deferred.Count - 1; i >= 0 && !retained; i--)
deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding); deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding);
// Only one open fragment per part can be the planner's own certified tab, so a // Only one open fragment per part can be the planner's own certified tab, so a
// further fragment that a fixed emission cut makes this uncertainty that emission's. // further fragment that a fixed emission cut makes this uncertainty that emission's.
@@ -222,6 +287,19 @@ public sealed class ReleasedContourState
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " + "Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."), "they cannot be assumed to be retained by tabs. Review rapid travel manually."),
unfinished.Any(chain => chain.Fixed)); unfinished.Any(chain => chain.Fixed));
// A part that may tip once cut is avoided like one cut free. The retention proof above is
// unchanged: the part is still attached by its tab, just not held level by it.
if (!cutoff && unfinished.Count > 0 && tabMayNotHold?.Invoke(part) == true)
{
var perimeter = unfinished.MaxBy(chain => chain.Curves.Sum(curve => curve.Length));
var curves = perimeter.Curves.ToList();
if (curves[^1].End.DistanceTo(curves[0].Start) > PostVerificationGeometry.Epsilon)
curves.Add(PostVerificationGeometry.Curve.Create(curves[^1].End, curves[0].Start, null, false));
obstacles.Add(new(part, contourNumber, curves, null) { TabMayNotHold = true });
}
partContours = contourNumber;
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
&& !inBreakCut && loose.Leads.Count == 0;
void Add(PostVerificationFinding finding, bool fixedEmission) void Add(PostVerificationFinding finding, bool fixedEmission)
{ {
@@ -244,9 +322,9 @@ public sealed class ReleasedContourState
return; return;
// A real uncut gap leaves the contour attached. CuttingParameters can be stale; // A real uncut gap leaves the contour attached. CuttingParameters can be stale;
// no flag or tab configuration is used as evidence of retention. // no flag or tab configuration is used as evidence of retention.
if (!cutoff && PostVerificationGeometry.Closed(current.Curves)) if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
{ {
obstacles.Add(new(part, contourNumber, current.Curves.ToArray())); obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
closed.Add(current); closed.Add(current);
} }
else if (!cutoff) else if (!cutoff)
@@ -255,12 +333,37 @@ public sealed class ReleasedContourState
} }
} }
/// <summary>
/// The closing piece of a contour cut on past its own start along its first cut, as a source
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
/// cut passes back through the start and its tail retraces the first cut. The tail encloses
/// nothing, so the contour is closed by the last cut up to its start. Null for any other shape.
/// Arcs of one circle may carry centers derived from their own rounded endpoints, so the seam
/// is matched at <see cref="PostVerificationGeometry.RaySeamTolerance"/>, the closure seam
/// ray parity already admits; the obstacle keeps that seam, never the retraced tail.
/// </summary>
private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList<PostVerificationGeometry.Curve> curves)
{
if (curves.Count < 2)
return null;
const double tolerance = PostVerificationGeometry.RaySeamTolerance;
var first = curves[0];
var last = curves[^1];
if (!last.SameDirection(first, tolerance) || !last.Contains(first.Start, tolerance)
|| !first.Contains(last.End, tolerance))
return null;
var seam = last.ClosestPoint(first.Start);
var along = last.DistanceAlong(seam);
var tail = last.Length - along;
return along > tolerance && tail > tolerance && tail <= first.Length + tolerance ? last.Until(seam) : null;
}
/// <summary> /// <summary>
/// Whether one part's open cuts, with their leads, can release no material. A closed /// Whether one part's open cuts, with their leads, can release no material. A closed
/// contour with its own leads already encloses whatever it releases and counts as one node. /// contour with its own leads already encloses whatever it releases and counts as one node.
/// Open cuts may touch any cut only at their own ends, and must form no loop through those /// Open cuts may touch any cut only at their own ends, and must form no loop through those
/// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and /// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and
/// tab strength are not modelled, exactly as for a single open contour. /// tab strength are not modeled, exactly as for a single open contour.
/// </summary> /// </summary>
private const double JointTolerance = 1e-5; private const double JointTolerance = 1e-5;
@@ -396,6 +499,9 @@ public sealed class ReleasedContourState
internal List<PostVerificationGeometry.Curve> Curves { get; } = new(); internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
internal List<PostVerificationGeometry.Curve> Leads { get; } = new(); internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
internal bool Fixed { get; set; } internal bool Fixed { get; set; }
// Where the cut really ended when OvercutClosure closed it: the retraced tail's end, on the
// first cut. Its last curve now ends at the seam instead.
internal Vector? OvercutEnd { get; set; }
} }
private sealed class DisjointSet(int count) private sealed class DisjointSet(int count)
@@ -412,9 +518,13 @@ public sealed class ReleasedContourState
internal void Union(int a, int b) => parents[Find(a)] = Find(b); internal void Union(int a, int b) => parents[Find(a)] = Find(b);
} }
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves) private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
Vector? OvercutEnd)
{ {
internal PostVerificationGeometry.Extent Extent { get; } = internal PostVerificationGeometry.Extent Extent { get; } =
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent)); Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
/// <summary>A tabbed perimeter closed across its tab gap, for a part its tab may not hold level.</summary>
internal bool TabMayNotHold { get; init; }
} }
} }
@@ -72,8 +72,8 @@ internal static class SeamOvercut
// Incremental coordinates only need a consistent origin within one contour. // Incremental coordinates only need a consistent origin within one contour.
var reference = incremental ? position ?? new Vector() : new Vector(); var reference = incremental ? position ?? new Vector() : new Vector();
var end = reference + motion.EndPoint; var end = reference + motion.EndPoint;
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display } var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter }
or ArcMove { Layer: LayerType.Cut or LayerType.Display }; or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.DisplayPerimeter };
if (!cut || position == null) if (!cut || position == null)
{ {
Finish(); Finish();
@@ -160,7 +160,7 @@ internal static class SeamOvercut
return true; return true;
} }
/// <summary>The angle travelled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary> /// <summary>The angle traveled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
private static double Travel(Vector center, Vector from, Vector to, bool clockwise) private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
{ {
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X); var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
@@ -83,16 +83,16 @@ internal static class StateFingerprint
Out(p.ExternalLeadOut); Out(p.ExternalLeadOut);
Lead(p.InternalLeadIn); Lead(p.InternalLeadIn);
Out(p.InternalLeadOut); Out(p.InternalLeadOut);
Lead(p.ArcCircleLeadIn);
Out(p.ArcCircleLeadOut);
Number(p.PierceClearance); Number(p.PierceClearance);
Number(p.PierceDiameter); Number(p.PierceDiameter);
Flag(p.RoundLeadInAngles); Flag(p.RoundLeadInAngles);
Number(p.LeadInAngleIncrement); Number(p.LeadInAngleIncrement);
Number(p.AutoTabMinSize);
Number(p.AutoTabMaxSize);
TabSettings(p.TabConfig); TabSettings(p.TabConfig);
Flag(p.TabsEnabled); Flag(p.TabsEnabled);
Flag(p.SupportTabs);
Number(p.NoBedTabMinWidth);
Number(p.NoBedTabMaxWidth);
Number(p.TabHeldMaxLength);
Sequence(p.Sequencing); Sequence(p.Sequencing);
Assignment(p.Assignment); Assignment(p.Assignment);
text.Append('}'); text.Append('}');
@@ -141,6 +141,7 @@ internal static class StateFingerprint
break; break;
case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)): case ArcLeadIn l when Begin(lead, typeof(ArcLeadIn)):
Number(l.Radius); Number(l.Radius);
Number(l.SweepAngle);
break; break;
case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)): case LineArcLeadIn l when Begin(lead, typeof(LineArcLeadIn)):
Number(l.LineLength); Number(l.LineLength);
@@ -10,6 +10,12 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
public CuttingParameters Parameters { get; set; } public CuttingParameters Parameters { get; set; }
/// <summary>
/// Remembers pierce-zone resolutions by contour ordinal across the emissions of one
/// preparation (see <see cref="PierceZoneMemo"/>); null resolves every emission afresh.
/// </summary>
internal PierceZoneMemo PierceZones { get; set; }
private record ContourEntry(Shape Shape, Vector Point, Entity Entity); private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
public CuttingResult Apply(Program partProgram, Vector approachPoint) public CuttingResult Apply(Program partProgram, Vector approachPoint)
@@ -20,6 +26,7 @@ namespace OpenNest.CNC.CuttingStrategy
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart) public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
{ {
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
RefuseDisplayPerimeter(entities);
var breakCuts = TakeBreakCuts(entities); var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -107,6 +114,14 @@ namespace OpenNest.CNC.CuttingStrategy
return new CuttingResult { Program = result, LastCutPoint = perimeterPt }; return new CuttingResult { Program = result, LastCutPoint = perimeterPt };
} }
// These tools would lead the outline or mistake the largest cutout for it (an External lead
// pierced on the blank). Only the cutting planner knows every contour of such a part is a hole.
private static void RefuseDisplayPerimeter(List<Entity> entities)
{
if (entities.Exists(e => e.Layer == SpecialLayers.DisplayPerimeter))
throw new InvalidOperationException(DisplayPerimeterTail.LeadInRefusal);
}
public CuttingResult ApplySingle( public CuttingResult ApplySingle(
Program partProgram, Program partProgram,
Vector point, Vector point,
@@ -115,6 +130,7 @@ namespace OpenNest.CNC.CuttingStrategy
) )
{ {
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
RefuseDisplayPerimeter(entities);
var breakCuts = TakeBreakCuts(entities); var breakCuts = TakeBreakCuts(entities);
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
@@ -251,14 +267,20 @@ namespace OpenNest.CNC.CuttingStrategy
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes, internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingPlanning.ContourChoice> choices,
IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null, IReadOnlyList<CuttingParameters> contourParameters = null, IReadOnlyList<Shape> breakCuts = null,
IReadOnlyList<Shape> tabbedCutouts = null) IReadOnlyList<Shape> tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null)
{ {
var result = new Program(Mode.Absolute); var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes); EmitScribeContours(result, scribes);
EmitBreakCuts(result, breakCuts, shapes); EmitBreakCuts(result, breakCuts, shapes);
foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>()) foreach (var cutout in tabbedCutouts ?? Array.Empty<Shape>())
EmitTabbedCutout(result, cutout); EmitTabbedCutout(result, cutout);
EmitChosenContours(result, shapes, choices, contourParameters); EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
if (displayTail != null)
{
// Display only, after every cut: executed reads and posts stop at its marker.
var start = GetShapeStartPoint(displayTail);
DisplayPerimeterTail.Append(result, ConvertShapeToMoves(displayTail, start, LayerType.DisplayPerimeter), start);
}
result.Mode = Mode.Incremental; result.Mode = Mode.Incremental;
return result; return result;
} }
@@ -269,20 +291,23 @@ namespace OpenNest.CNC.CuttingStrategy
// perimeter never degrades to a hole because it was isolated. Output must not be // perimeter never degrades to a hole because it was isolated. Output must not be
// installed on a Part or accepted as a complete plan. // installed on a Part or accepted as a complete plan.
internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes, internal Program EmitCandidateIsolated(Shape[] shapes, List<Entity> scribes,
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null) CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> contourParameters = null,
int? perimeterOrdinal = null)
{ {
var result = new Program(Mode.Absolute); var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes); EmitScribeContours(result, scribes);
EmitChosenContours(result, shapes, new[] { choice }, contourParameters); EmitChosenContours(result, shapes, new[] { choice }, contourParameters, perimeterOrdinal ?? shapes.Length - 1);
result.Mode = Mode.Incremental; result.Mode = Mode.Incremental;
return result; return result;
} }
// One contour per choice in order; the perimeter (last shape) is always External. // One contour per choice in order; the perimeter is always External. Without a cut perimeter
// (a display perimeter outside) perimeterOrdinal is past the last shape: every contour is a hole.
// Contour emission itself reads only the contour's own geometry and settings, never // Contour emission itself reads only the contour's own geometry and settings, never
// prior choices — the differential tests in ContourCandidateEmissionTests pin this. // prior choices — the differential tests in ContourCandidateEmissionTests pin this.
private void EmitChosenContours(Program result, Shape[] shapes, private void EmitChosenContours(Program result, Shape[] shapes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters) IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> contourParameters,
int perimeterOrdinal)
{ {
var shared = Parameters; var shared = Parameters;
try try
@@ -294,8 +319,10 @@ namespace OpenNest.CNC.CuttingStrategy
Parameters = contourParameters?[choice.ContourOrdinal] ?? shared; Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
var shape = shapes[choice.ContourOrdinal]; var shape = shapes[choice.ContourOrdinal];
EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal], EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null, choice.ContourOrdinal == perimeterOrdinal ? ContourType.External : null,
exactCirclePrograms: true, scrapPierce: choice.Pierce); exactCirclePrograms: true, scrapPierce: choice.Pierce,
stopAtCenterLine: contourParameters?[choice.ContourOrdinal] == null,
memoContour: choice.ContourOrdinal);
} }
} }
finally finally
@@ -421,7 +448,9 @@ namespace OpenNest.CNC.CuttingStrategy
Entity entity, Entity entity,
ContourType? forceType = null, ContourType? forceType = null,
bool exactCirclePrograms = false, bool exactCirclePrograms = false,
Vector? scrapPierce = null Vector? scrapPierce = null,
bool stopAtCenterLine = false,
int? memoContour = null
) )
{ {
var contourType = forceType ?? DetectContourType(shape); var contourType = forceType ?? DetectContourType(shape);
@@ -430,6 +459,9 @@ namespace OpenNest.CNC.CuttingStrategy
var leadIn = SelectLeadIn(contourType); var leadIn = SelectLeadIn(contourType);
var leadOut = SelectLeadOut(contourType); var leadOut = SelectLeadOut(contourType);
// A cutout the planner pierced by erosion is too small for its configured leads.
if (scrapPierce != null && contourType != ContourType.External)
leadOut = new NoLeadOut();
if (contourType == ContourType.ArcCircle && entity is Circle circle) if (contourType == ContourType.ArcCircle && entity is Circle circle)
{ {
@@ -446,9 +478,16 @@ namespace OpenNest.CNC.CuttingStrategy
); );
} }
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal, if (scrapPierce is { } holePierce)
Parameters.GetEffectivePierceClearance()); leadIn = StraightLeadIn(point, holePierce, out normal);
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius()); else
{
if (stopAtCenterLine)
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance());
leadIn = PierceZone(memoContour, shape, point, leadIn, normal, winding);
}
// Build hole sub-program relative to (0,0) // Build hole sub-program relative to (0,0)
var holeCenter = circle.Center; var holeCenter = circle.Center;
@@ -487,7 +526,9 @@ namespace OpenNest.CNC.CuttingStrategy
return; return;
} }
var turn = LeadTurn(contourType, winding);
double leadInNormal; double leadInNormal;
var configuredLeadIn = leadIn;
// A planner-placed scrap pierce replaces the configured lead-in with a straight one. // A planner-placed scrap pierce replaces the configured lead-in with a straight one.
if (scrapPierce is { } pierce) if (scrapPierce is { } pierce)
leadIn = StraightLeadIn(point, pierce, out leadInNormal); leadIn = StraightLeadIn(point, pierce, out leadInNormal);
@@ -496,9 +537,12 @@ namespace OpenNest.CNC.CuttingStrategy
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding, leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
Parameters.GetEffectivePierceClearance(), out leadInNormal); Parameters.GetEffectivePierceClearance(), out leadInNormal);
if (contourType == ContourType.Internal) if (contourType == ContourType.Internal)
leadIn = ResolvePierceZone(shape, point, leadIn, leadInNormal, Parameters.GetPierceZoneRadius()); {
if (stopAtCenterLine)
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
leadIn = PierceZone(memoContour, shape, point, leadIn, leadInNormal, turn);
}
} }
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
var reindexedShape = shape.ReindexAt(point, entity); var reindexedShape = shape.ReindexAt(point, entity);
@@ -511,6 +555,7 @@ namespace OpenNest.CNC.CuttingStrategy
var leadOutNormal = normal; var leadOutNormal = normal;
if (tabbed) if (tabbed)
{ {
var first = reindexedShape.Entities.Count > 0 ? reindexedShape.Entities[0] : null;
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size); reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
// Leave from where the trimmed cut actually ends, on that entity's normal: // Leave from where the trimmed cut actually ends, on that entity's normal:
@@ -520,6 +565,18 @@ namespace OpenNest.CNC.CuttingStrategy
var last = reindexedShape.Entities[^1]; var last = reindexedShape.Entities[^1];
leadOutPoint = EntityEndPoint(last); leadOutPoint = EntityEndPoint(last);
leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding); leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding);
// Straight leads either side of a tab that wraps a corner share its bisector,
// as they share the edge normal on a straight edge.
if (ReferenceEquals(leadIn, configuredLeadIn) && leadIn is LineLeadIn lineIn
&& leadOut is LineLeadOut lineOut && first is Line or Arc
&& TabCornerNormal(shape, point, TravelTangent(first, point), leadInNormal, lineIn,
leadOutPoint, TravelTangent(last, leadOutPoint), leadOutNormal, lineOut, winding,
turn, Parameters.GetEffectivePierceClearance()) is { } tabNormal)
{
leadInNormal = tabNormal;
leadOutNormal = tabNormal;
}
} }
} }
else else
@@ -528,10 +585,22 @@ namespace OpenNest.CNC.CuttingStrategy
Parameters.GetEffectivePierceClearance(), out leadOutNormal); Parameters.GetEffectivePierceClearance(), out leadOutNormal);
} }
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point)); program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, winding)); program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn));
} }
/// <summary>
/// The direction a lead arc turns to meet a contour of <paramref name="winding"/>
/// tangentially, in the cut direction. Normals point away from the material, so the arc
/// center lies outside an outside perimeter (turning against its winding) and inside a
/// cutout (turning with it).
/// </summary>
public static RotationType LeadTurn(ContourType contourType, RotationType winding) =>
contourType == ContourType.External
? winding == RotationType.CW ? RotationType.CCW : RotationType.CW
: winding;
private void EmitScribeContours(Program program, List<Entity> scribeEntities) private void EmitScribeContours(Program program, List<Entity> scribeEntities)
{ {
if (scribeEntities.Count == 0) if (scribeEntities.Count == 0)
@@ -616,8 +685,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// <paramref name="pierceClearance"/> from the contour; the approach angle is /// <paramref name="pierceClearance"/> from the contour; the approach angle is
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a /// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
/// reflex corner it bisects the notch. The result does not depend on which of /// reflex corner it bisects the notch. The result does not depend on which of
/// the two edges meeting at the corner was picked. Other styles and contour /// the two edges meeting at the corner was picked. A curved lead-in at a convex
/// types keep <see cref="ComputeLeadInNormal"/>. /// perimeter corner uses the normal of the edge cut first, so its arc meets that
/// edge tangentially. Other styles and contour types keep
/// <see cref="ComputeLeadInNormal"/>.
/// </summary> /// </summary>
public static LeadIn ResolveLeadIn( public static LeadIn ResolveLeadIn(
Shape shape, Shape shape,
@@ -631,8 +702,19 @@ namespace OpenNest.CNC.CuttingStrategy
) )
{ {
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding); normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
if (contourType != ContourType.External || leadIn is not LineLeadIn line if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|| !TryGetCorner(shape, point, entity, out var corner)) return leadIn;
if (leadIn is ArcLeadIn or LineArcLeadIn or CleanHoleLeadIn)
{
// The arc meets the edge cut first tangentially; its center sits off that edge,
// which at a convex corner keeps the whole arc clear of the part.
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
return leadIn;
}
if (leadIn is not LineLeadIn line)
return leadIn; return leadIn;
switch (ClassifyCorner(corner, winding)) switch (ClassifyCorner(corner, winding))
@@ -662,7 +744,8 @@ namespace OpenNest.CNC.CuttingStrategy
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner /// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from /// along the edge cut last, when its end keeps <paramref name="clearance"/> from
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it /// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
/// bisects the notch. Other styles and contour types keep the entity normal. /// bisects the notch. An <see cref="ArcLeadOut"/> at a convex corner uses the normal of
/// the edge cut last. Other styles and contour types keep the entity normal.
/// </summary> /// </summary>
public static LeadOut ResolveLeadOut( public static LeadOut ResolveLeadOut(
Shape shape, Shape shape,
@@ -676,8 +759,18 @@ namespace OpenNest.CNC.CuttingStrategy
) )
{ {
normal = ComputeNormal(point, entity, contourType, winding); normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.External || leadOut is not LineLeadOut line if (contourType != ContourType.External || !TryGetCorner(shape, point, entity, out var corner))
|| !TryGetCorner(shape, point, entity, out var corner)) return leadOut;
if (leadOut is ArcLeadOut)
{
// The arc leaves the edge cut last tangentially.
if (ClassifyCorner(corner, winding) is CornerKind.Convex or CornerKind.Smooth)
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
return leadOut;
}
if (leadOut is not LineLeadOut line)
return leadOut; return leadOut;
switch (ClassifyCorner(corner, winding)) switch (ClassifyCorner(corner, winding))
@@ -804,10 +897,18 @@ namespace OpenNest.CNC.CuttingStrategy
/// A turn whose offset over the tangent is within chaining tolerance is smooth, /// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner. /// not a corner.
/// </summary> /// </summary>
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
/// <summary>
/// The corner a contour of <paramref name="winding"/> would make turning from travel
/// direction <paramref name="tangentIn"/> to <paramref name="tangentOut"/>, classified
/// exactly as a shared vertex is (see <see cref="TryClassifyAutomaticStartCorner"/>).
/// </summary>
internal static CornerKind ClassifyTurn(Vector tangentIn, Vector tangentOut, RotationType winding)
{ {
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X; var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
var dot = corner.TangentIn.DotProduct(corner.TangentOut); var dot = tangentIn.DotProduct(tangentOut);
var turn = winding == RotationType.CCW ? cross : -cross; var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon) if (System.Math.Abs(turn) <= Tolerance.Epsilon)
@@ -836,6 +937,46 @@ namespace OpenNest.CNC.CuttingStrategy
return Angle.NormalizeRad(System.Math.Atan2(y, x)); return Angle.NormalizeRad(System.Math.Atan2(y, x));
} }
/// <summary>
/// The normal straight leads either side of a perimeter tab share when the tab wraps a
/// convex corner: the bisector of the normals where the cut starts and where it stops, so
/// the leads form the V they form on a straight edge, centered on the corner. Null when the
/// tab lies on one edge or wraps a reflex turn, or when either lead would not keep
/// <paramref name="clearance"/> from the part or would cross it.
/// </summary>
private static double? TabCornerNormal(
Shape shape,
Vector start,
Vector tangentStart,
double startNormal,
LineLeadIn leadIn,
Vector end,
Vector tangentEnd,
double endNormal,
LineLeadOut leadOut,
RotationType winding,
RotationType turn,
double clearance
)
{
if (!IsFinite(tangentStart) || !IsFinite(tangentEnd)
|| ClassifyTurn(tangentEnd, tangentStart, winding) != CornerKind.Convex)
return null;
var x = System.Math.Cos(startNormal) + System.Math.Cos(endNormal);
var y = System.Math.Sin(startNormal) + System.Math.Sin(endNormal);
if (x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
return null;
var normal = Angle.NormalizeRad(System.Math.Atan2(y, x));
var pierce = leadIn.GetPiercePoint(start, normal, turn);
var leadOutEnd = ((LinearMove)leadOut.Generate(end, normal, turn)[0]).EndPoint;
return IsClearStraightLead(shape, start, pierce, clearance)
&& IsClearStraightLead(shape, end, leadOutEnd, clearance)
? normal
: null;
}
/// <summary> /// <summary>
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap: /// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
/// its free end keeps <paramref name="clearance"/> from the contour and the lead /// its free end keeps <paramref name="clearance"/> from the contour and the lead
@@ -945,7 +1086,7 @@ namespace OpenNest.CNC.CuttingStrategy
if (leadIn is NoLeadIn || clearance <= 0) if (leadIn is NoLeadIn || clearance <= 0)
return leadIn; return leadIn;
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle); var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle, circle.Rotation);
var maxRadius = circle.Radius - clearance; var maxRadius = circle.Radius - clearance;
if (maxRadius <= 0) if (maxRadius <= 0)
return leadIn; return leadIn;
@@ -987,17 +1128,30 @@ namespace OpenNest.CNC.CuttingStrategy
/// <summary> /// <summary>
/// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall. /// Keeps a hole's lead-in pierce <paramref name="pierceRadius"/> clear of the hole wall.
/// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is /// A lead-in whose pierce already keeps it, or that pierces on the contour itself, is
/// returned as given. Otherwise the pierce moves onto the line from /// returned as given. Otherwise the pierce moves straight in from the wall to the hole's
/// <paramref name="point"/> toward the hole's pole of inaccessibility (the centre of a /// center line, or the lead's own length in when that comes first (see
/// round hole), as close to the contour as the zone allows, and the lead becomes a /// <see cref="StopAtCenterLine"/>). A lead that cannot keep the zone there moves onto the
/// straight line from there, generated with <paramref name="normal"/>. A hole too small /// line from <paramref name="point"/> toward the nearest point that keeps it (see
/// <see cref="ErodedPierce"/>) or else the hole's pole of inaccessibility (the center of a
/// round hole), as close to the contour as the zone allows. Either way the lead becomes
/// a straight line from there, generated with <paramref name="normal"/>. A hole too small
/// for the zone is pierced at the pole itself, its best clearance; the lead validator and /// for the zone is pierced at the pole itself, its best clearance; the lead validator and
/// pre-post review still report the shortfall. Reads only the hole's own geometry, so it /// pre-post review still report the shortfall. Reads only the hole's own geometry, so it
/// knows nothing of parts nested inside the hole. Shared by program generation and the /// knows nothing of parts nested inside the hole. Shared by program generation and the
/// manual placement preview. /// manual placement preview. <paramref name="turn"/> is the hole's
/// <see cref="LeadTurn"/>, which places a curved lead's pierce.
/// </summary> /// </summary>
// The memoized resolution for a contour the preparation emits by ordinal.
private LeadIn PierceZone(int? contour, Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
{
var radius = Parameters.GetPierceZoneRadius();
return PierceZones != null && contour is { } ordinal
? PierceZones.Resolve(ordinal, hole, point, leadIn, normal, radius, turn)
: ResolvePierceZone(hole, point, leadIn, normal, radius, turn);
}
public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal, public static LeadIn ResolvePierceZone(Shape hole, Vector point, LeadIn leadIn, double normal,
double pierceRadius) double pierceRadius, RotationType turn)
{ {
if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn if (pierceRadius <= 0 || !double.IsFinite(pierceRadius) || leadIn is NoLeadIn
|| !hole.IsClosed()) || !hole.IsClosed())
@@ -1007,29 +1161,42 @@ namespace OpenNest.CNC.CuttingStrategy
var polygon = circle == null var polygon = circle == null
? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance)) ? hole.ToPolygonWithTolerance(System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance))
: null; : null;
if (Clear(leadIn.GetPiercePoint(point, normal))) var configured = leadIn.GetPiercePoint(point, normal, turn);
if (Clear(configured))
return leadIn; return leadIn;
var depth = CenterLineDepth(hole, point, normal, configured.DistanceTo(point));
if (depth > Math.Tolerance.Epsilon && Clear(point + Inward(normal) * depth))
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
var pole = circle?.Center ?? PolyLabel.Find(polygon, null, var pole = circle?.Center ?? PolyLabel.Find(polygon, null,
System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells); System.Math.Max(pierceRadius / 100, Math.Tolerance.ChainTolerance), PoleSearchCells);
if (!Inside(pole)) if (!Inside(pole))
return leadIn; return leadIn;
// Clearance grows monotonically toward the pole of a convex hole, so the bisection // The pole of a letter can be a stroke away: head for the stroke beside the entry, on
// its center line when the lead reaches that far.
var reach = configured.DistanceTo(point);
var toward = pole;
if (circle == null && ErodedPierce.Find(hole, System.Math.Max(pierceRadius, reach), new[] { point }, 1)
.Pierces is [var eroded] && Clear(eroded))
toward = eroded;
// Clearance grows monotonically along this line in a convex hole, so the bisection
// finds the shortest clear lead; in other holes it still ends on a clear point. // finds the shortest clear lead; in other holes it still ends on a clear point.
var pierce = pole; var pierce = toward;
if (Clear(pole)) if (Clear(toward) && (reach < pierceRadius || toward == pole))
{ {
double low = 0, high = 1; double low = 0, high = 1;
for (var i = 0; i < 40; i++) for (var i = 0; i < 40; i++)
{ {
var middle = (low + high) / 2; var middle = (low + high) / 2;
if (Clear(point + (pole - point) * middle)) if (Clear(point + (toward - point) * middle))
high = middle; high = middle;
else else
low = middle; low = middle;
} }
pierce = point + (pole - point) * high; pierce = point + (toward - point) * high;
} }
var length = pierce.DistanceTo(point); var length = pierce.DistanceTo(point);
@@ -1050,6 +1217,97 @@ namespace OpenNest.CNC.CuttingStrategy
&& hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius; && hole.ClosestPointTo(p, out _).DistanceTo(p) >= pierceRadius;
} }
/// <summary>
/// Keeps a cutout's lead-in out of the cutout's far half. A lead longer than the cutout is
/// deep at its entry, measured straight in from the wall to the center line where the walls
/// meet as the cutout erodes, reaches past the center line or runs along a narrow stroke, as
/// does one whose own line gets closer to a wall again. It becomes a straight one, generated
/// with <paramref name="normal"/>, from that center line (the center of a round hole) or
/// from the lead's own length in when that comes first. Any other lead keeps its shape, and
/// one that leaves a cutout other than a round hole is left for the lead check to refuse.
/// </summary>
public static LeadIn StopAtCenterLine(Shape hole, Vector point, LeadIn leadIn, double normal, RotationType turn)
{
if (leadIn is NoLeadIn || !hole.IsClosed())
return leadIn;
var pierce = leadIn.GetPiercePoint(point, normal, turn);
var length = pierce.DistanceTo(point);
if (!double.IsFinite(length) || length <= Math.Tolerance.Epsilon)
return leadIn;
var circle = hole.Entities.Count == 1 ? hole.Entities[0] as Circle : null;
var along = pierce - point;
var inward = Inward(normal);
// A pierce as far from every wall as it is deep has not come near another wall.
var inside = circle == null || pierce.DistanceTo(circle.Center) < circle.Radius;
if (inside && Clearance(hole, pierce) >= along.X * inward.X + along.Y * inward.Y - Math.Tolerance.Epsilon)
return leadIn;
var depth = CenterLineDepth(hole, point, normal, length);
if (depth <= Math.Tolerance.Epsilon
|| inside && depth >= length - Math.Tolerance.Epsilon && !PeaksBeforeItsEnd(hole, point, along))
return leadIn;
if (circle == null)
{
var polygon = hole.ToPolygonWithTolerance(Math.Tolerance.ChainTolerance);
for (var i = 1; i <= CenterLineSteps; i++)
if (!polygon.ContainsPoint(point + along * ((double)i / CenterLineSteps)))
return leadIn;
}
return new LineLeadIn { Length = depth, ApproachAngle = 90 };
}
// How far a straight line in from the wall runs, up to maxDepth, before it reaches the
// center line: the distance to the wall grows at a steady rate until a second wall is as
// near, then levels off along a stroke or falls.
private static double CenterLineDepth(Shape hole, Vector point, double normal, double maxDepth)
{
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
return System.Math.Min(maxDepth, circle.Radius);
var inward = Inward(normal);
var step = maxDepth / CenterLineSteps;
var rate = Clearance(hole, point + inward * step) / step;
// A corner's curved lead keeps an edge's normal, which runs along the other edge.
if (rate < MinimumCenterLineRate)
return maxDepth;
bool Rising(double depth) => Clearance(hole, point + inward * (depth + step / CenterLineSteps))
- Clearance(hole, point + inward * depth) >= rate / 2 * (step / CenterLineSteps);
for (var i = 1; i < CenterLineSteps; i++)
{
if (Rising(step * i))
continue;
double low = step * (i - 1), high = step * i;
for (var refine = 0; refine < CenterLineRefinements; refine++)
{
var middle = (low + high) / 2;
if (Rising(middle))
low = middle;
else
high = middle;
}
return low;
}
return maxDepth;
}
private static bool PeaksBeforeItsEnd(Shape hole, Vector point, Vector along)
{
var peak = 0.0;
for (var i = 1; i <= CenterLineSteps; i++)
{
var clearance = Clearance(hole, point + along * ((double)i / CenterLineSteps));
if (clearance < peak - Math.Tolerance.Epsilon)
return true;
peak = System.Math.Max(peak, clearance);
}
return false;
}
private static double Clearance(Shape hole, Vector point) => hole.ClosestPointTo(point, out _).DistanceTo(point);
private static Vector Inward(double normal) => new(System.Math.Cos(normal), System.Math.Sin(normal));
/// <summary> /// <summary>
/// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the /// A straight lead-in from <paramref name="pierce"/> to <paramref name="point"/>, with the
/// normal to generate it with. /// normal to generate it with.
@@ -1063,12 +1321,16 @@ namespace OpenNest.CNC.CuttingStrategy
// Bounds the pole search on long, thin holes; the result is checked before use. // Bounds the pole search on long, thin holes; the result is checked before use.
private const int PoleSearchCells = 4096; private const int PoleSearchCells = 4096;
// Samples along a lead when looking for the center line, then the steps narrowing in on it.
private const int CenterLineSteps = 64;
private const int CenterLineRefinements = 16;
private const double MinimumCenterLineRate = 0.01;
private LeadIn SelectLeadIn(ContourType contourType) private LeadIn SelectLeadIn(ContourType contourType)
{ {
return contourType switch return contourType switch
{ {
ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn, ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadIn,
ContourType.Internal => Parameters.InternalLeadIn,
_ => Parameters.ExternalLeadIn, _ => Parameters.ExternalLeadIn,
}; };
} }
@@ -1077,14 +1339,29 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
return contourType switch return contourType switch
{ {
ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut, ContourType.Internal or ContourType.ArcCircle => Parameters.InternalLeadOut,
ContourType.Internal => Parameters.InternalLeadOut,
_ => Parameters.ExternalLeadOut, _ => Parameters.ExternalLeadOut,
}; };
} }
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize) private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
{ {
// A round perimeter is one circle entered at `center`: cut it as an arc from the entry
// that stops the tab's chord short of it, travelling the circle's own way.
if (shape.Entities is [Circle round] && round.Radius > tabSize / 2)
{
var entry = round.Center.AngleTo(center);
var gap = 2 * System.Math.Asin(tabSize / (2 * round.Radius));
var reversed = round.Rotation == RotationType.CW;
var arc = new Arc(round.Center, round.Radius, entry, reversed ? entry + gap : entry - gap, reversed)
{
Layer = round.Layer,
};
var trimmed = new Shape();
trimmed.Entities.Add(arc);
return trimmed;
}
var tabCircle = new Circle(center, tabSize); var tabCircle = new Circle(center, tabSize);
var entities = new List<Entity>(shape.Entities); var entities = new List<Entity>(shape.Entities);
@@ -1261,7 +1538,7 @@ namespace OpenNest.CNC.CuttingStrategy
} }
/// <summary> /// <summary>
/// Where a break cut (travelling with its scrap on the left) is entered: the end with more /// Where a break cut (traveling with its scrap on the left) is entered: the end with more
/// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and /// scrap before <paramref name="contours"/>, the unit direction into the scrap there, and
/// whether that is the cut's far end. /// whether that is the cut's far end.
/// </summary> /// </summary>
@@ -1351,7 +1628,7 @@ namespace OpenNest.CNC.CuttingStrategy
return mirrored; return mirrored;
} }
// Reaching both ways, or tangent to an arc: pierce straight in from the same distance. // Reaching both ways, or tangent to an arc: pierce straight in from the same distance.
var pierce = leadIn.GetPiercePoint(entry, normal); var pierce = leadIn.GetPiercePoint(entry, normal, winding);
return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding); return new LineLeadIn { Length = pierce.DistanceTo(entry), ApproachAngle = 90 }.Generate(entry, normal, winding);
} }
@@ -15,13 +15,14 @@ namespace OpenNest.CNC.CuttingStrategy
public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn(); public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn();
public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut(); public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut();
/// <summary>
/// The lead-in of every cutout, full circles included; circles still get their own
/// geometry handling (radius clamp, angle rounding) when the lead is emitted.
/// </summary>
public LeadIn InternalLeadIn { get; set; } = public LeadIn InternalLeadIn { get; set; } =
new LineLeadIn { Length = 0.125, ApproachAngle = 90 }; new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
public LeadOut InternalLeadOut { get; set; } = new NoLeadOut(); public LeadOut InternalLeadOut { get; set; } = new NoLeadOut();
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
public double PierceClearance { get; set; } = 0.0625; public double PierceClearance { get; set; } = 0.0625;
/// <summary> /// <summary>
@@ -40,12 +41,55 @@ namespace OpenNest.CNC.CuttingStrategy
public bool RoundLeadInAngles { get; set; } public bool RoundLeadInAngles { get; set; }
public double LeadInAngleIncrement { get; set; } = 5.0; public double LeadInAngleIncrement { get; set; } = 5.0;
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
public Tab TabConfig { get; set; } public Tab TabConfig { get; set; }
public bool TabsEnabled { get; set; } public bool TabsEnabled { get; set; }
/// <summary>
/// Tabs each part its plate's support bed cannot hold level once it is cut free, at the
/// perimeter point that best keeps its center of gravity over the bed's contacts (see
/// docs/support-tabs.md). Needs <see cref="TabConfig"/> for the tab size.
/// </summary>
public bool SupportTabs { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the narrowest part
/// width that is tabbed instead (see <see cref="TabsWithoutBedAt"/>).
/// </summary>
public double NoBedTabMinWidth { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the widest part
/// width that is tabbed instead; 0 turns the rule off, so nothing is tabbed without a bed.
/// </summary>
public double NoBedTabMaxWidth { get; set; }
/// <summary>Whether <see cref="NoBedTabMinWidth"/> to <see cref="NoBedTabMaxWidth"/> is a usable range.</summary>
public bool HasNoBedTabRange() =>
NoBedTabMaxWidth > 0 && double.IsFinite(NoBedTabMaxWidth)
&& NoBedTabMinWidth >= 0 && NoBedTabMinWidth <= NoBedTabMaxWidth;
/// <summary>
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
/// bounding rectangle) is tabbed when its plate has no support bed.
/// </summary>
public bool TabsWithoutBedAt(double width) =>
HasNoBedTabRange()
&& width >= NoBedTabMinWidth - Math.Tolerance.Epsilon
&& width <= NoBedTabMaxWidth + Math.Tolerance.Epsilon;
/// <summary>
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the
/// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a
/// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
/// it. 0 turns the rule off. New settings start at 6; settings saved without it read as 0.
/// </summary>
public double TabHeldMaxLength { get; set; } = 6;
/// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
public bool TabHoldsAt(double length) =>
TabHeldMaxLength > 0 && double.IsFinite(TabHeldMaxLength)
&& length <= TabHeldMaxLength + Math.Tolerance.Epsilon;
public SequenceParameters Sequencing { get; set; } = new SequenceParameters(); public SequenceParameters Sequencing { get; set; } = new SequenceParameters();
public AssignmentParameters Assignment { get; set; } = new AssignmentParameters(); public AssignmentParameters Assignment { get; set; } = new AssignmentParameters();
} }
@@ -0,0 +1,143 @@
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// Pierces found by eroding a cutout: the part of it that keeps a clearance from its wall
/// (see <see cref="ErodedPierce.Find"/>).
/// </summary>
/// <param name="Pierces">The pierce for each target in turn, up to the maximum asked for.</param>
/// <param name="Clearance">The least any pierce keeps from the wall, at the erosion level that found it.</param>
public sealed record ErodedPierces(IReadOnlyList<Vector> Pierces, double Clearance)
{
public static ErodedPierces None { get; } = new(System.Array.Empty<Vector>(), 0);
}
/// <summary>
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small
/// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
/// The erosion goes as deep as the caller asks when the cutout has room for it, else almost
/// to its pole of inaccessibility; a round hole is pierced at its center. Each target, an entry the planner ranked, gets the
/// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
/// half that depth: a letter stroke is pierced on its own center line next to the entry, and
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs
/// straight from the pierce to the wall.
/// </summary>
public static class ErodedPierce
{
/// <summary>
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
/// not fit: deep enough to stay near the center line, with room for every place the
/// cutout is about as wide as its widest.
/// </summary>
public const double DepthFraction = 0.9;
private const int PoleSearchCells = 4096;
// Shallower erosion levels a target may fall back to, each a tenth of the deepest less.
private const int LevelCount = 6;
private const double LevelStep = 0.1;
/// <summary>
/// A pierce in the closed cutout <paramref name="hole"/> for each of the first
/// <paramref name="max"/> <paramref name="targets"/>: the eroded point nearest it. A cutout
/// too small to erode is pierced at its pole for every target. Nothing for an open or
/// degenerate contour.
/// </summary>
public static ErodedPierces Find(Shape hole, double depth, IReadOnlyList<Vector> targets, int max)
{
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|| !double.IsFinite(depth))
return ErodedPierces.None;
// The inscribed radius, from the true wall: the deepest any erosion can go.
var coarse = ClipperBridge.Flatten(hole, Tolerance(hole.BoundingBox.Width, hole.BoundingBox.Length), false);
if (coarse.Vertices.Count < 3)
return ErodedPierces.None;
var pole = PolyLabel.Find(coarse, null, Tolerance(coarse.BoundingBox.Width, coarse.BoundingBox.Length) / 10,
PoleSearchCells);
if (!double.IsFinite(pole.X) || !double.IsFinite(pole.Y) || !coarse.ContainsPoint(pole))
return ErodedPierces.None;
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
if (!(inscribed > 0))
return ErodedPierces.None;
if (hole.Entities.Count == 1 && hole.Entities[0] is Circle circle)
return new(targets.Take(max).Select(_ => circle.Center).ToArray(), circle.Radius);
// Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
// each by the tolerance; Clipper rounds to its precision. Erode past both.
depth = System.Math.Min(System.Math.Max(depth, 0), inscribed * DepthFraction);
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
if (depth + margin >= inscribed - tolerance)
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
var polygon = ClipperBridge.Flatten(hole, tolerance, circumscribe: false);
var path = new PathsD { ClipperBridge.ToPath(polygon, positive: true) };
var levels = new List<PathD>[LevelCount];
List<PathD> Level(int level) => levels[level] ??= Clipper.InflatePaths(path,
-(Depth(level) + margin), JoinType.Round, EndType.Polygon, 2.0, ClipperBridge.Precision, tolerance)
.Where(p => p.Count >= 3).ToList();
double Depth(int level) => depth * (1 - level * LevelStep);
if (Level(0).Count == 0)
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
// Each target gets the deepest level that reaches the wall beside it: a stroke
// narrower than the cutout's widest place is still pierced on its own center line, near
// the entry, rather than only at the widest place.
var pierces = new List<Vector>();
var shallowest = depth;
foreach (var target in targets)
{
var pierce = Nearest(Level(0), target);
var reached = 0;
for (var level = 0; level < LevelCount; level++)
{
var region = Level(level);
if (region.Count == 0)
continue;
var nearest = Nearest(region, target);
if (nearest.DistanceTo(target) <= 2 * (Depth(level) + margin))
{
pierce = nearest;
reached = level;
break;
}
}
pierces.Add(pierce);
shallowest = System.Math.Min(shallowest, Depth(reached));
if (pierces.Count == max)
break;
}
return new(pierces, shallowest);
}
// A hundredth of the smaller side: fine enough to find a thin cutout's pole.
private static double Tolerance(double width, double length) =>
System.Math.Max(System.Math.Min(width, length) / 100, System.Math.Pow(10, -ClipperBridge.Precision));
// The closest point of the region's boundary; targets lie on the wall, outside it.
private static Vector Nearest(List<PathD> paths, Vector target)
{
var best = default(Vector);
var bestDistance = double.PositiveInfinity;
foreach (var path in paths)
for (var i = 0; i < path.Count; i++)
{
var a = new Vector(path[i].x, path[i].y);
var b = new Vector(path[(i + 1) % path.Count].x, path[(i + 1) % path.Count].y);
var point = new Line(a, b).ClosestPointTo(target);
var distance = point.DistanceTo(target);
if (distance < bestDistance)
{
bestDistance = distance;
best = point;
}
}
return best;
}
}
}
@@ -1,43 +1,62 @@
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
/// <summary>
/// A single arc that ends tangent to the cut. Its center lies <see cref="Radius"/> along the
/// contour normal and it turns <see cref="SweepAngle"/> degrees in the lead's turn direction:
/// 90 is a quarter circle, 180 (the default) a half circle starting two radii out.
/// </summary>
public class ArcLeadIn : LeadIn public class ArcLeadIn : LeadIn
{ {
public const double DefaultSweepAngle = 180.0;
public double Radius { get; set; } public double Radius { get; set; }
/// <summary>Degrees of arc, in (0, 180].</summary>
public double SweepAngle { get; set; } = DefaultSweepAngle;
public override List<ICode> Generate( public override List<ICode> Generate(
Vector contourStartPoint, Vector contourStartPoint,
double contourNormalAngle, double contourNormalAngle,
RotationType winding = RotationType.CW RotationType winding = RotationType.CW
) )
{ {
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenter = new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
return new List<ICode> return new List<ICode>
{ {
new RapidMove(piercePoint), new RapidMove(GetPiercePoint(contourStartPoint, contourNormalAngle, winding)),
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }, new ArcMove(contourStartPoint, ArcCenter(contourStartPoint, contourNormalAngle), winding)
{
Layer = LayerType.Leadin,
},
}; };
} }
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) public override Vector GetPiercePoint(
Vector contourStartPoint,
double contourNormalAngle,
RotationType winding = RotationType.CW
)
{ {
var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle); // The contour point sits opposite the normal from the center; the pierce is the
var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle); // sweep back from there, against the turn. A half circle pierces on the normal.
var shortfall = Angle.ToRadians(DefaultSweepAngle - SweepAngle);
var angle = contourNormalAngle + (winding == RotationType.CCW ? shortfall : -shortfall);
var center = ArcCenter(contourStartPoint, contourNormalAngle);
return new Vector( return new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle), center.X + Radius * System.Math.Cos(angle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle) center.Y + Radius * System.Math.Sin(angle)
); );
} }
public override LeadIn Scale(double factor) => new ArcLeadIn { Radius = Radius * factor }; public override LeadIn Scale(double factor) =>
new ArcLeadIn { Radius = Radius * factor, SweepAngle = SweepAngle };
private Vector ArcCenter(Vector contourStartPoint, double contourNormalAngle) => new Vector(
contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle),
contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)
);
} }
} }
@@ -1,11 +1,12 @@
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
public class CleanHoleLeadIn : LeadIn public class CleanHoleLeadIn : LeadIn
{ {
private const double ApproachAngle = 135.0;
public double LineLength { get; set; } public double LineLength { get; set; }
public double ArcRadius { get; set; } public double ArcRadius { get; set; }
public double Kerf { get; set; } public double Kerf { get; set; }
@@ -14,42 +15,11 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint, Vector contourStartPoint,
double contourNormalAngle, double contourNormalAngle,
RotationType winding = RotationType.CW RotationType winding = RotationType.CW
) ) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); RotationType winding = RotationType.CW) =>
var arcCenter = new Vector(arcCenterX, arcCenterY); Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(135.0);
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new CleanHoleLeadIn new CleanHoleLeadIn
@@ -58,5 +28,8 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor, ArcRadius = ArcRadius * factor,
Kerf = Kerf, Kerf = Kerf,
}; };
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
} }
} }
@@ -3,6 +3,11 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
/// <summary>
/// A lead into a contour point. <c>winding</c> is the direction a lead arc turns to meet the
/// cut tangentially: the cut's own direction around the arc center, which lies along the
/// normal (see <see cref="ContourCuttingStrategy.LeadTurn"/>). Straight styles ignore it.
/// </summary>
public abstract class LeadIn public abstract class LeadIn
{ {
public abstract List<ICode> Generate( public abstract List<ICode> Generate(
@@ -11,7 +16,8 @@ namespace OpenNest.CNC.CuttingStrategy
RotationType winding = RotationType.CW RotationType winding = RotationType.CW
); );
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle); public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW);
public virtual LeadIn Scale(double factor) => this; public virtual LeadIn Scale(double factor) => this;
} }
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// A straight line into an arc that ends on the contour start, shared by
/// <see cref="LineArcLeadIn"/> and <see cref="CleanHoleLeadIn"/>. The arc's center lies
/// the arc radius along the contour normal; the line meets the arc at the approach angle
/// (degrees from the normal, on the side the arc turns from) and starts at the pierce point.
/// </summary>
internal readonly struct LineArcApproach
{
private LineArcApproach(Vector arcCenter, Vector arcStart, Vector piercePoint)
{
ArcCenter = arcCenter;
ArcStart = arcStart;
PiercePoint = piercePoint;
}
public Vector ArcCenter { get; }
public Vector ArcStart { get; }
public Vector PiercePoint { get; }
public static LineArcApproach Create(Vector contourStartPoint, double contourNormalAngle,
double arcRadius, double approachAngle, double lineLength, RotationType winding)
{
var arcCenterX = contourStartPoint.X + arcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + arcRadius * System.Math.Sin(contourNormalAngle);
// The approach angle is measured back from the normal against the turn, so the arc
// runs the short way round to the contour point whichever way it turns.
var approach = Angle.ToRadians(approachAngle);
var lineAngle = contourNormalAngle + (winding == RotationType.CCW ? approach : -approach);
var arcStartX = arcCenterX + arcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + arcRadius * System.Math.Sin(lineAngle);
return new LineArcApproach(
new Vector(arcCenterX, arcCenterY),
new Vector(arcStartX, arcStartY),
new Vector(
arcStartX + lineLength * System.Math.Cos(lineAngle),
arcStartY + lineLength * System.Math.Sin(lineAngle)));
}
/// <summary>Rapid to the pierce point, then the line and arc on the lead-in layer.</summary>
public List<ICode> ToCodes(Vector contourStartPoint, RotationType winding) =>
new List<ICode>
{
new RapidMove(PiercePoint),
new LinearMove(ArcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, ArcCenter, winding) { Layer = LayerType.Leadin },
};
}
}
@@ -1,6 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
@@ -14,42 +13,11 @@ namespace OpenNest.CNC.CuttingStrategy
Vector contourStartPoint, Vector contourStartPoint,
double contourNormalAngle, double contourNormalAngle,
RotationType winding = RotationType.CW RotationType winding = RotationType.CW
) ) => Approach(contourStartPoint, contourNormalAngle, winding).ToCodes(contourStartPoint, winding);
{
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); RotationType winding = RotationType.CW) =>
var arcCenter = new Vector(arcCenterX, arcCenterY); Approach(contourStartPoint, contourNormalAngle, winding).PiercePoint;
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStart = new Vector(
arcCenterX + ArcRadius * System.Math.Cos(lineAngle),
arcCenterY + ArcRadius * System.Math.Sin(lineAngle)
);
return new List<ICode>
{
new RapidMove(piercePoint),
new LinearMove(arcStart) { Layer = LayerType.Leadin },
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin },
};
}
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
{
var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle);
var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle);
var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle);
var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle);
return new Vector(
arcStartX + LineLength * System.Math.Cos(lineAngle),
arcStartY + LineLength * System.Math.Sin(lineAngle)
);
}
public override LeadIn Scale(double factor) => public override LeadIn Scale(double factor) =>
new LineArcLeadIn new LineArcLeadIn
@@ -58,5 +26,8 @@ namespace OpenNest.CNC.CuttingStrategy
ArcRadius = ArcRadius * factor, ArcRadius = ArcRadius * factor,
ApproachAngle = ApproachAngle, ApproachAngle = ApproachAngle,
}; };
private LineArcApproach Approach(Vector contourStartPoint, double contourNormalAngle, RotationType winding) =>
LineArcApproach.Create(contourStartPoint, contourNormalAngle, ArcRadius, ApproachAngle, LineLength, winding);
} }
} }
@@ -24,7 +24,8 @@ namespace OpenNest.CNC.CuttingStrategy
}; };
} }
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{ {
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle); var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
return new Vector( return new Vector(
@@ -33,7 +33,8 @@ namespace OpenNest.CNC.CuttingStrategy
}; };
} }
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{ {
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1); var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle); var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
@@ -14,7 +14,8 @@ namespace OpenNest.CNC.CuttingStrategy
return new List<ICode> { new RapidMove(contourStartPoint) }; return new List<ICode> { new RapidMove(contourStartPoint) };
} }
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle,
RotationType winding = RotationType.CW)
{ {
return contourStartPoint; return contourStartPoint;
} }
@@ -3,6 +3,10 @@ using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy namespace OpenNest.CNC.CuttingStrategy
{ {
/// <summary>
/// A quarter circle leaving the cut tangentially, turning <c>winding</c> about a center
/// <see cref="Radius"/> along the contour normal.
/// </summary>
public class ArcLeadOut : LeadOut public class ArcLeadOut : LeadOut
{ {
public double Radius { get; set; } public double Radius { get; set; }
@@ -17,9 +21,12 @@ namespace OpenNest.CNC.CuttingStrategy
var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle); var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle);
var arcCenter = new Vector(arcCenterX, arcCenterY); var arcCenter = new Vector(arcCenterX, arcCenterY);
// The contour point sits opposite the normal from the center; turn a quarter on.
var endAngle = contourNormalAngle
+ (winding == RotationType.CCW ? -System.Math.PI / 2 : System.Math.PI / 2);
var endPoint = new Vector( var endPoint = new Vector(
arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2), arcCenterX + Radius * System.Math.Cos(endAngle),
arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2) arcCenterY + Radius * System.Math.Sin(endAngle)
); );
return new List<ICode> return new List<ICode>
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
/// <summary>
/// Remembers <see cref="ContourCuttingStrategy.ResolvePierceZone"/> for one preparation's
/// contours. The resolution reads only the cutout's own geometry, the entry, the configured
/// lead-in and the zone, so for a given contour it is a pure function of those, yet the
/// search emits every contour of a part's chain again on every expansion, and on a cutout
/// whose configured pierce is inside the zone each resolution flattens the cutout, finds its
/// pole and erodes it. One memo serves every emission of one <c>PreparedContours</c> and
/// its copies, which share the contours by ordinal. A lead-in a memo returns is shared and
/// must not be mutated.
/// </summary>
internal sealed class PierceZoneMemo
{
private readonly Dictionary<Key, LeadIn> resolved = new();
private readonly object gate = new();
/// <summary>How many resolutions the memo answered without computing; for tests.</summary>
internal int Hits { get; private set; }
internal LeadIn Resolve(int contour, Shape hole, Vector point, LeadIn leadIn, double normal,
double pierceRadius, RotationType turn)
{
if (KeyOf(leadIn) is not { } lead)
return ContourCuttingStrategy.ResolvePierceZone(hole, point, leadIn, normal, pierceRadius, turn);
var key = new Key(contour, point.X, point.Y, normal, pierceRadius, turn, lead);
lock (gate)
{
if (resolved.TryGetValue(key, out var known))
{
Hits++;
return known;
}
}
var result = ContourCuttingStrategy.ResolvePierceZone(hole, point, leadIn, normal, pierceRadius, turn);
lock (gate)
{
resolved[key] = result;
}
return result;
}
// The configured lead-in by value: an equal lead-in resolves the same way whichever
// instance carries it. A lead-in type this does not know is resolved every time.
private static LeadKey? KeyOf(LeadIn leadIn) => leadIn switch
{
NoLeadIn => new(0, 0, 0, 0, 0),
LineLeadIn line => new(1, line.Length, line.ApproachAngle, 0, 0),
ArcLeadIn arc => new(2, arc.Radius, arc.SweepAngle, 0, 0),
LineArcLeadIn lineArc => new(3, lineArc.LineLength, lineArc.ApproachAngle, lineArc.ArcRadius, 0),
CleanHoleLeadIn cleanHole => new(4, cleanHole.LineLength, cleanHole.ArcRadius, cleanHole.Kerf, 0),
LineLineLeadIn lineLine => new(5, lineLine.Length1, lineLine.ApproachAngle1, lineLine.Length2, lineLine.ApproachAngle2),
_ => null,
};
private readonly record struct LeadKey(int Kind, double A, double B, double C, double D);
private readonly record struct Key(int Contour, double X, double Y, double Normal, double PierceRadius,
RotationType Turn, LeadKey Lead);
}
}
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
/// <summary>
/// The never-cut outline of a supplied blank, kept at the end of an applied cutting program.
/// An applied program for a blank with a <see cref="LayerType.DisplayPerimeter"/> outline cuts
/// only its cutouts; the outline follows them after <see cref="Marker"/> so the part keeps the
/// blank's extent for bounds, overlap and cut-offs. Nothing that executes or posts a program may
/// run the tail: executed reads stop at the marker and posts take <see cref="ForPosting"/>.
/// </summary>
public static class DisplayPerimeterTail
{
/// <summary>The comment that starts the tail; every code after it is display only.</summary>
public const string Marker = "DISPLAY PERIMETER NEVER CUT";
/// <summary>Why the manual and legacy lead-in tools leave a display-perimeter part alone.</summary>
public const string LeadInRefusal =
"This part's outline is a display perimeter: a supplied blank that is never cut. "
+ "Lead-ins for its cutouts come from Plan Cutting, which keeps every pierce inside a cutout.";
/// <summary>
/// The index of the tail's marker in <paramref name="program"/>'s codes, or -1. A marker that is
/// repeated, sits in a sub-program, or is followed by anything but one rapid and moves on the
/// display perimeter layer is a malformed program (<see cref="ArgumentException"/>): cuts after
/// it must never be dropped silently.
/// </summary>
public static int IndexOf(Program program)
{
ArgumentNullException.ThrowIfNull(program);
var marker = -1;
for (var i = 0; i < program.Codes.Count; i++)
{
if (program.Codes[i] is Comment { Value: Marker })
{
if (marker >= 0)
throw new ArgumentException("A program has more than one display perimeter tail.");
marker = i;
}
else if (program.Codes[i] is SubProgramCall call
&& HasMarker(call.Program, new HashSet<Program>(ReferenceEqualityComparer.Instance)))
throw new ArgumentException("A display perimeter tail can only end the part's own program, not a sub-program.");
}
if (marker < 0)
return -1;
var tail = program.Codes.Count - marker - 1;
if (tail < 2 || program.Codes[marker + 1] is not RapidMove
|| !program.Codes.Skip(marker + 2).All(code =>
code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter }))
throw new ArgumentException(
"A display perimeter tail must be one rapid followed only by the outline on the display perimeter layer.");
return marker;
}
private static bool HasMarker(Program program, HashSet<Program> visited)
{
if (program?.Codes == null || !visited.Add(program))
return false;
foreach (var code in program.Codes)
if (code is Comment { Value: Marker } || code is SubProgramCall call && HasMarker(call.Program, visited))
return true;
return false;
}
public static bool HasTail(Program program) => IndexOf(program) >= 0;
/// <summary>
/// Appends the outline after the marker: a rapid to its start, then its entities on the
/// display perimeter layer. <paramref name="program"/> must still be in absolute mode.
/// </summary>
internal static void Append(Program program, IReadOnlyList<ICode> outline, Vector start)
{
if (program.Mode != Mode.Absolute)
throw new InvalidOperationException("Append the display tail before switching to incremental mode.");
program.Codes.Add(new Comment(Marker));
program.Codes.Add(new RapidMove(start));
program.Codes.AddRange(outline);
}
/// <summary>
/// The executed program for estimates and drawing: <paramref name="program"/> itself when it
/// has no well-formed tail, otherwise a shallow copy of the codes before the marker. Posts
/// and verification use the strict <see cref="ForPosting"/> and executed reads instead.
/// </summary>
public static Program WithoutTail(Program program)
{
try
{
var marker = IndexOf(program);
return marker < 0 ? program : program.HeadCopy(marker);
}
catch (ArgumentException)
{
return program;
}
}
/// <summary>Whether <paramref name="program"/> or any sub-program it calls moves on the display perimeter layer.</summary>
public static bool HasDisplayPerimeterMotion(Program program) =>
HasDisplayPerimeterMotion(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
private static bool HasDisplayPerimeterMotion(Program program, HashSet<Program> visited)
{
if (program?.Codes == null || !visited.Add(program))
return false;
foreach (var code in program.Codes)
{
if (code is LinearMove { Layer: LayerType.DisplayPerimeter } or ArcMove { Layer: LayerType.DisplayPerimeter })
return true;
if (code is SubProgramCall call && HasDisplayPerimeterMotion(call.Program, visited))
return true;
}
return false;
}
/// <summary>
/// The program a post may write for a part: its tail removed. A display perimeter anywhere
/// else means the part was never planned, and posting it would cut the blank's outline.
/// </summary>
public static Program ForPosting(Program program, string partName)
{
int marker;
try
{
marker = IndexOf(program);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{partName} cannot be posted: {ex.Message}", ex);
}
var executed = marker < 0 ? program : program.HeadCopy(marker);
if (HasDisplayPerimeterMotion(executed))
throw new InvalidOperationException(
$"{partName} has a display perimeter (a supplied blank's outline that is never cut) but no "
+ "cutting plan. Plan cutting first (Plate > Plan Cutting..., or --plan-cutting) so only its cutouts are cut.");
return executed;
}
/// <summary>The name posts give a part in a refusal.</summary>
public static string PartName(Part part) => $"Part '{part.BaseDrawing?.Name}'";
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: every part's program
/// must pass <see cref="ForPosting"/>, so a refusal never leaves an empty or partial file.
/// </summary>
public static void EnsurePostable(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
foreach (var part in plate.Parts)
if (part?.Program != null && part.BaseDrawing?.IsCutOff != true)
ForPosting(part.Program, PartName(part));
}
}
}
+82
View File
@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
namespace OpenNest.CNC
{
/// <summary>
/// The head-height lock around a cut-off's overcut past the nominal far sheet edge. Stock can
/// arrive wider than nominal, so a cut-off overcuts past the far edge to cut the oversize off;
/// past the real edge there is no material to sense, so the head height is locked first. A
/// cut-off program marks the locked stretch with a <see cref="Lock"/> comment and ends it with an
/// <see cref="Unlock"/> comment; a rapid between them keeps the head locked and is not a head
/// raise. Posts write each marker as their machine's lock or unlock code and refuse to post a
/// marked program when no code is configured, because the overcut is unsafe without the lock.
/// See docs/automatic-scrap-cutoffs.md.
/// </summary>
public static class HeadHeightLock
{
/// <summary>The comment before the first motion made with the head height locked.</summary>
public const string Lock = "HEAD HEIGHT LOCK";
/// <summary>The comment after the last motion made with the head height locked.</summary>
public const string Unlock = "HEAD HEIGHT UNLOCK";
public static bool IsLock(ICode code) => code is Comment { Value: Lock };
public static bool IsUnlock(ICode code) => code is Comment { Value: Unlock };
/// <summary>Whether <paramref name="program"/>'s own codes hold a lock marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsLock(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether the head height is locked while it runs: the
/// codes after a lock marker up to its unlock marker. Markers must alternate, starting with a
/// lock and ending unlocked; anything else is a malformed program (<see cref="ArgumentException"/>),
/// since a post that lost track of the lock could cut off the sheet unlocked or never unlock.
/// </summary>
public static bool[] LockedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
var locked = false;
for (var i = 0; i < codes.Count; i++)
{
if (IsLock(codes[i]))
{
if (locked)
throw new ArgumentException("A head-height lock is set twice without an unlock.");
locked = true;
}
else if (IsUnlock(codes[i]))
{
if (!locked)
throw new ArgumentException("A head-height unlock has no lock before it.");
locked = false;
}
else
mask[i] = locked;
}
if (locked)
throw new ArgumentException("A head-height lock is never released.");
return mask;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCodes, string postName) =>
PostMarkers.EnsurePostable(nest, hasCodes, IsMarked, program => LockedMask(program.Codes),
name => $"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
+ "or delete the cut-off and add it again with the head-height lock off.");
}
}
+85
View File
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.CNC
{
/// <summary>
/// A rapid the head must make raised because it passes over parts already cut, which may have
/// tipped. Plan Cutting marks such a rapid only when no route avoids the crossing: a
/// <see cref="Marker"/> comment immediately before the <see cref="RapidMove"/>, or before the
/// <see cref="SubProgramCall"/> whose arrival rapid it is, since a sub-program may be shared and
/// is never marked itself. Rapid checks accept a crossing on a marked rapid. Posts write their machine's head raise before every marked rapid,
/// and refuse to post a marked program when they have none, since the rapid is unsafe head down.
/// See docs/cutting-planner.md.
/// </summary>
public static class HeadRaise
{
/// <summary>The comment immediately before a rapid made with the head raised.</summary>
public const string Marker = "HEAD RAISE";
public static bool IsMarker(ICode code) => code is Comment { Value: Marker };
/// <summary>Whether <paramref name="program"/>'s own codes hold a marker.</summary>
public static bool IsMarked(Program program)
{
if (program?.Codes == null)
return false;
foreach (var code in program.Codes)
if (IsMarker(code))
return true;
return false;
}
/// <summary>
/// For each of <paramref name="codes"/>, whether it is a rapid made with the head raised, or a
/// sub-program call whose arrival rapid is. A marker must be followed directly by a rapid or
/// a call; anything else is a malformed program (<see cref="ArgumentException"/>), since a
/// post could raise before the wrong move.
/// </summary>
public static bool[] RaisedMask(IReadOnlyList<ICode> codes)
{
ArgumentNullException.ThrowIfNull(codes);
var mask = new bool[codes.Count];
for (var i = 0; i < codes.Count; i++)
{
if (!IsMarker(codes[i]))
continue;
if (i + 1 >= codes.Count || codes[i + 1] is not (RapidMove or SubProgramCall))
throw new ArgumentException("A head-raise marker is not followed by a rapid.");
mask[i + 1] = true;
}
return mask;
}
/// <summary>
/// A copy of <paramref name="program"/> with a marker before each of its own codes at
/// <paramref name="rapids"/>, which must be rapids or sub-program calls not marked already.
/// </summary>
public static Program Mark(Program program, IReadOnlyCollection<int> rapids)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(rapids);
var marked = RaisedMask(program.Codes);
var copy = (Program)program.Clone();
for (var i = copy.Codes.Count - 1; i >= 0; i--)
{
if (!rapids.Contains(i))
continue;
if (copy.Codes[i] is not (RapidMove or SubProgramCall) || marked[i])
throw new ArgumentException("Only an unmarked rapid or sub-program call can be marked for a head raise.");
copy.Codes.Insert(i, new Comment(Marker));
}
return copy;
}
/// <summary>
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
/// the post's head-raise code, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, string postName) =>
PostMarkers.EnsurePostable(nest, hasCode, IsMarked, program => RaisedMask(program.Codes),
name => $"{name} has rapids that must raise the head over parts already cut, but the {postName} "
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
}
}
+9
View File
@@ -21,5 +21,14 @@ namespace OpenNest.CNC
/// leaves it out, cutting the rest of the contour as open pieces. /// leaves it out, cutting the rest of the contour as open pieces.
/// </summary> /// </summary>
Tab, Tab,
/// <summary>
/// The outside edge of a supplied blank that is only shown, never cut (for example a
/// purchased door whose instrument cutouts are the only cuts). It bounds material like
/// <see cref="Cut"/>, so nesting, containment, lead clearance and cut-offs all see the
/// blank, but it has no pierce, lead or motion in any emitted program. It must be one whole
/// closed contour enclosing every other contour.
/// </summary>
DisplayPerimeter,
} }
} }
+37
View File
@@ -0,0 +1,37 @@
using System;
namespace OpenNest.CNC
{
internal static class PostMarkers
{
/// <summary>
/// Refuses a nest whose marked programs a post cannot write: every marked part needs the
/// post's <paramref name="hasCode"/>, and <paramref name="validate"/> rejects a malformed
/// program with an <see cref="ArgumentException"/>.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, Func<Program, bool> isMarked,
Action<Program> validate, Func<string, string> missingCode)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !isMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCode)
throw new InvalidOperationException(missingCode(name));
try
{
validate(part.Program);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
+16 -29
View File
@@ -158,35 +158,7 @@ namespace OpenNest.CNC
SetModeInc(); SetModeInc();
} }
public virtual void Offset(Vector voffset) public virtual void Offset(Vector voffset) => Offset(voffset.X, voffset.Y);
{
var mode = Mode;
SetModeAbs();
for (int i = 0; i < Codes.Count; ++i)
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + voffset.X,
subpgm.Offset.Y + voffset.Y
);
}
if (code is Motion == false)
continue;
var code2 = (Motion)code;
code2.Offset(voffset);
}
if (mode == Mode.Incremental)
SetModeInc();
}
public void LineTo(double x, double y) public void LineTo(double x, double y)
{ {
@@ -508,6 +480,21 @@ namespace OpenNest.CNC
return true; return true;
} }
/// <summary>
/// A shallow copy holding only the first <paramref name="count"/> codes: the same
/// instruction, variable and sub-program instances, mode and rotation.
/// </summary>
internal Program HeadCopy(int count)
{
var head = new Program() { mode = this.mode, Rotation = this.Rotation };
head.Codes.AddRange(Codes.GetRange(0, count));
foreach (var kvp in Variables)
head.Variables[kvp.Key] = kvp.Value;
foreach (var kvp in SubPrograms)
head.SubPrograms[kvp.Key] = kvp.Value;
return head;
}
public object Clone() public object Clone()
{ {
var pgm = new Program() { mode = this.mode, Rotation = this.Rotation }; var pgm = new Program() { mode = this.mode, Rotation = this.Rotation };
+2
View File
@@ -31,6 +31,8 @@ namespace OpenNest.CNC
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results) private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
{ {
// The head never travels to a display perimeter's tail.
pgm = DisplayPerimeterTail.WithoutTail(pgm);
// Draw the approach to the first rapid destination, or to the program // Draw the approach to the first rapid destination, or to the program
// origin when it starts cutting without a rapid. Only an initial rapid // origin when it starts cutting without a rapid. Only an initial rapid
// (or call arrival) is already represented by this approach; a later // (or call arrival) is already represented by this approach; a later
+26 -7
View File
@@ -26,14 +26,16 @@ namespace OpenNest
double width, double width,
double depth, double depth,
IEnumerable<double> centers, IEnumerable<double> centers,
double removalMinutes = DefaultRemovalMinutes double removalMinutes = DefaultRemovalMinutes,
double? clearance = null
) )
: this( : this(
width, width,
depth, depth,
(centers ?? throw new ArgumentNullException(nameof(centers))) (centers ?? throw new ArgumentNullException(nameof(centers)))
.Select(c => new RailClamp(ClampEdge.OriginSide, c)), .Select(c => new RailClamp(ClampEdge.OriginSide, c)),
removalMinutes removalMinutes,
clearance
) )
{ } { }
@@ -41,7 +43,8 @@ namespace OpenNest
double width, double width,
double depth, double depth,
IEnumerable<RailClamp> clamps, IEnumerable<RailClamp> clamps,
double removalMinutes = DefaultRemovalMinutes double removalMinutes = DefaultRemovalMinutes,
double? clearance = null
) )
{ {
ArgumentNullException.ThrowIfNull(clamps); ArgumentNullException.ThrowIfNull(clamps);
@@ -60,6 +63,11 @@ namespace OpenNest
nameof(removalMinutes), nameof(removalMinutes),
"Clamp removal time must be finite and zero or more." "Clamp removal time must be finite and zero or more."
); );
if (clearance is double c && (!double.IsFinite(c) || c < 0))
throw new ArgumentOutOfRangeException(
nameof(clearance),
"Clamp clearance must be finite and zero or more."
);
var sorted = new SortedSet<RailClamp>(); var sorted = new SortedSet<RailClamp>();
foreach (var clamp in clamps) foreach (var clamp in clamps)
@@ -77,6 +85,7 @@ namespace OpenNest
Width = width; Width = width;
Depth = depth; Depth = depth;
RemovalMinutes = removalMinutes; RemovalMinutes = removalMinutes;
Clearance = clearance;
Clamps = sorted.ToArray(); Clamps = sorted.ToArray();
Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray(); Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray();
} }
@@ -93,6 +102,14 @@ namespace OpenNest
/// </summary> /// </summary>
public double RemovalMinutes { get; } public double RemovalMinutes { get; }
/// <summary>
/// The gap parts keep from every clamp footprint, independent of part spacing; null
/// keeps the sheet's bottom edge spacing (D6), the clearance before clamps had their
/// own. A clamp drawn as a support block under the sheet can use close to zero, so the
/// laser only stays off it. See <see cref="Plate.ClampClearance"/>.
/// </summary>
public double? Clearance { get; }
/// <summary> /// <summary>
/// Distinct clamps ordered by edge, then center. A clamp's position in this list /// Distinct clamps ordered by edge, then center. A clamp's position in this list
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids /// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
@@ -103,9 +120,9 @@ namespace OpenNest
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary> /// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
public IReadOnlyList<double> Centers { get; } public IReadOnlyList<double> Centers { get; }
/// <summary>A copy with the same size and removal time and these clamps.</summary> /// <summary>A copy with the same size, removal time and clearance and these clamps.</summary>
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) => public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
new(Width, Depth, clamps, RemovalMinutes); new(Width, Depth, clamps, RemovalMinutes, Clearance);
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary> /// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
public static string IdOf(int index) => $"clamp-{index + 1}"; public static string IdOf(int index) => $"clamp-{index + 1}";
@@ -225,20 +242,22 @@ namespace OpenNest
&& Width.Equals(other.Width) && Width.Equals(other.Width)
&& Depth.Equals(other.Depth) && Depth.Equals(other.Depth)
&& RemovalMinutes.Equals(other.RemovalMinutes) && RemovalMinutes.Equals(other.RemovalMinutes)
&& Nullable.Equals(Clearance, other.Clearance)
&& Clamps.SequenceEqual(other.Clamps); && Clamps.SequenceEqual(other.Clamps);
public override bool Equals(object obj) => Equals(obj as ClampRail); public override bool Equals(object obj) => Equals(obj as ClampRail);
public override int GetHashCode() public override int GetHashCode()
{ {
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count); var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clearance, Clamps.Count);
foreach (var clamp in Clamps) foreach (var clamp in Clamps)
hash = HashCode.Combine(hash, clamp); hash = HashCode.Combine(hash, clamp);
return hash; return hash;
} }
public override string ToString() => public override string ToString() =>
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]"; $"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]"
+ (Clearance is double clearance ? $" clearance {clearance:R}" : "");
private static string Describe(RailClamp clamp) => private static string Describe(RailClamp clamp) =>
clamp.Edge == ClampEdge.OriginSide clamp.Edge == ClampEdge.OriginSide
@@ -183,6 +183,9 @@ namespace OpenNest.Converters
if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase)) if (string.Equals(name, SpecialLayers.Tab.Name, System.StringComparison.OrdinalIgnoreCase))
return LayerType.Tab; return LayerType.Tab;
if (string.Equals(name, SpecialLayers.DisplayPerimeter.Name, System.StringComparison.OrdinalIgnoreCase))
return LayerType.DisplayPerimeter;
return LayerType.Cut; return LayerType.Cut;
} }
} }
@@ -213,6 +213,9 @@ namespace OpenNest.Converters
case LayerType.Tab: case LayerType.Tab:
return SpecialLayers.Tab; return SpecialLayers.Tab;
case LayerType.DisplayPerimeter:
return SpecialLayers.DisplayPerimeter;
default: default:
return new Layer(layer.ToString()); return new Layer(layer.ToString());
} }
+366 -26
View File
@@ -11,9 +11,13 @@ namespace OpenNest
Vertical, Vertical,
} }
/// <summary>A stretch of a cut-off's line, along its axis, left uncut.</summary>
public readonly record struct CutOffGap(double Start, double End);
public class CutOff public class CutOff
{ {
private const double OffsetTolerance = 0.001; private const double OffsetTolerance = 0.001;
private IReadOnlyList<CutOffGap> gaps = System.Array.Empty<CutOffGap>();
public Vector Position { get; set; } public Vector Position { get; set; }
public CutOffAxis Axis { get; set; } public CutOffAxis Axis { get; set; }
@@ -21,6 +25,83 @@ namespace OpenNest
public double? EndLimit { get; set; } public double? EndLimit { get; set; }
public Drawing Drawing { get; private set; } public Drawing Drawing { get; private set; }
/// <summary>
/// Stretches between the limits left uncut, so the line splits into pieces as it does
/// around a part; a trim of a middle piece makes one from crossing line to crossing line
/// (<see cref="CutOffTrim"/>). Kept sorted along the axis, with touching gaps merged and
/// empty or nonfinite ones dropped.
/// </summary>
public IReadOnlyList<CutOffGap> Gaps
{
get => gaps;
set => gaps = Normalize(value);
}
/// <summary>
/// The nominal line from <paramref name="start"/> to <paramref name="end"/> with its gaps
/// taken out, in ascending order: the pieces a consumer of the limits treats as cut.
/// </summary>
public IReadOnlyList<(double Start, double End)> Pieces(double start, double end) =>
Pieces(start, end, gaps);
internal static IReadOnlyList<(double Start, double End)> Pieces(double start, double end,
IReadOnlyList<CutOffGap> gaps)
{
var (low, high) = (System.Math.Min(start, end), System.Math.Max(start, end));
var pieces = new List<(double Start, double End)>();
if (gaps.Count == 0)
{
pieces.Add((low, high));
return pieces;
}
var current = low;
foreach (var gap in gaps)
{
if (gap.End <= current || gap.Start >= high)
continue;
if (gap.Start > current)
pieces.Add((current, gap.Start));
current = System.Math.Max(current, gap.End);
}
if (current < high)
pieces.Add((current, high));
return pieces;
}
private static IReadOnlyList<CutOffGap> Normalize(IEnumerable<CutOffGap> value)
{
var sorted = (value ?? Enumerable.Empty<CutOffGap>())
.Where(g => double.IsFinite(g.Start) && double.IsFinite(g.End) && g.End > g.Start)
.OrderBy(g => g.Start)
.ToList();
var merged = new List<CutOffGap>();
foreach (var gap in sorted)
{
if (merged.Count > 0 && gap.Start <= merged[^1].End + Math.Tolerance.Epsilon)
merged[^1] = merged[^1] with { End = System.Math.Max(merged[^1].End, gap.End) };
else
merged.Add(gap);
}
return merged.Count == 0 ? System.Array.Empty<CutOffGap>() : merged.AsReadOnly();
}
/// <summary>
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the
/// segment reaching that edge cuts with the head height locked; zero for none. Stock can
/// arrive wider than nominal, so only the origin-side edge is certain: the overcut cuts the
/// oversize off. It replaces <see cref="CutOffSettings.Overtravel"/> at the far edge and
/// applies only when the line runs to that edge (no limit on that side).
/// </summary>
public double HeadLockOvercut { get; set; }
/// <summary>
/// Whether the segment reaching the far edge is cut from it toward the origin: it starts at
/// the nominal far edge, overcuts outward with the head locked, rapids back with the head
/// still locked, unlocks and cuts toward the origin. Ignored without a head-locked overcut,
/// since nothing else may start a cut at an edge whose position is unknown.
/// </summary>
public bool FarEdgeFirst { get; set; }
public CutOff(Vector position, CutOffAxis axis) public CutOff(Vector position, CutOffAxis axis)
{ {
Position = position; Position = position;
@@ -35,10 +116,128 @@ namespace OpenNest
) )
{ {
var segments = ComputeSegments(plate, settings, cache); var segments = ComputeSegments(plate, settings, cache);
var program = BuildProgram(segments, settings); var program = BuildProgram(segments, settings, LockedFarEdge(plate));
Drawing.Program = program; Drawing.Program = program;
} }
/// <summary>
/// The regenerated program split into its cutting segments, one rapid-and-cut program
/// each in program order, so the plate can sequence every segment on its own. A rapid with
/// the head height locked returns along the overcut and stays in its segment.
/// </summary>
public IReadOnlyList<Program> SegmentPrograms()
{
var program = Drawing.Program;
var result = new List<Program>();
if (program == null)
return result;
Program current = null;
var locked = false;
foreach (var code in program.Codes)
{
if (code is RapidMove && !locked || current == null)
{
current = new Program(program.Mode);
result.Add(current);
}
if (HeadHeightLock.IsLock(code))
locked = true;
else if (HeadHeightLock.IsUnlock(code))
locked = false;
current.Codes.Add(code);
}
return result;
}
/// <summary>
/// Whether the far edge, along this axis on <paramref name="plate"/>, is the high
/// coordinate: the sheet runs from the origin toward positive coordinates on that axis.
/// </summary>
internal static bool FarEdgeIsHigh(CutOffAxis axis, int quadrant) =>
axis == CutOffAxis.Vertical ? quadrant is 1 or 2 : quadrant is 1 or 4;
/// <summary>
/// The nominal far edge this cut-off overcuts with the head locked, along its axis, or null
/// when it has no head-locked overcut or a limit stops it short of that edge.
/// </summary>
private (double Edge, bool High)? LockedFarEdge(Plate plate)
{
if (!(HeadLockOvercut > 0) || !double.IsFinite(HeadLockOvercut))
return null;
var bounds = plate.BoundingBox(includeParts: false);
var high = FarEdgeIsHigh(Axis, plate.Quadrant);
if ((high ? EndLimit : StartLimit) != null)
return null;
var vertical = Axis == CutOffAxis.Vertical;
return (high ? (vertical ? bounds.Top : bounds.Right) : (vertical ? bounds.Bottom : bounds.Left), high);
}
/// <summary>
/// The same segment cut the other way at the far edge: a head-locked segment cut toward the
/// far edge becomes one started there, and back; null when <paramref name="segment"/> is not
/// one of the two forms <see cref="Regenerate"/> builds for the segment reaching that edge.
/// </summary>
public static Program ReverseAtFarEdge(Program segment)
{
if (segment?.Codes == null || segment.Mode != Mode.Absolute)
return null;
var codes = segment.Codes;
var result = new Program(segment.Mode);
// Toward the far edge: rapid near, cut to the edge, lock, overcut, unlock.
if (codes.Count == 5 && codes[0] is RapidMove { Suppressed: false } near
&& codes[1] is LinearMove { Suppressed: false, Layer: LayerType.Cut } toEdge
&& HeadHeightLock.IsLock(codes[2])
&& codes[3] is LinearMove { Suppressed: false, Layer: LayerType.Cut } overcut
&& HeadHeightLock.IsUnlock(codes[4]))
{
result.Codes.Add(new RapidMove(toEdge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Lock));
result.Codes.Add(new LinearMove(overcut.EndPoint));
result.Codes.Add(new RapidMove(toEdge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
result.Codes.Add(new LinearMove(near.EndPoint));
return result;
}
// From the far edge: rapid to the edge, lock, overcut, rapid back, unlock, cut away.
if (codes.Count == 6 && codes[0] is RapidMove { Suppressed: false } edge
&& HeadHeightLock.IsLock(codes[1])
&& codes[2] is LinearMove { Suppressed: false, Layer: LayerType.Cut } outward
&& codes[3] is RapidMove { Suppressed: false } back && back.EndPoint == edge.EndPoint
&& HeadHeightLock.IsUnlock(codes[4])
&& codes[5] is LinearMove { Suppressed: false, Layer: LayerType.Cut } away)
{
result.Codes.Add(new RapidMove(away.EndPoint));
result.Codes.Add(new LinearMove(edge.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Lock));
result.Codes.Add(new LinearMove(outward.EndPoint));
result.Codes.Add(new Comment(HeadHeightLock.Unlock));
return result;
}
return null;
}
/// <summary>
/// Installs Plan Cutting's choice for the far-edge segment: <paramref name="segment"/> (one
/// of this cut-off's placed parts) takes <paramref name="program"/>, the same segment cut the
/// other way, and <see cref="FarEdgeFirst"/> and the whole-line program follow it, so
/// regeneration keeps the direction. The caller has checked that the program is that flip.
/// </summary>
internal void InstallFarEdgeSegment(Part segment, Program program)
{
var codes = new List<ICode>();
foreach (var part in SegmentPrograms())
codes.AddRange((ReverseAtFarEdge(part) != null ? program : part).Codes);
var whole = new Program(Drawing.Program?.Mode ?? Mode.Absolute);
whole.Codes.AddRange(codes);
segment.ReplaceCutOffProgram(program);
FarEdgeFirst = StartsAtFarEdge(program);
Drawing.Program = whole;
}
/// <summary>Whether <paramref name="segment"/> is the far-edge segment cut from the far edge.</summary>
public static bool StartsAtFarEdge(Program segment) =>
segment?.Codes is { Count: 6 } codes && codes[0] is RapidMove && HeadHeightLock.IsLock(codes[1]);
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary> /// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
internal (double Length, int SegmentCount) MeasureSegments( internal (double Length, int SegmentCount) MeasureSegments(
Plate plate, Plate plate,
@@ -54,38 +253,106 @@ namespace OpenNest
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts /// 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 /// 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 /// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet. /// line on the sheet edge, a stale or unrecognized program) does not separate the sheet.
/// </summary> /// </summary>
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet) internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet) =>
ContinuousCut(part, definition, sheet) is { } cut
&& cut.Low <= (definition.Axis == CutOffAxis.Vertical ? sheet.Bottom : sheet.Left) + Math.Tolerance.Epsilon
&& cut.High >= (definition.Axis == CutOffAxis.Vertical ? sheet.Top : sheet.Right) - Math.Tolerance.Epsilon;
/// <summary>
/// The cut-off parts among <paramref name="parts"/> that split off a piece of the sheet,
/// each with the round it was found in. Round 0 holds the lines that
/// <see cref="SeparatesSheet"/> across the whole sheet; a line of a later round is one
/// continuous cut from a sheet edge or an earlier round's line to another, such as the
/// last strip line stopping at a remnant's horizontal separator. It frees a piece together
/// with the lines it stops on, so it is cut after every other part and before those lines
/// (higher rounds first), keeping the remnant's mass on the sheet until the end.
/// </summary>
public static IReadOnlyDictionary<Part, int> Separators(IEnumerable<Part> parts, IEnumerable<CutOff> definitions, Box sheet)
{
var rounds = new Dictionary<Part, int>(ReferenceEqualityComparer.Instance);
if (parts == null || definitions == null || sheet == null)
return rounds;
var byDrawing = new Dictionary<Drawing, CutOff>(ReferenceEqualityComparer.Instance);
foreach (var definition in definitions)
if (definition?.Drawing != null)
byDrawing[definition.Drawing] = definition;
var cuts = new List<(Part Part, bool Vertical, double Line, double Low, double High)>();
foreach (var part in parts)
if (part?.BaseDrawing is { IsCutOff: true } drawing && byDrawing.TryGetValue(drawing, out var definition)
&& ContinuousCut(part, definition, sheet) is { } cut)
{
var vertical = definition.Axis == CutOffAxis.Vertical;
cuts.Add((part, vertical, vertical ? definition.Position.X : definition.Position.Y, cut.Low, cut.High));
}
for (var round = 0; ; round++)
{
var found = cuts.Where(cut => !rounds.ContainsKey(cut.Part) && Bounded(cut)).ToList();
if (found.Count == 0)
return rounds;
foreach (var cut in found)
rounds[cut.Part] = round;
}
// The cut runs between two boundaries: sheet edges, or lines found in an earlier round
// that cross this one where they are cut. The stretch between them is cut, as the cut
// is continuous.
bool Bounded((Part Part, bool Vertical, double Line, double Low, double High) cut)
{
var reached = cuts.Where(other => other.Vertical != cut.Vertical && rounds.ContainsKey(other.Part)
&& other.Low <= cut.Line + Math.Tolerance.Epsilon && other.High >= cut.Line - Math.Tolerance.Epsilon)
.Select(other => other.Line)
.Append(cut.Vertical ? sheet.Bottom : sheet.Left)
.Append(cut.Vertical ? sheet.Top : sheet.Right)
.Where(b => b >= cut.Low - Math.Tolerance.Epsilon && b <= cut.High + Math.Tolerance.Epsilon)
.ToList();
return reached.Count > 1 && reached.Max() - reached.Min() > Math.Tolerance.Epsilon;
}
}
/// <summary>The cut-off parts on <paramref name="plate"/> that split off a piece of it, by round.</summary>
public static IReadOnlyDictionary<Part, int> Separators(Plate plate) =>
Separators(plate.Parts, plate.CutOffs, plate.BoundingBox(includeParts: false));
// The extent along the line of a part that cuts one continuous straight line on its
// definition's line strictly inside the sheet, or null for anything else.
private static (double Low, double High)? ContinuousCut(Part part, CutOff definition, Box sheet)
{ {
if (part?.Program == null || definition == null || sheet == null if (part?.Program == null || definition == null || sheet == null
|| part.Program.Mode != Mode.Absolute) || part.Program.Mode != Mode.Absolute)
return false; return null;
var vertical = definition.Axis == CutOffAxis.Vertical; var vertical = definition.Axis == CutOffAxis.Vertical;
var line = vertical ? definition.Position.X : definition.Position.Y; var line = vertical ? definition.Position.X : definition.Position.Y;
var acrossMin = vertical ? sheet.Left : sheet.Bottom; var acrossMin = vertical ? sheet.Left : sheet.Bottom;
var acrossMax = vertical ? sheet.Right : sheet.Top; 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)) if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
return false; return null;
Vector? position = null; Vector? position = null;
double? low = null, high = null; double? low = null, high = null;
var cutting = false; var cutting = false;
var locked = false;
foreach (var code in part.Program.Codes) foreach (var code in part.Program.Codes)
{ {
if (HeadHeightLock.IsLock(code))
locked = true;
else if (HeadHeightLock.IsUnlock(code))
locked = false;
switch (code) switch (code)
{ {
case RapidMove rapid: case RapidMove rapid:
if (cutting) // A second pierce leaves an uncut gap; a head-locked rapid returns along
return false; // A second pierce leaves an uncut gap. // the overcut to where the cut toward the origin begins.
if (cutting && !locked)
return null;
position = rapid.EndPoint + part.Location; position = rapid.EndPoint + part.Location;
break; break;
case LinearMove move when position != null: case LinearMove move when position != null:
var end = move.EndPoint + part.Location; var end = move.EndPoint + part.Location;
if (!OnLine(position.Value) || !OnLine(end)) if (!OnLine(position.Value) || !OnLine(end))
return false; return null;
foreach (var value in new[] { Along(position.Value), Along(end) }) foreach (var value in new[] { Along(position.Value), Along(end) })
{ {
low = low == null ? value : System.Math.Min(low.Value, value); low = low == null ? value : System.Math.Min(low.Value, value);
@@ -95,10 +362,10 @@ namespace OpenNest
cutting = true; cutting = true;
break; break;
case Motion: case Motion:
return false; return null;
} }
} }
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon; return cutting ? (low.Value, high.Value) : null;
bool OnLine(Vector point) => bool OnLine(Vector point) =>
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon; System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
@@ -119,6 +386,9 @@ namespace OpenNest
) )
{ {
var bounds = plate.BoundingBox(includeParts: false); var bounds = plate.BoundingBox(includeParts: false);
// A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
var lockedFarEdge = LockedFarEdge(plate);
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
double lineStart, double lineStart,
lineEnd, lineEnd,
@@ -128,15 +398,22 @@ namespace OpenNest
{ {
cutPosition = Position.X; cutPosition = Position.X;
lineStart = StartLimit ?? bounds.Y; lineStart = StartLimit ?? bounds.Y;
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel); lineEnd = EndLimit ?? (bounds.Y + bounds.Width + overtravel);
} }
else else
{ {
cutPosition = Position.Y; cutPosition = Position.Y;
lineStart = StartLimit ?? bounds.X; lineStart = StartLimit ?? bounds.X;
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel); lineEnd = EndLimit ?? (bounds.X + bounds.Length + overtravel);
} }
// An outlined sheet is cut only where the line crosses material: each crossing is a
// span of its own, started at the material's edge and overtraveled past the far
// one, within the user's limits.
var spans = plate.Outline == null
? new List<(double Start, double End)> { (lineStart, lineEnd) }
: MaterialSpans(plate, cutPosition, lineStart, lineEnd, settings.Overtravel, lockedFarEdge);
var exclusions = new List<(double Start, double End)>(); var exclusions = new List<(double Start, double End)>();
foreach (var part in plate.Parts) foreach (var part in plate.Parts)
@@ -169,6 +446,8 @@ namespace OpenNest
); );
exclusions.AddRange(partExclusions); exclusions.AddRange(partExclusions);
} }
foreach (var gap in gaps)
exclusions.Add((gap.Start, gap.End));
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start)); exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
var merged = new List<(double Start, double End)>(); var merged = new List<(double Start, double End)>();
@@ -181,27 +460,71 @@ namespace OpenNest
} }
var segments = new List<(double Start, double End)>(); var segments = new List<(double Start, double End)>();
var current = lineStart; foreach (var (spanStart, spanEnd) in spans)
foreach (var ex in merged)
{ {
var clampedStart = System.Math.Max(ex.Start, lineStart); var current = spanStart;
var clampedEnd = System.Math.Min(ex.End, lineEnd);
if (clampedStart > current) foreach (var ex in merged)
segments.Add((current, clampedStart)); {
var clampedStart = System.Math.Max(ex.Start, spanStart);
var clampedEnd = System.Math.Min(ex.End, spanEnd);
current = System.Math.Max(current, clampedEnd); if (clampedStart > current)
segments.Add((current, clampedStart));
current = System.Math.Max(current, clampedEnd);
}
if (current < spanEnd)
segments.Add((current, spanEnd));
} }
if (current < lineEnd)
segments.Add((current, lineEnd));
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList(); segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
return segments; return segments;
} }
/// <summary>
/// Where the line at <paramref name="cutPosition"/> crosses the sheet's outlined material,
/// in ascending order along the axis and clipped to the line's limits. Each span starts at
/// the material edge and runs <paramref name="overtravel"/> past the far one unless a limit
/// or a head-locked far edge ends it there.
/// </summary>
private List<(double Start, double End)> MaterialSpans(Plate plate, double cutPosition,
double lineStart, double lineEnd, double overtravel, (double Edge, bool High)? lockedFarEdge)
{
var vertical = Axis == CutOffAxis.Vertical;
var crossings = new List<double>();
foreach (var path in plate.Outline.Material(plate.Outline.PlacementOrigin(plate.Quadrant)))
{
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
var (aAcross, aAlong) = vertical ? (a.x, a.y) : (a.y, a.x);
var (bAcross, bAlong) = vertical ? (b.x, b.y) : (b.y, b.x);
// Half-open on the across axis, so a line through a vertex counts one edge.
if (aAcross <= cutPosition == bAcross <= cutPosition)
continue;
crossings.Add(aAlong + (bAlong - aAlong) * (cutPosition - aAcross) / (bAcross - aAcross));
}
}
crossings.Sort();
var spans = new List<(double Start, double End)>();
for (var i = 0; i + 1 < crossings.Count; i += 2)
{
var start = System.Math.Max(crossings[i], lineStart);
var end = crossings[i + 1];
var limited = EndLimit != null && end >= EndLimit.Value
|| lockedFarEdge is var (edge, high) && high && end == edge;
end = System.Math.Min(limited ? end : end + overtravel, lineEnd);
if (end > start)
spans.Add((start, end));
}
return spans;
}
private static readonly List<(double Start, double End)> EmptyExclusions = new(); private static readonly List<(double Start, double End)> EmptyExclusions = new();
private List<(double Start, double End)> GetPartExclusions( private List<(double Start, double End)> GetPartExclusions(
@@ -436,7 +759,8 @@ namespace OpenNest
private Program BuildProgram( private Program BuildProgram(
List<(double Start, double End)> segments, List<(double Start, double End)> segments,
CutOffSettings settings CutOffSettings settings,
(double Edge, bool High)? farEdge
) )
{ {
var program = new Program(); var program = new Program();
@@ -453,6 +777,22 @@ namespace OpenNest
foreach (var seg in segments) foreach (var seg in segments)
{ {
if (farEdge is var (edge, high) && (high ? seg.End : seg.Start) == edge)
{
// The segment reaching the far edge overcuts it with the head height locked,
// whichever way the rest of the line runs.
var near = MakePoint(cutPos, high ? seg.Start : seg.End);
var atEdge = MakePoint(cutPos, edge);
var beyond = MakePoint(cutPos, high ? edge + HeadLockOvercut : edge - HeadLockOvercut);
var towardEdge = new Program();
towardEdge.Codes.Add(new RapidMove(near));
towardEdge.Codes.Add(new LinearMove(atEdge));
towardEdge.Codes.Add(new Comment(HeadHeightLock.Lock));
towardEdge.Codes.Add(new LinearMove(beyond));
towardEdge.Codes.Add(new Comment(HeadHeightLock.Unlock));
program.Codes.AddRange((FarEdgeFirst ? ReverseAtFarEdge(towardEdge) : towardEdge).Codes);
continue;
}
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End); var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
program.Codes.Add(new RapidMove(MakePoint(cutPos, from))); program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
program.Codes.Add(new LinearMove(MakePoint(cutPos, to))); program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
+106
View File
@@ -0,0 +1,106 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along
/// the cut-off's axis, and the span of the nominal line it stands for, from the end of the
/// segment before it (or the line's start) to the start of the one after it (or the line's
/// end), stopping short of a <see cref="CutOff.Gaps">gap</see> between them. The parts the
/// nominal line passes through lie in the gaps between segments, so each is inside the span
/// of the segment on either side of it; a segment is cut before exactly those parts.
/// </summary>
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
/// <summary>
/// A cut-off's trimmed segments are materialized as one part each, so every segment has its
/// own place in the cut sequence. This finds those parts and the span each stands for.
/// </summary>
public static class CutOffSegments
{
/// <summary>The definition's placed segment parts in ascending order along its axis.</summary>
public static IReadOnlyList<CutOffSegment> Of(Plate plate, CutOff definition) =>
Of(definition, plate.Parts, plate.BoundingBox(includeParts: false));
/// <summary>
/// The definition's segment parts among <paramref name="parts"/> (those placed from its
/// drawing) in ascending order along its axis, with spans measured against the nominal
/// line's limits on <paramref name="sheet"/>. A part whose cut cannot be measured stands
/// for the whole line.
/// </summary>
public static IReadOnlyList<CutOffSegment> Of(CutOff definition, IEnumerable<Part> parts, Box sheet)
{
var vertical = definition.Axis == CutOffAxis.Vertical;
var lineStart = definition.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left);
var lineEnd = definition.EndLimit ?? (vertical ? sheet.Top : sheet.Right);
var low = System.Math.Min(lineStart, lineEnd);
var high = System.Math.Max(lineStart, lineEnd);
var measured = new List<(Part Part, double Start, double End)>();
var whole = new List<CutOffSegment>();
foreach (var part in parts)
{
if (!ReferenceEquals(part.BaseDrawing, definition.Drawing))
continue;
if (Extent(part, vertical) is { } extent)
measured.Add((part, extent.Start, extent.End));
else
whole.Add(new CutOffSegment(part, low, high, low, high));
}
measured.Sort((a, b) => a.Start != b.Start ? a.Start.CompareTo(b.Start) : a.End.CompareTo(b.End));
var result = new List<CutOffSegment>(measured.Count + whole.Count);
for (var i = 0; i < measured.Count; i++)
{
var (part, start, end) = measured[i];
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End;
var spanEnd = i == measured.Count - 1 ? System.Math.Max(high, end) : measured[i + 1].Start;
// A gap between two segments belongs to neither: each span stops at its edge.
foreach (var gap in definition.Gaps)
{
if (i > 0 && gap.End <= start + Math.Tolerance.Epsilon && gap.End > spanStart)
spanStart = gap.End;
if (i < measured.Count - 1 && gap.Start >= end - Math.Tolerance.Epsilon && gap.Start < spanEnd)
spanEnd = gap.Start;
}
result.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
}
result.AddRange(whole);
return result;
}
/// <summary>
/// The extent along the cut-off's axis of the part's cutting moves, or null when the
/// part cuts nothing or its program is not the absolute rapid-and-cut form cut-offs use.
/// </summary>
public static (double Start, double End)? Extent(Part part, bool vertical)
{
if (part?.Program == null || part.Program.Mode != Mode.Absolute)
return null;
double? min = null, max = null;
Vector? position = null;
foreach (var code in part.Program.Codes)
{
switch (code)
{
case RapidMove rapid:
position = rapid.EndPoint + part.Location;
break;
case Motion motion when position != null:
var end = motion.EndPoint + part.Location;
foreach (var point in new[] { position.Value, end })
{
var along = vertical ? point.Y : point.X;
min = min == null ? along : System.Math.Min(min.Value, along);
max = max == null ? along : System.Math.Max(max.Value, along);
}
position = end;
break;
case Motion:
return null;
}
}
return min == null ? null : (min.Value, max.Value);
}
}
}
+15
View File
@@ -12,5 +12,20 @@ namespace OpenNest
public double Overtravel { get; set; } public double Overtravel { get; set; }
public double MinSegmentLength { get; set; } = 0.05; public double MinSegmentLength { get; set; } = 0.05;
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin; public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
/// <summary>
/// Whether new cut-offs lock the head height and overcut past the nominal far sheet edge
/// by <see cref="HeadLockOvercut"/> (see <see cref="CutOff.HeadLockOvercut"/>). Off until
/// the machine's post has head-height lock codes.
/// </summary>
public bool LockHeadHeight { get; set; }
/// <summary>How far past the nominal far edge a head-locked cut-off cuts, in model units.</summary>
public double HeadLockOvercut { get; set; } = 1.5;
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0;
public CutOffSettings Copy() => (CutOffSettings)MemberwiseClone();
} }
} }
+500
View File
@@ -0,0 +1,500 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// The limits and gaps one cut-off takes when a <see cref="CutOffEdit"/> is applied; null
/// <paramref name="Gaps"/> keeps the cut-off's gaps.
/// </summary>
public sealed record CutOffLimitChange(CutOff CutOff, double? StartLimit, double? EndLimit,
IReadOnlyList<CutOffGap> Gaps = null)
{
/// <summary>The gaps the cut-off has once the change is applied.</summary>
public IReadOnlyList<CutOffGap> NewGaps => Gaps ?? CutOff.Gaps;
}
/// <summary>
/// A worked-out trim, extend or swap: the new limits and gaps of each cut-off it changes, or
/// why it was refused. Nothing changes until <see cref="Apply"/>; the caller then regenerates
/// the plate's cut-offs and places their segments in the cutting order.
/// </summary>
public sealed class CutOffEdit
{
private CutOffEdit(IReadOnlyList<CutOffLimitChange> changes, string refusal, string notice)
{
Changes = changes;
Refusal = refusal;
Notice = notice;
}
public IReadOnlyList<CutOffLimitChange> Changes { get; }
/// <summary>Why the edit cannot be made; null when it can.</summary>
public string Refusal { get; }
/// <summary>Something the operator should know once it is made, such as a head-locked overcut turning off.</summary>
public string Notice { get; }
public bool Succeeded => Refusal == null;
internal static CutOffEdit Refused(string reason) => new(System.Array.Empty<CutOffLimitChange>(), reason, null);
internal static CutOffEdit Of(IReadOnlyList<CutOffLimitChange> changes, string notice) => new(changes, null, notice);
public void Apply()
{
if (!Succeeded)
throw new System.InvalidOperationException(Refusal);
foreach (var change in Changes)
{
change.CutOff.StartLimit = change.StartLimit;
change.CutOff.EndLimit = change.EndLimit;
if (change.Gaps != null)
change.CutOff.Gaps = change.Gaps;
}
}
}
/// <summary>
/// The perpendicular lines that meet a cut-off as a drag of it starts: their limits or gap
/// edges sit on its center, where a trim or extend left them. <see cref="MoveTo"/> takes those
/// stops along, so each line is extended or trimmed to keep meeting it. Every move works from
/// the lines as they were at the start, so a stop held back partway is restored on the way
/// back. A stop never passes the next one on its line: it stays a minimum segment short of it,
/// or of the sheet edge.
/// </summary>
public sealed class CutOffJoints
{
private readonly List<(CutOff CutOff, double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps)> joined;
private readonly double from;
private readonly double sheetLow;
private readonly double sheetHigh;
private readonly double minimum;
internal CutOffJoints(IEnumerable<CutOff> lines, double from, double sheetLow, double sheetHigh, double minimum)
{
joined = lines.Select(c => (c, c.StartLimit, c.EndLimit, c.Gaps)).ToList();
this.from = from;
this.sheetLow = sheetLow;
this.sheetHigh = sheetHigh;
this.minimum = minimum;
}
/// <summary>The lines that follow the cut-off.</summary>
public IReadOnlyList<CutOff> Lines => joined.Select(j => j.CutOff).ToList();
/// <summary>Moves the joined stops to <paramref name="line"/>, the cut-off's new center.</summary>
public void MoveTo(double line)
{
foreach (var (cutOff, startLimit, endLimit, gaps) in joined)
{
var stops = gaps.SelectMany(g => new[] { g.Start, g.End })
.Append(startLimit ?? sheetLow)
.Append(endLimit ?? sheetHigh)
.ToList();
var below = stops.Where(s => s < from - Tolerance.Epsilon).DefaultIfEmpty(sheetLow).Max();
var above = stops.Where(s => s > from + Tolerance.Epsilon).DefaultIfEmpty(sheetHigh).Min();
var at = System.Math.Clamp(line,
System.Math.Min(below + minimum, from), System.Math.Max(above - minimum, from));
cutOff.StartLimit = startLimit is double start ? Follow(start, at) : null;
cutOff.EndLimit = endLimit is double end ? Follow(end, at) : null;
cutOff.Gaps = gaps.Select(g => new CutOffGap(Follow(g.Start, at), Follow(g.End, at))).ToList();
}
}
private double Follow(double stop, double at) =>
System.Math.Abs(stop - from) <= Tolerance.Epsilon ? at : stop;
}
/// <summary>
/// Trims and extends cut-offs against the cut-offs that cross them, by changing only their
/// start and end limits and their gaps (see docs/automatic-scrap-cutoffs.md, "Trim and
/// extend"). A trim stops on the crossing line's center, inside its kerf; trimming a piece
/// between two crossing lines leaves a gap from one center to the other. An extend closes the
/// gap at the picked end, or runs the line's end to the next crossing line beyond it, or to
/// the sheet edge, which clears the limit.
/// </summary>
public static class CutOffTrim
{
public const string NotOnPlate = "That cut-off is not on this plate.";
public const string NothingToTrimTo = "No cut-off crosses this line to trim it to; delete it instead.";
public const string InGap = "That stretch of the line is already uncut.";
public const string TooShort = "Trimming here would leave a line shorter than the minimum segment length.";
public const string AtSheetEdge = "This end already runs to the sheet edge.";
public const string NotAJunction = "These cut-offs do not meet with one running through and the other stopping at it.";
public const string GapAtJunction = "A gap in one of these lines meets the junction; extend to close it before swapping.";
public const string OvercutOff = "The head-locked overcut is off while this line stops short of the far edge.";
public const string OvercutOn = "The head-locked overcut is back on at the far edge.";
/// <summary>
/// Removes the piece of <paramref name="target"/> picked at <paramref name="pick"/>, between
/// the crossing cut-offs on either side of it, or a gap or the line's end where there is no
/// crossing on that side. An end piece moves that end's limit; a piece between two crossing
/// lines becomes a gap, merged with any gap it touches.
/// </summary>
public static CutOffEdit Trim(Plate plate, CutOff target, Vector pick, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = System.Math.Clamp(Along(target, pick), start, end);
var pieces = target.Pieces(start, end);
var piece = pieces.Where(p => at >= p.Start - Tolerance.Epsilon && at <= p.End + Tolerance.Epsilon)
.Cast<(double Start, double End)?>().FirstOrDefault();
if (piece is not var (pieceStart, pieceEnd))
return CutOffEdit.Refused(InGap);
var crossings = Crossings(plate, target)
.Where(c => c > pieceStart + Tolerance.Epsilon && c < pieceEnd - Tolerance.Epsilon)
.ToList();
var low = crossings.Where(c => c <= at).DefaultIfEmpty(pieceStart).Max();
var high = crossings.Where(c => c > at).DefaultIfEmpty(pieceEnd).Min();
if (pieces.Count == 1 && low == start && high == end)
return CutOffEdit.Refused(NothingToTrimTo);
var remaining = new List<(double Start, double End)>();
foreach (var (from, to) in pieces)
{
if (from < low)
remaining.Add((from, System.Math.Min(to, low)));
if (to > high)
remaining.Add((System.Math.Max(from, high), to));
}
var minimum = System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon);
if (remaining.Count == 0
|| remaining.Any(p => (p.End == low || p.Start == high) && p.End - p.Start < minimum))
return CutOffEdit.Refused(TooShort);
return Single(plate, Change(target, start, end, remaining));
}
/// <summary>
/// Lengthens <paramref name="target"/> at the end of the piece nearer <paramref name="pick"/>:
/// a gap there closes; the line's own end runs to the next crossing cut-off beyond it, or to
/// the sheet edge when there is none. A cut-off sitting at the current end is skipped, so
/// picking again extends one more step. A pick inside a gap closes that gap.
/// </summary>
public static CutOffEdit Extend(Plate plate, CutOff target, Vector pick)
{
if (!plate.CutOffs.Contains(target))
return CutOffEdit.Refused(NotOnPlate);
var (start, end) = NominalSpan(plate, target);
var at = Along(target, pick);
var gaps = target.Gaps;
var inside = gaps.Where(g => at > g.Start && at < g.End).ToList();
if (inside.Count == 0)
{
var pieces = target.Pieces(start, end);
var piece = pieces.OrderBy(p => Distance(at, p)).First();
var high = System.Math.Abs(at - piece.End) <= System.Math.Abs(at - piece.Start);
var edge = high ? piece.End : piece.Start;
inside = gaps.Where(g => System.Math.Abs((high ? g.Start : g.End) - edge) <= Tolerance.Epsilon).ToList();
if (inside.Count == 0)
{
return ExtendEnd(plate, target, high, out var limit)
? Single(plate, new CutOffLimitChange(target,
high ? target.StartLimit : limit, high ? limit : target.EndLimit))
: CutOffEdit.Refused(AtSheetEdge);
}
}
return Single(plate, new CutOffLimitChange(target, target.StartLimit, target.EndLimit,
gaps.Except(inside).ToList()));
}
/// <summary>
/// Stops a new <paramref name="target"/> at the first crossing cut-off from its start edge,
/// the origin side unless <paramref name="settings"/> cut toward the origin. It stays full
/// length, with no changes, when nothing crosses it or the line left would be shorter than
/// the minimum segment length. <paramref name="target"/> need not be on the plate yet.
/// </summary>
public static CutOffEdit StopAtFirstCrossing(Plate plate, CutOff target, CutOffSettings settings)
{
var (start, end) = NominalSpan(plate, target);
var crossings = Crossings(plate, target)
.Where(c => c > start + Tolerance.Epsilon && c < end - Tolerance.Epsilon)
.ToList();
if (crossings.Count == 0)
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
var towardOrigin = settings?.CutDirection == CutDirection.TowardOrigin;
var startsHigh = towardOrigin == CutOff.FarEdgeIsHigh(target.Axis, plate.Quadrant);
var stop = startsHigh ? crossings.Max() : crossings.Min();
var remaining = startsHigh ? end - stop : stop - start;
if (remaining < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Of(System.Array.Empty<CutOffLimitChange>(), null);
return startsHigh
? Single(plate, new CutOffLimitChange(target, stop, target.EndLimit))
: Single(plate, new CutOffLimitChange(target, target.StartLimit, stop));
}
/// <summary>
/// The lines that meet <paramref name="moved"/> and follow it when it moves: perpendicular
/// cut-offs with a limit or gap edge on its center, where its own cut pieces reach them. A
/// line that only ends level with it, past its end or in one of its gaps, stays put.
/// </summary>
public static CutOffJoints Joints(Plate plate, CutOff moved, CutOffSettings settings)
{
var at = Line(moved);
var (movedStart, movedEnd) = NominalSpan(plate, moved);
var pieces = moved.Pieces(movedStart, movedEnd);
var lines = plate.CutOffs
.Where(other => other != moved && other.Axis != moved.Axis)
.Where(other => pieces.Any(p => Within(Line(other), p.Start, p.End)))
.Where(other => Stops(other).Any(stop => System.Math.Abs(stop - at) <= Tolerance.Epsilon));
var sheet = plate.BoundingBox(includeParts: false);
var horizontal = moved.Axis == CutOffAxis.Vertical;
return new CutOffJoints(lines, at, horizontal ? sheet.Left : sheet.Bottom,
horizontal ? sheet.Right : sheet.Top,
System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon));
}
// Where the cut-off's own limits and gaps stop it, not counting the sheet edges.
private static IEnumerable<double> Stops(CutOff cutOff)
{
if (cutOff.StartLimit is double start)
yield return start;
if (cutOff.EndLimit is double end)
yield return end;
foreach (var gap in cutOff.Gaps)
{
yield return gap.Start;
yield return gap.End;
}
}
/// <summary>
/// Swaps which of two meeting cut-offs runs through: the one that stopped at the junction
/// extends past it (as <see cref="Extend"/> does), and the one that ran through is trimmed
/// there, losing the side away from the plate's origin and any gaps on it. Only the
/// origin-side sheet edge is certain, so the trimmed line keeps starting there. Refused when
/// a gap in either line meets the junction.
/// </summary>
public static CutOffEdit Swap(Plate plate, CutOff first, CutOff second, CutOffSettings settings)
{
if (!plate.CutOffs.Contains(first) || !plate.CutOffs.Contains(second))
return CutOffEdit.Refused(NotOnPlate);
if (first != second && first.Axis != second.Axis
&& (TouchesGap(first, Line(second)) || TouchesGap(second, Line(first))))
return CutOffEdit.Refused(GapAtJunction);
if (Junction(plate, first, second) is not var (through, stopper, point))
return CutOffEdit.Refused(NotAJunction);
var (start, end) = NominalSpan(plate, through);
var farHigh = CutOff.FarEdgeIsHigh(through.Axis, plate.Quadrant);
var kept = through.Pieces(start, end)
.Select(p => farHigh ? (p.Start, System.Math.Min(p.End, point)) : (System.Math.Max(p.Start, point), p.End))
.Where(p => p.Item2 > p.Item1)
.ToList();
var adjacent = kept.FirstOrDefault(p => (farHigh ? p.Item2 : p.Item1) == point);
if (kept.Count == 0
|| adjacent.Item2 - adjacent.Item1 < System.Math.Max(settings?.MinSegmentLength ?? 0, Tolerance.Epsilon))
return CutOffEdit.Refused(TooShort);
var throughChange = farHigh
? new CutOffLimitChange(through, through.StartLimit, point, GapsBetween(kept))
: new CutOffLimitChange(through, point, through.EndLimit, GapsBetween(kept));
var (stopStart, stopEnd) = NominalSpan(plate, stopper);
var stopperPoint = Line(through);
var high = System.Math.Abs(stopperPoint - stopEnd) <= System.Math.Abs(stopperPoint - stopStart);
if (!ExtendEnd(plate, stopper, high, out var limit))
return CutOffEdit.Refused(NotAJunction);
var stopperChange = high
? new CutOffLimitChange(stopper, stopper.StartLimit, limit)
: new CutOffLimitChange(stopper, limit, stopper.EndLimit);
var notices = new[] { Notice(plate, throughChange), Notice(plate, stopperChange) }
.Where(n => n != null).Distinct().ToList();
return CutOffEdit.Of(new[] { stopperChange, throughChange },
notices.Count == 0 ? null : string.Join(" ", notices));
}
/// <summary>
/// The two cut-offs meeting within <paramref name="tolerance"/> of <paramref name="point"/>,
/// one running through the junction and the other stopping at it; null when there is none.
/// </summary>
public static (CutOff Through, CutOff Stopper)? JunctionAt(Plate plate, Vector point, double tolerance)
{
if (plate?.CutOffs == null)
return null;
(CutOff, CutOff)? best = null;
var bestDistance = double.MaxValue;
foreach (var vertical in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical))
foreach (var horizontal in plate.CutOffs.Where(c => c.Axis == CutOffAxis.Horizontal))
{
if (Junction(plate, vertical, horizontal) is not var (through, stopper, _))
continue;
var distance = new Vector(vertical.Position.X, horizontal.Position.Y).DistanceTo(point);
if (distance <= tolerance && distance < bestDistance)
{
best = (through, stopper);
bestDistance = distance;
}
}
return best;
}
/// <summary>
/// Where <paramref name="cutOff"/>'s line runs along its axis: its limits, or the sheet
/// edges where it has none. Overtravel is not part of the nominal line, and neither does it
/// take out the gaps (see <see cref="CutOff.Pieces(double, double)"/>).
/// </summary>
public static (double Start, double End) NominalSpan(Plate plate, CutOff cutOff)
{
var sheet = plate.BoundingBox(includeParts: false);
var vertical = cutOff.Axis == CutOffAxis.Vertical;
return (cutOff.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
cutOff.EndLimit ?? (vertical ? sheet.Top : sheet.Right));
}
/// <summary>
/// The stretches of the nominal line a change removes and adds, along the axis, in ascending
/// order: what a preview marks.
/// </summary>
public static (IReadOnlyList<(double Start, double End)> Removed, IReadOnlyList<(double Start, double End)> Added)
Difference(Plate plate, CutOffLimitChange change)
{
var cutOff = change.CutOff;
var sheet = plate.BoundingBox(includeParts: false);
var vertical = cutOff.Axis == CutOffAxis.Vertical;
var before = cutOff.Pieces(NominalSpan(plate, cutOff).Start, NominalSpan(plate, cutOff).End);
var after = CutOff.Pieces(change.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left),
change.EndLimit ?? (vertical ? sheet.Top : sheet.Right), change.NewGaps);
return (Subtract(before, after), Subtract(after, before));
}
private static List<(double Start, double End)> Subtract(
IReadOnlyList<(double Start, double End)> from, IReadOnlyList<(double Start, double End)> remove)
{
var result = new List<(double Start, double End)>();
foreach (var piece in from)
{
var current = piece.Start;
foreach (var (start, end) in remove.Where(r => r.End > piece.Start && r.Start < piece.End))
{
if (start > current + Tolerance.Epsilon)
result.Add((current, start));
current = System.Math.Max(current, end);
}
if (piece.End > current + Tolerance.Epsilon)
result.Add((current, piece.End));
}
return result;
}
private static (CutOff Through, CutOff Stopper, double Point)? Junction(Plate plate, CutOff first, CutOff second)
{
if (first == second || first.Axis == second.Axis)
return null;
var firstAt = Line(second);
var secondAt = Line(first);
if (TouchesGap(first, firstAt) || TouchesGap(second, secondAt))
return null;
var (firstStart, firstEnd) = NominalSpan(plate, first);
var (secondStart, secondEnd) = NominalSpan(plate, second);
if (!Within(firstAt, firstStart, firstEnd) || !Within(secondAt, secondStart, secondEnd))
return null;
var firstStops = AtEnd(firstAt, firstStart, firstEnd);
var secondStops = AtEnd(secondAt, secondStart, secondEnd);
if (firstStops == secondStops)
return null;
return firstStops ? (second, first, secondAt) : (first, second, firstAt);
}
// Whether one of the cut-off's gaps contains or ends at the value along its axis.
private static bool TouchesGap(CutOff cutOff, double along) =>
cutOff.Gaps.Any(g => Within(along, g.Start, g.End));
// The limit an end takes when extended: the next crossing line beyond it, or null for the
// sheet edge. False when that end already runs to the sheet edge.
private static bool ExtendEnd(Plate plate, CutOff target, bool high, out double? limit)
{
limit = null;
if ((high ? target.EndLimit : target.StartLimit) == null)
return false;
var (start, end) = NominalSpan(plate, target);
var crossings = Crossings(plate, target);
if (high)
{
var next = crossings.Where(c => c > end + Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Min();
limit = double.IsNaN(next) ? null : next;
}
else
{
var next = crossings.Where(c => c < start - Tolerance.Epsilon).DefaultIfEmpty(double.NaN).Max();
limit = double.IsNaN(next) ? null : next;
}
return true;
}
// Where the perpendicular cut-offs whose cut pieces reach this one cross it, along its axis.
private static List<double> Crossings(Plate plate, CutOff target)
{
var line = Line(target);
return plate.CutOffs
.Where(other => other != target && other.Axis != target.Axis)
.Where(other =>
{
var (start, end) = NominalSpan(plate, other);
return other.Pieces(start, end).Any(p => Within(line, p.Start, p.End));
})
.Select(Line)
.ToList();
}
// The change that leaves exactly the given pieces of the nominal line from start to end: a
// limit stays null while its end stays at the sheet edge.
private static CutOffLimitChange Change(CutOff target, double start, double end,
IReadOnlyList<(double Start, double End)> pieces)
{
var first = pieces[0].Start;
var last = pieces[^1].End;
return new CutOffLimitChange(target,
target.StartLimit == null && first == start ? null : first,
target.EndLimit == null && last == end ? null : last,
GapsBetween(pieces));
}
private static List<CutOffGap> GapsBetween(IReadOnlyList<(double Start, double End)> pieces)
{
var gaps = new List<CutOffGap>();
for (var i = 1; i < pieces.Count; i++)
if (pieces[i].Start > pieces[i - 1].End)
gaps.Add(new CutOffGap(pieces[i - 1].End, pieces[i].Start));
return gaps;
}
private static CutOffEdit Single(Plate plate, CutOffLimitChange change) =>
CutOffEdit.Of(new[] { change }, Notice(plate, change));
private static string Notice(Plate plate, CutOffLimitChange change)
{
var cutOff = change.CutOff;
if (!(cutOff.HeadLockOvercut > 0))
return null;
var high = CutOff.FarEdgeIsHigh(cutOff.Axis, plate.Quadrant);
var before = (high ? cutOff.EndLimit : cutOff.StartLimit) == null;
var after = (high ? change.EndLimit : change.StartLimit) == null;
return before == after ? null : after ? OvercutOn : OvercutOff;
}
// The cut-off's own coordinate across its axis: X for a vertical line, Y for a horizontal one.
private static double Line(CutOff cutOff) =>
cutOff.Axis == CutOffAxis.Vertical ? cutOff.Position.X : cutOff.Position.Y;
private static double Along(CutOff cutOff, Vector point) =>
cutOff.Axis == CutOffAxis.Vertical ? point.Y : point.X;
private static double Distance(double value, (double Start, double End) piece) =>
value < piece.Start ? piece.Start - value : value > piece.End ? value - piece.End : 0;
private static bool Within(double value, double start, double end) =>
value >= start - Tolerance.Epsilon && value <= end + Tolerance.Epsilon;
private static bool AtEnd(double value, double start, double end) =>
System.Math.Abs(value - start) <= Tolerance.Epsilon || System.Math.Abs(value - end) <= Tolerance.Epsilon;
}
}
@@ -28,15 +28,26 @@ public static class ClampCutDiagnostics
var cut = plate.CutOffs[index]; var cut = plate.CutOffs[index];
if (cut == null || !Enum.IsDefined(cut.Axis)) if (cut == null || !Enum.IsDefined(cut.Axis))
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate)); throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
var start = cut.Axis == CutOffAxis.Vertical // A head-locked overcut runs on past the far edge, where an off-sheet clamp can be.
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom) var overcut = cut.HeadLockOvercut > 0 && double.IsFinite(cut.HeadLockOvercut) ? cut.HeadLockOvercut : 0;
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y); var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
var end = cut.Axis == CutOffAxis.Vertical var low = farHigh ? 0 : overcut;
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top) var high = farHigh ? overcut : 0;
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y); var vertical = cut.Axis == CutOffAxis.Vertical;
var from = cut.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left) - low;
var to = cut.EndLimit ?? (vertical ? sheet.Top : sheet.Right) + high;
// A clamp wholly inside a gap meets no cut.
foreach (var zone in zones) foreach (var zone in zones)
if (Intersects(start, end, zone.Bounds, out var location)) foreach (var (pieceStart, pieceEnd) in cut.Pieces(from, to))
findings.Add(new CutOffClampFinding(index, zone.Id, location)); {
var start = vertical ? new Vector(cut.Position.X, pieceStart) : new Vector(pieceStart, cut.Position.Y);
var end = vertical ? new Vector(cut.Position.X, pieceEnd) : new Vector(pieceEnd, cut.Position.Y);
if (Intersects(start, end, zone.Bounds, out var location))
{
findings.Add(new CutOffClampFinding(index, zone.Id, location));
break;
}
}
} }
return findings.AsReadOnly(); return findings.AsReadOnly();
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using OpenNest.CNC; using OpenNest.CNC;
namespace OpenNest.Diagnostics; namespace OpenNest.Diagnostics;
@@ -12,20 +13,24 @@ 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 (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; // Gaps are replaced, never edited in place, so their reference stands for their content.
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End,
IReadOnlyList<CutOffGap> Gaps, long Overcut)[] cutoffs;
private readonly (long Width, long Length, int Quadrant) sheet; private readonly (long Width, long Length, int Quadrant) sheet;
private readonly long partSpacing;
private OverlapGeometryStamp(Plate plate) private OverlapGeometryStamp(Plate plate)
{ {
this.plate = plate; this.plate = plate;
sheet = SheetState(plate); sheet = SheetState(plate);
partSpacing = Bits(plate.PartSpacing);
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]; zones = new (string, long, long, long, long)[plate.ClampZones.Count];
for (var i = 0; i < zones.Length; i++) for (var i = 0; i < zones.Length; i++)
zones[i] = ZoneState(plate.ClampZones[i]); zones[i] = ZoneState(plate.ClampZones[i]);
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count]; cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long)[plate.CutOffs.Count];
for (var i = 0; i < cutoffs.Length; i++) for (var i = 0; i < cutoffs.Length; i++)
cutoffs[i] = CutOffState(plate.CutOffs[i]); cutoffs[i] = CutOffState(plate.CutOffs[i]);
} }
@@ -36,11 +41,12 @@ public sealed class OverlapGeometryStamp
zone?.Bounds is not { } box ? default zone?.Bounds is not { } box ? default
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width)); : (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) => private static (CutOff, CutOffAxis, long, long, long?, long?, IReadOnlyList<CutOffGap>, long) CutOffState(CutOff cut) =>
cut == null ? default cut == null ? default
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y), : (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null, cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null); cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null,
cut.Gaps, Bits(cut.HeadLockOvercut));
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
@@ -73,9 +79,13 @@ public sealed class OverlapGeometryStamp
return true; return true;
} }
internal bool SameSpacing(Plate current) =>
ReferenceEquals(plate, current) && partSpacing == Bits(current.PartSpacing);
public bool Matches(Plate current) public bool Matches(Plate current)
{ {
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current)) if (current.Parts.Count != entries.Length || partSpacing != Bits(current.PartSpacing)
|| !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]))
+3 -3
View File
@@ -48,13 +48,13 @@ internal static class OverlapMarker
point = default; point = default;
var minY = outline.Min(vertex => vertex.Y); var minY = outline.Min(vertex => vertex.Y);
var maxY = outline.Max(vertex => vertex.Y); var maxY = outline.Max(vertex => vertex.Y);
var centre = (minY + maxY) / 2; var center = (minY + maxY) / 2;
double below = minY, above = maxY; double below = minY, above = maxY;
foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole))) foreach (var vertex in outline.Concat(holes.SelectMany(hole => hole)))
{ {
if (vertex.Y <= centre && vertex.Y > below) if (vertex.Y <= center && vertex.Y > below)
below = vertex.Y; below = vertex.Y;
else if (vertex.Y > centre && vertex.Y < above) else if (vertex.Y > center && vertex.Y < above)
above = vertex.Y; above = vertex.Y;
} }
var y = (below + above) / 2; var y = (below + above) / 2;
+74 -7
View File
@@ -31,11 +31,17 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
} }
// Sampling can hide a crossing or tangency between curves. Reject native // Sampling can hide a crossing or tangency between curves. Reject native
// contour contact before asking the polygon approximation about containment. // contour contact before asking the polygon approximation about containment.
// A contact lies within both entities' bounds, so shapes whose bounds are clear by more
// than any native query's allowance cannot touch: a part with thousands of holes does
// not query every pair.
var boxes = shapes.Select(shape => shape.BoundingBox).ToList();
for (var i = 0; i < shapes.Count; i++) for (var i = 0; i < shapes.Count; i++)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
for (var j = 0; j < i; j++) for (var j = 0; j < i; j++)
{ {
if (ClearOfNativeQueries(boxes[i], boxes[j]))
continue;
shapes[i].Intersects(shapes[j], out var intersections); shapes[i].Intersects(shapes[j], out var intersections);
if (intersections.Count > 0) if (intersections.Count > 0)
throw new ArgumentException("Native material contours cross or touch."); throw new ArgumentException("Native material contours cross or touch.");
@@ -44,23 +50,70 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList(); var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
var outer = ordered[0]; var outer = ordered[0];
var holes = ordered.Skip(1).ToList(); var holes = ordered.Skip(1).ToList();
var outerBounds = BoundsOf(outer);
var holeBounds = holes.Select(BoundsOf).ToList();
for (var i = 0; i < holes.Count; i++) for (var i = 0; i < holes.Count; i++)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (BoundariesTouch(outer, holes[i], cancellationToken) if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken)
|| !Inside(outer, holes[i].Vertices[0])) || !Inside(outer, outerBounds, holes[i].Vertices[0]))
throw new ArgumentException("Contours must have one outer with strictly internal holes."); throw new ArgumentException("Contours must have one outer with strictly internal holes.");
for (var j = 0; j < i; j++) for (var j = 0; j < i; j++)
{ {
if (BoundariesTouch(holes[i], holes[j], cancellationToken) // Exact polygon tests: holes whose vertex bounds are apart can neither touch
|| Inside(holes[i], holes[j].Vertices[0]) // nor contain each other's vertices.
|| Inside(holes[j], holes[i].Vertices[0])) if (!Overlap(holeBounds[i], holeBounds[j]))
continue;
if (BoundariesTouch(holes[i], holeBounds[i], holes[j], holeBounds[j], cancellationToken)
|| Inside(holes[i], holeBounds[i], holes[j].Vertices[0])
|| Inside(holes[j], holeBounds[j], holes[i].Vertices[0]))
throw new ArgumentException("Intersecting holes and nested material islands are unsupported."); throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
} }
} }
return new OverlapMaterial(outer, holes); return new OverlapMaterial(outer, holes);
} }
/// <summary>
/// The gap beyond which no native intersection query counts two entities as touching: ten
/// times the 0.00001 bounding-box allowance of native intersections, as the planner's
/// <c>PostVerificationGeometry.Extent.ClearMargin</c>.
/// </summary>
private const double NativeClearMargin = 1e-3;
// True only when both boxes are finite and apart by more than the native allowance on some axis.
private static bool ClearOfNativeQueries(Box a, Box b) =>
IsFinite(a) && IsFinite(b)
&& (a.Right + NativeClearMargin < b.Left || b.Right + NativeClearMargin < a.Left
|| a.Top + NativeClearMargin < b.Bottom || b.Top + NativeClearMargin < a.Bottom);
private static bool IsFinite(Box box) => double.IsFinite(box.Left) && double.IsFinite(box.Right)
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top);
/// <summary>Exact vertex bounds of a sampled polygon, for skipping segment and winding tests.</summary>
private readonly record struct Bounds(double MinX, double MinY, double MaxX, double MaxY)
{
internal bool Contains(Vector point) =>
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
}
private static Bounds BoundsOf(Polygon polygon)
{
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
foreach (var vertex in polygon.Vertices)
{
minX = System.Math.Min(minX, vertex.X);
minY = System.Math.Min(minY, vertex.Y);
maxX = System.Math.Max(maxX, vertex.X);
maxY = System.Math.Max(maxY, vertex.Y);
}
return new(minX, minY, maxX, maxY);
}
// Closed-interval overlap: two segments that touch share a point inside both bounds.
private static bool Overlap(Bounds a, Bounds b) =>
a.MaxX >= b.MinX && b.MaxX >= a.MinX && a.MaxY >= b.MinY && b.MaxY >= a.MinY;
internal OverlapMaterial Transform(double rotation, Vector offset) => internal OverlapMaterial Transform(double rotation, Vector offset) =>
new(TransformPolygon(Outer, rotation, offset), new(TransformPolygon(Outer, rotation, offset),
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList()); Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
@@ -171,13 +224,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
throw new ArgumentException("Material contour could not be completely triangulated."); throw new ArgumentException("Material contour could not be completely triangulated.");
} }
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken) // Exact: a segment of a whose bounds are clear of b's bounds touches no segment of b.
private static bool BoundariesTouch(Polygon a, Bounds aBounds, Polygon b, Bounds bBounds,
CancellationToken cancellationToken)
{ {
if (!Overlap(aBounds, bBounds))
return false;
for (var i = 0; i + 1 < a.Vertices.Count; i++) for (var i = 0; i + 1 < a.Vertices.Count; i++)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var start = a.Vertices[i];
var end = a.Vertices[i + 1];
var segment = new Bounds(System.Math.Min(start.X, end.X), System.Math.Min(start.Y, end.Y),
System.Math.Max(start.X, end.X), System.Math.Max(start.Y, end.Y));
if (!Overlap(segment, bBounds))
continue;
for (var j = 0; j + 1 < b.Vertices.Count; j++) for (var j = 0; j + 1 < b.Vertices.Count; j++)
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1])) if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1]))
return true; return true;
} }
return false; return false;
@@ -200,6 +263,10 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y); && point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
// Boundary contact is rejected before this winding-number test is used for topology. // Boundary contact is rejected before this winding-number test is used for topology.
// A point outside the polygon's vertex bounds has winding zero.
private static bool Inside(Polygon polygon, Bounds bounds, Vector point) =>
bounds.Contains(point) && Inside(polygon, point);
private static bool Inside(Polygon polygon, Vector point) private static bool Inside(Polygon polygon, Vector point)
{ {
var winding = 0; var winding = 0;
@@ -26,6 +26,17 @@ public static class OverlapPairPresentation
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}"; + $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
} }
public static string Label(PlateSpacingPair pair, IFormatProvider provider = null) =>
$"{pair.PartAId + 1}/{pair.PartBId + 1} gap {Number(pair.Gap, provider)}";
public static string Details(PlateSpacingPair pair, Units capturedUnits, IFormatProvider provider = null)
{
var units = UnitsHelper.GetShortString(capturedUnits);
return $"Part spacing {pair.PartAId + 1}/{pair.PartBId + 1}: {pair.PartAName} / {pair.PartBName}\n"
+ $"Gap ≈ {Number(pair.Gap, provider)} {units}\n"
+ $"Marker: ({Number(pair.Marker.X, provider)}, {Number(pair.Marker.Y, provider)}) {units}";
}
// Significant figures adapt to scale: a positive sliver must never read as zero. // Significant figures adapt to scale: a positive sliver must never read as zero.
public static string Number(double value, IFormatProvider provider = null) => public static string Number(double value, IFormatProvider provider = null) =>
value.ToString("G6", provider ?? CultureInfo.CurrentCulture); value.ToString("G6", provider ?? CultureInfo.CurrentCulture);
@@ -39,15 +50,21 @@ public static class OverlapPairPresentation
/// Coincident or nearby markers return every matching pair in stable ID order. /// Coincident or nearby markers return every matching pair in stable ID order.
/// </summary> /// </summary>
public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs, public static IReadOnlyList<PlateOverlapPair> HitTest(IEnumerable<PlateOverlapPair> pairs,
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi) Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi) =>
pairs.Where(pair => Hits(worldToScreen(pair.Marker), pointer, deviceDpi))
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
/// <inheritdoc cref="HitTest(IEnumerable{PlateOverlapPair}, Func{Vector, Vector}, Vector, int)"/>
public static IReadOnlyList<PlateSpacingPair> HitTest(IEnumerable<PlateSpacingPair> pairs,
Func<Vector, Vector> worldToScreen, Vector pointer, int deviceDpi) =>
pairs.Where(pair => Hits(worldToScreen(pair.Marker), pointer, deviceDpi))
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
private static bool Hits(Vector center, Vector pointer, int deviceDpi)
{ {
var radius = HitRadius(deviceDpi); var radius = HitRadius(deviceDpi);
return pairs.Where(pair => var dx = pointer.X - center.X;
{ var dy = pointer.Y - center.Y;
var center = worldToScreen(pair.Marker); return dx * dx + dy * dy <= radius * radius;
var dx = pointer.X - center.X;
var dy = pointer.Y - center.Y;
return dx * dx + dy * dy <= radius * radius;
}).OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToArray();
} }
} }
@@ -23,20 +23,31 @@ public sealed class OverlapReportState
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; } = public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
Array.Empty<CutOffClampFinding>(); Array.Empty<CutOffClampFinding>();
/// <summary>Known part-spacing pairs safe to draw, retained like <see cref="DisplayPairs"/>.</summary>
public IReadOnlyList<PlateSpacingPair> DisplaySpacingPairs { get; private set; } =
Array.Empty<PlateSpacingPair>();
public bool IsRunning => Status == OverlapCheckStatus.Checking; public bool IsRunning => Status == OverlapCheckStatus.Checking;
/// <summary>A current, fully checked report with no finding of any kind.</summary>
public bool IsAllClear => Status == OverlapCheckStatus.Current && Report.Pairs.Count == 0
&& Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
&& Report.SpacingPairs.Count == 0;
/// <summary>
/// The last published result's message, kept through layout edits and rechecks so the canvas
/// stays quiet until the next result; never a claim about the current layout. Null when none.
/// </summary>
public string SettledMessage { get; private set; }
/// <summary>Whether that last published result was all clear.</summary>
public bool SettledClear { get; private set; }
public string Message => Status switch public string Message => Status switch
{ {
OverlapCheckStatus.Checking => "Checking overlaps…", OverlapCheckStatus.Checking => "Checking overlaps…",
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0 OverlapCheckStatus.Current => CurrentMessage(),
? (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.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
+ (Report.CutOffClampFindings.Count == 0 ? "" + (Report.CutOffClampFindings.Count == 0 ? ""
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ") : $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
+ (Report.SpacingPairs.Count == 0 ? "" : $"{Report.SpacingPairs.Count} pairs under part spacing; ")
+ $"{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",
@@ -44,6 +55,50 @@ public sealed class OverlapReportState
_ => "Overlaps: not checked" _ => "Overlaps: not checked"
}; };
/// <summary>A few steady words for the status bar; <see cref="Message"/> has the detail.</summary>
public string Summary => Status switch
{
OverlapCheckStatus.Checking => "Layout: checking…",
OverlapCheckStatus.Current => IsAllClear ? "Layout: OK" : "Layout: " + Findings(),
OverlapCheckStatus.Incomplete => "Layout: check incomplete",
OverlapCheckStatus.Failed => "Layout: check failed",
OverlapCheckStatus.Canceled => "Layout: check canceled",
OverlapCheckStatus.Stale => "Layout: out of date",
_ => "Layout: not checked"
};
/// <summary>True when <see cref="Summary"/> reports findings or a check that did not finish.</summary>
public bool NeedsAttention => Status is OverlapCheckStatus.Incomplete or OverlapCheckStatus.Failed
|| Status == OverlapCheckStatus.Current && !IsAllClear;
private string Findings()
{
var overlaps = Report.Pairs.Count + Report.ClampFindings.Count + Report.CutOffClampFindings.Count;
var spacing = Report.SpacingPairs.Count;
var words = new List<string>();
if (overlaps != 0)
words.Add(overlaps == 1 ? "1 overlap" : $"{overlaps} overlaps");
if (spacing != 0)
words.Add($"{spacing} too close");
return string.Join(", ", words);
}
// Only a fully checked report that required a gap may clear spacing as well as overlaps.
private string CurrentMessage()
{
var spacing = Report.SpacingPairs.Count == 0 ? ""
: $"; part spacing: {Report.SpacingPairs.Count} pairs too close";
if (Report.ClampFindings.Count != 0 || Report.CutOffClampFindings.Count != 0)
return $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts{spacing}";
if (Report.Pairs.Count != 0)
return $"Overlaps: {Report.Pairs.Count} pairs{spacing}";
if (spacing.Length != 0)
return "No material overlaps" + spacing;
return Report.SpacingChecked ? "No material overlaps or part spacing violations detected"
: "No material overlaps detected";
}
/// <summary> /// <summary>
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps /// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
/// the user's display choice, including Off. /// the user's display choice, including Off.
@@ -66,10 +121,13 @@ public sealed class OverlapReportState
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly(); : report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
DisplayCutOffClampFindings = report.CutOffClampFindings; DisplayCutOffClampFindings = report.CutOffClampFindings;
DisplaySpacingPairs = report.SpacingPairs;
displayStamp = stamp; displayStamp = stamp;
observedDisplayStamp = stamp; observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues); uncheckedPartCount = CountUncheckedParts(report.Issues.Concat(report.SpacingIssues));
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete; Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
SettledMessage = Message;
SettledClear = IsAllClear;
return true; return true;
} }
@@ -123,7 +181,12 @@ public sealed class OverlapReportState
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList(); && (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 && DisplayCutOffClampFindings.Count == 0) var sameSpacing = displayStamp.SameSpacing(plate);
var retainedSpacing = DisplaySpacingPairs.Where(pair => sameSpacing
&& unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
if (retainedSpacing.Count != DisplaySpacingPairs.Count)
DisplaySpacingPairs = retainedSpacing.AsReadOnly();
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0 && DisplaySpacingPairs.Count == 0)
ClearDisplayPairs(); ClearDisplayPairs();
else else
observedDisplayStamp = OverlapGeometryStamp.Capture(plate); observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
@@ -133,6 +196,7 @@ public sealed class OverlapReportState
{ {
DisplayPairs = Array.Empty<PlateOverlapPair>(); DisplayPairs = Array.Empty<PlateOverlapPair>();
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>(); DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
DisplaySpacingPairs = Array.Empty<PlateSpacingPair>();
displayStamp = null; displayStamp = null;
observedDisplayStamp = null; observedDisplayStamp = null;
} }
@@ -148,7 +212,11 @@ public sealed class OverlapReportState
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false) private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
{ {
if (!preserveDisplay) if (!preserveDisplay)
{
ClearDisplayPairs(); ClearDisplayPairs();
SettledMessage = null;
SettledClear = false;
}
Generation++; Generation++;
Report = null; Report = null;
uncheckedPartCount = 0; uncheckedPartCount = 0;
+105 -107
View File
@@ -15,19 +15,24 @@ namespace OpenNest.Diagnostics;
/// Nest's layout check finds them: each part's clean nesting material /// Nest's layout check finds them: each part's clean nesting material
/// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>, /// (<see cref="NestingMaterial.ForNesting"/>, leads excluded) outlined by <see cref="SpacingOutline"/>,
/// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline /// the leftmost of a pair inflated by <see cref="SpacingOutline.InflationFor"/>. That outline
/// circumscribes arcs by up to its chord tolerance, more than the slack, and engines add a margin /// circumscribes arcs by up to its chord tolerance, more than the slack, and interactive fills
/// for it; interactive fills place native arcs exactly at the spacing. So each candidate is /// place native arcs exactly at the spacing. So each candidate is confirmed on
/// confirmed on the native contours flattened at <see cref="FineTolerance"/> and reported only when /// <see cref="NativeContours"/>, as the layout check confirms it, and reported only when that gap
/// that gap is short of the spacing by more than the slack. Zero spacing enforces nothing, so /// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays
/// contact stays valid. /// valid.
/// Pairs whose material already overlaps, and parts the overlap check could not read, are left to /// Pairs whose material already overlaps, and parts the overlap check could not read, are left to
/// that check. The sheet edge and clamps are not spacing-checked here. /// that check. The sheet edge and clamps are not spacing-checked here. The pre-post review and the
/// desktop overlap label share this scan, so they cannot disagree.
/// </summary> /// </summary>
internal static class PartSpacingReview internal static class PartSpacingReview
{ {
/// <summary>Chord tolerance of the confirming measurement. Circumscribed on both sides, it /// <summary>One part to scan: <see cref="Id"/> is its position in the overlap check's input.
/// understates a gap by at most twice this, a tenth of <see cref="SpacingOutline.SpacingSlack"/>.</summary> /// The profile loads inside the scan's error boundary; null means no material.</summary>
internal const double FineTolerance = SpacingOutline.SpacingSlack / 20; internal sealed record SpacingInput(int Id, Func<ShapeProfile> Profile, Vector Location);
/// <summary>True when <paramref name="spacing"/> requires any gap at all.</summary>
internal static bool Enforces(double spacing) =>
double.IsFinite(spacing) && SpacingOutline.InflationFor(spacing) > Tolerance.Epsilon;
internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts, internal static void Add(Plate plate, int plateNumber, IReadOnlyList<Part> parts,
IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings, IReadOnlyList<int> partNumbers, PlateOverlapReport overlap, List<PostVerificationFinding> findings,
@@ -40,39 +45,89 @@ internal static class PartSpacingReview
$"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value.")); $"Part spacing check: plate part spacing {spacing} is not a finite, non-negative value."));
return; return;
} }
var profiles = new Dictionary<(Program, long), ShapeProfile>();
var inputs = parts.Select((part, id) => new SpacingInput(id, () => Profile(part, profiles), part.Location))
.ToList();
Scan(inputs, spacing, overlap.Pairs, overlap.Issues,
(a, b, message) => findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[a],
b is { } other ? partNumbers[other] : null, "Part spacing check: " + message)),
(a, b, gap, marker) => findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[a],
partNumbers[b],
$"'{Name(parts[a])}' and '{Name(parts[b])}' are {gap:F3} apart, closer than the "
+ $"plate's part spacing ({spacing:F3}).")
{ Location = marker }),
cancellationToken);
}
/// <summary>
/// The desktop label's check: the same scan over a snapshot's owned material, so it runs on
/// the overlap worker and never reads a live part. Pairs and issues keep input ids.
/// </summary>
internal static void Add(PlateOverlapSnapshot snapshot, IReadOnlyList<PlateOverlapPair> overlaps,
IReadOnlyList<PlateOverlapIssue> overlapIssues, List<PlateSpacingPair> pairs,
List<PlateOverlapIssue> issues, CancellationToken cancellationToken)
{
var spacing = snapshot.PartSpacing;
if (!Enforces(spacing))
return;
var names = snapshot.Parts.ToDictionary(part => part.Id, part => part.Name);
var profiles = new Dictionary<(OverlapSource, long), ShapeProfile>();
var inputs = snapshot.Parts
.Select(part => new SpacingInput(part.Id, () => Profile(part, profiles), part.Location)).ToList();
Scan(inputs, spacing, overlaps, overlapIssues,
(a, b, message) => issues.Add(new PlateOverlapIssue(a, b, message)),
(a, b, gap, marker) => pairs.Add(new PlateSpacingPair(a, b, names[a], names[b], gap, marker)),
cancellationToken);
}
/// <summary>
/// Reports each pair by input id, lower id first. <paramref name="incomplete"/> receives a
/// part or pair that could not be read; <paramref name="tooClose"/> a confirmed gap and its marker.
/// </summary>
private static void Scan(IReadOnlyList<SpacingInput> parts, double spacing,
IEnumerable<PlateOverlapPair> overlaps, IEnumerable<PlateOverlapIssue> overlapIssues,
Action<int, int?, string> incomplete, Action<int, int, double, Vector> tooClose,
CancellationToken cancellationToken)
{
var inflateBy = SpacingOutline.InflationFor(spacing); var inflateBy = SpacingOutline.InflationFor(spacing);
if (inflateBy <= Tolerance.Epsilon || parts.Count < 2) if (inflateBy <= Tolerance.Epsilon || parts.Count < 2)
return; return;
var unreadable = overlap.Issues.Where(issue => issue.PartBId == null) var unreadable = overlapIssues.Where(issue => issue.PartBId == null)
.Select(issue => issue.PartAId).ToHashSet(); .Select(issue => issue.PartAId).ToHashSet();
var overlapping = overlap.Pairs.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet(); var overlapping = overlaps.Select(pair => Key(pair.PartAId, pair.PartBId)).ToHashSet();
var profiles = new Dictionary<(Program, long), ShapeProfile>(); var outlines = new Dictionary<ShapeProfile, (SpacingOutline Raw, SpacingOutline Inflated)>();
var shapes = new ShapeProfile[parts.Count]; var shapes = new ShapeProfile[parts.Count];
var fine = new FineRings[parts.Count]; var fine = new NativeContours[parts.Count];
var raw = new SpacingOutline[parts.Count]; var raw = new SpacingOutline[parts.Count];
var inflated = new SpacingOutline[parts.Count]; var inflated = new SpacingOutline[parts.Count];
for (var i = 0; i < parts.Count; i++) for (var i = 0; i < parts.Count; i++)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (unreadable.Contains(i)) if (unreadable.Contains(parts[i].Id))
continue; continue;
try try
{ {
var profile = Profile(parts[i], profiles); var profile = parts[i].Profile();
if (profile == null) if (profile == null)
continue; continue;
shapes[i] = profile; shapes[i] = profile;
raw[i] = SpacingOutline.Build(profile, parts[i].Location, 0); // Repeated parts share a profile, so each shape is offset once.
inflated[i] = SpacingOutline.Build(profile, parts[i].Location, inflateBy); if (!outlines.TryGetValue(profile, out var outline))
if (raw[i] == null || inflated[i] == null) {
outline = (SpacingOutline.Build(profile, Vector.Zero, 0),
SpacingOutline.Build(profile, Vector.Zero, inflateBy));
outlines[profile] = outline;
}
if (outline.Raw == null || outline.Inflated == null)
throw new ArgumentException("Part material has no closed perimeter."); throw new ArgumentException("Part material has no closed perimeter.");
raw[i] = outline.Raw.MovedTo(parts[i].Location);
inflated[i] = outline.Inflated.MovedTo(parts[i].Location);
} }
catch (Exception exception) when (IsInvalid(exception)) catch (Exception exception) when (IsInvalid(exception))
{ {
raw[i] = inflated[i] = null; raw[i] = inflated[i] = null;
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumbers[i], null, incomplete(parts[i].Id, null, exception.Message);
"Part spacing check: " + exception.Message));
} }
} }
@@ -92,33 +147,25 @@ internal static class PartSpacingReview
// Sorted by Left, so nothing further along can reach part i either. // Sorted by Left, so nothing further along can reach part i either.
if (other.Left > reach.Right + Tolerance.Epsilon) if (other.Left > reach.Right + Tolerance.Epsilon)
break; break;
if (!BoxesTouch(reach, other) || overlapping.Contains(Key(i, j)) var (first, second) = Key(parts[i].Id, parts[j].Id);
if (!BoxesTouch(reach, other) || overlapping.Contains((first, second))
|| !inflated[i].Overlaps(raw[j])) || !inflated[i].Overlaps(raw[j]))
continue; continue;
double gap; double gap;
Vector marker; Vector marker;
try try
{ {
fine[i] ??= FineRings.Of(shapes[i], parts[i].Location); fine[i] ??= NativeContours.Of(shapes[i], parts[i].Location);
fine[j] ??= FineRings.Of(shapes[j], parts[j].Location); fine[j] ??= NativeContours.Of(shapes[j], parts[j].Location);
gap = Gap(fine[i], fine[j], spacing, out marker); gap = fine[i].GapTo(fine[j], spacing, out marker);
} }
catch (Exception exception) when (IsInvalid(exception)) catch (Exception exception) when (IsInvalid(exception))
{ {
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, incomplete(first, second, exception.Message);
System.Math.Min(partNumbers[i], partNumbers[j]),
System.Math.Max(partNumbers[i], partNumbers[j]),
"Part spacing check: " + exception.Message));
continue; continue;
} }
if (!(gap < spacing - SpacingOutline.SpacingSlack)) if (gap < spacing - SpacingOutline.SpacingSlack)
continue; tooClose(first, second, gap, marker);
var (first, second) = partNumbers[i] < partNumbers[j] ? (i, j) : (j, i);
findings.Add(new(PostVerificationKind.PartSpacing, plateNumber, partNumbers[first],
partNumbers[second],
$"'{Name(parts[first])}' and '{Name(parts[second])}' are {gap:F3} apart, closer than the "
+ $"plate's part spacing ({spacing:F3}).")
{ Location = marker });
} }
} }
} }
@@ -135,86 +182,37 @@ internal static class PartSpacingReview
if (profiles.TryGetValue(key, out var cached)) if (profiles.TryGetValue(key, out var cached))
return cached; return cached;
// Conversion creates fresh geometry, so rotating it never touches the live program. // Conversion creates fresh geometry, so rotating it never touches the live program.
var entities = ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program)) var profile = Rotated(ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
.Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout) .Where(entity => entity.Layer != SpecialLayers.Leadin && entity.Layer != SpecialLayers.Leadout),
.ForNesting(); rotation);
profiles[key] = profile;
return profile;
}
/// <summary>A snapshot source's material at a part's pose, from clones: sources are shared.</summary>
private static ShapeProfile Profile(CapturedOverlapPart part,
Dictionary<(OverlapSource, long), ShapeProfile> profiles)
{
var key = (part.Source, BitConverter.DoubleToInt64Bits(part.Rotation));
if (profiles.TryGetValue(key, out var cached))
return cached;
var profile = Rotated(part.Source.Entities.Select(entity => entity.Clone()), part.Rotation);
profiles[key] = profile;
return profile;
}
private static ShapeProfile Rotated(IEnumerable<Entity> owned, double rotation)
{
var entities = owned.ForNesting();
if (rotation != 0) if (rotation != 0)
foreach (var entity in entities) foreach (var entity in entities)
entity.Rotate(rotation); entity.Rotate(rotation);
var profile = entities.Count == 0 ? null : new ShapeProfile(entities); var profile = entities.Count == 0 ? null : new ShapeProfile(entities);
if (profile != null && profile.Perimeter == null) if (profile != null && profile.Perimeter == null)
throw new ArgumentException("Part material has no closed perimeter."); throw new ArgumentException("Part material has no closed perimeter.");
profiles[key] = profile;
return profile; return profile;
} }
/// <summary>Closest boundary approach between two parts, including a part inside the other's
/// cutout; <paramref name="marker"/> is the middle of that gap. Only edges within
/// <paramref name="reach"/> of the other part's bounds can be closer than it, so the rest are
/// skipped. Infinity when no edges are that near.</summary>
private static double Gap(FineRings a, FineRings b, double reach, out Vector marker)
{
var best = double.PositiveInfinity;
var middle = a.Bounds.Center;
var perimeterA = Near(a.Perimeter, b.Bounds, reach);
var perimeterB = Near(b.Perimeter, a.Bounds, reach);
Measure(perimeterA, perimeterB);
foreach (var hole in a.Holes)
Measure(Near(hole, b.Bounds, reach), perimeterB);
foreach (var hole in b.Holes)
Measure(perimeterA, Near(hole, a.Bounds, reach));
marker = middle;
return best;
void Measure(List<Line> ringA, List<Line> ringB)
{
if (ringA.Count == 0 || ringB.Count == 0)
return;
var distance = Clearance.ClosestPoints(ringA, ringB, out var onA, out var onB);
if (distance < best)
{
best = distance;
middle = (onA + onB) * 0.5;
}
}
}
private static List<Line> Near(List<Line> lines, Box other, double reach) =>
lines.Where(line =>
System.Math.Min(line.StartPoint.X, line.EndPoint.X) <= other.Right + reach
&& System.Math.Max(line.StartPoint.X, line.EndPoint.X) >= other.Left - reach
&& System.Math.Min(line.StartPoint.Y, line.EndPoint.Y) <= other.Top + reach
&& System.Math.Max(line.StartPoint.Y, line.EndPoint.Y) >= other.Bottom - reach).ToList();
/// <summary>A part's native contours in world space, flattened at <see cref="FineTolerance"/>.
/// Circumscribed, so the measured gap never exceeds the true one by more than rounding.</summary>
private sealed class FineRings
{
public List<Line> Perimeter { get; private init; }
public List<List<Line>> Holes { get; private init; }
public Box Bounds { get; private init; }
public static FineRings Of(ShapeProfile profile, Vector location)
{
var perimeter = World(profile.Perimeter, location, out var bounds);
return new FineRings
{
Perimeter = perimeter.ToLines(),
Holes = profile.Cutouts.Select(cutout => World(cutout, location, out _).ToLines()).ToList(),
Bounds = bounds,
};
}
private static Polygon World(Shape shape, Vector location, out Box bounds)
{
var polygon = ClipperBridge.Flatten(shape, FineTolerance, true);
polygon.Offset(location);
polygon.UpdateBounds();
bounds = polygon.BoundingBox;
return polygon;
}
}
private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a); private static (int, int) Key(int a, int b) => a < b ? (a, b) : (b, a);
private static string Name(Part part) => part.BaseDrawing.Name; private static string Name(Part part) => part.BaseDrawing.Name;
@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
using OpenNest.Support;
namespace OpenNest.Diagnostics;
/// <summary>
/// Pre-post review of part support (docs/support-tabs.md). Every part whose cutting parameters
/// ask for support tabs and that needs one (<see cref="SupportTabSettings.Analyze"/>: its plate's
/// support bed cannot hold it level or, without a bed, its width is in the no-bed range) must be
/// cut with a tab that keeps it level, however its program was made (planned, placed by hand or
/// locked); without a bed any tab will do. The tab is the gap the outside cut leaves between its end and its start. Reports
/// only; a part whose material or program cannot be read is reported as not checked.
/// </summary>
internal static class PartSupportReview
{
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> findings,
CancellationToken token)
{
var bed = plate.SupportBed;
for (var index = 0; index < plate.Parts.Count; index++)
{
token.ThrowIfCancellationRequested();
var part = plate.Parts[index];
var parameters = part?.CuttingParameters ?? plate.CuttingParameters;
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff || parameters?.SupportTabs != true
|| (bed == null && !parameters.HasNoBedTabRange()))
continue;
var partNumber = index + 1;
try
{
var support = SupportTabSettings.Analyze(parameters, bed, plate.Quadrant, SupportMaterial.FromPart(part));
if (support == null)
continue;
if (!support.IsComplete)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
"Support check: " + support.Reason));
continue;
}
if (support.IsSupported)
continue;
var tab = OutsideTab(ExecutionMotionReader.Read(part.Program, part.Location, null, token));
if (tab is { } point && support.IsSteadiedByTabAt(point))
continue;
findings.Add(new(PostVerificationKind.PartSupport, plateNumber, partNumber, null,
Message(support, tab))
{ Location = support.CenterOfGravity });
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null,
"Support check: " + exception.Message));
}
}
}
/// <summary>
/// The middle of the gap the outside cut leaves open, or null when it closes: the outside cut
/// is the chain of cutting motions with the largest extent.
/// </summary>
internal static Vector? OutsideTab(OwnedExecution execution)
{
Vector? start = null, end = null, bestStart = null, bestEnd = null;
var minX = double.PositiveInfinity; var minY = double.PositiveInfinity;
var maxX = double.NegativeInfinity; var maxY = double.NegativeInfinity;
var bestArea = -1.0;
foreach (var motion in execution.Motions.Append(null))
{
if (motion is { Rapid: false, Layer: LayerType.Cut or LayerType.Display })
{
start ??= motion.Start ?? motion.End;
end = motion.End;
foreach (var p in new[] { motion.Start ?? motion.End, motion.End, motion.Curve?.Midpoint ?? motion.End })
{
minX = System.Math.Min(minX, p.X); minY = System.Math.Min(minY, p.Y);
maxX = System.Math.Max(maxX, p.X); maxY = System.Math.Max(maxY, p.Y);
}
continue;
}
// Anything else ends a chain: a rapid, a lead, a scribe or a break cut.
if (start != null && (maxX - minX) * (maxY - minY) > bestArea)
{
bestArea = (maxX - minX) * (maxY - minY);
bestStart = start;
bestEnd = end;
}
start = end = null;
minX = minY = double.PositiveInfinity;
maxX = maxY = double.NegativeInfinity;
}
if (bestStart is not { } first || bestEnd is not { } last
|| first.DistanceTo(last) <= PostVerificationGeometry.Epsilon)
return null;
return (first + last) * 0.5;
}
private static string Message(PartSupport support, Vector? tab)
{
// Without a bed, or for a part its tab alone holds, any tab steadies the part, so the only
// failure is no tab at all.
if (!support.HasBed || support.IsHeldByTab)
return (support.HasBed
? FormattableString.Invariant(
$"The part is {support.Length:0.####} long, short enough for its tab alone to hold, ")
: FormattableString.Invariant(
$"The part is {support.Width:0.####} wide, in the width range tabbed without a support bed, "))
+ "and it has no tab; it may tip once cut free."
+ (support.BestTabPoint is { } suggested
? FormattableString.Invariant($" Tab it near ({suggested.X:0.####}, {suggested.Y:0.####}).")
: "");
var held = support.Margin == double.NegativeInfinity
? "rests on no support contact"
: support.Margin < 0
? FormattableString.Invariant($"has its center of gravity {-support.Margin:0.####} outside its support contacts")
: FormattableString.Invariant($"has its center of gravity only {support.Margin:0.####} inside its support contacts");
var tabText = tab is { } point
? FormattableString.Invariant($"its tab at ({point.X:0.####}, {point.Y:0.####}) does not steady it")
: "it has no tab";
var best = support.BestTabPoint is { } bestPoint && support.TabCanSupport
? FormattableString.Invariant($" A tab near ({bestPoint.X:0.####}, {bestPoint.Y:0.####}) would.")
: " No single tab can steady it.";
return FormattableString.Invariant($"The part {held} (needs {support.RequiredMargin:0.####}) and {tabText}; ")
+ "it may tip once cut free." + best;
}
}
@@ -18,7 +18,7 @@ public static class PlateOverlapAnalyzer
public const double ChordTolerance = 0.001; public const double ChordTolerance = 0.001;
/// <summary> /// <summary>
/// Capture a whole plate including active clamps and nominal cut-off definitions. /// Capture a whole plate including active clamps, nominal cut-off definitions and part spacing.
/// Use this entry for the desktop overlap check so manual cut-off contacts /// 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. /// 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 /// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
@@ -33,7 +33,7 @@ public static class PlateOverlapAnalyzer
var cutoffs = ClampCutDiagnostics.Find(plate); var cutoffs = ClampCutDiagnostics.Find(plate);
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(), return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
material.Clamps.ToList(), cutoffs.ToList()); material.Clamps.ToList(), cutoffs.ToList(), plate.PartSpacing);
} }
/// <summary> /// <summary>
@@ -223,11 +223,16 @@ public static class PlateOverlapAnalyzer
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken); var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
var spacingPairs = new List<PlateSpacingPair>();
var spacingIssues = new List<PlateOverlapIssue>();
PartSpacingReview.Add(snapshot, pairs, issues, spacingPairs, spacingIssues, 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, clampFindings .ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(), .OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
snapshot.CutOffClampFindings.ToList()); snapshot.CutOffClampFindings.ToList(), spacingPairs.OrderBy(pair => pair.PartAId)
.ThenBy(pair => pair.PartBId).ToList(), spacingIssues.OrderBy(issue => issue.PartAId)
.ThenBy(issue => issue.PartBId).ToList());
} }
/// <summary> /// <summary>
@@ -348,10 +353,10 @@ public static class PlateOverlapAnalyzer
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top), new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly. new Vector(zone.Left, zone.Bottom), // Regions repeat their closing vertex exactly.
}; };
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2); var center = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name, 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, $"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, center,
centre); center);
} }
private static Polygon RectanglePolygon(Box box, Vector offset) private static Polygon RectanglePolygon(Box box, Vector offset)
@@ -9,13 +9,16 @@ public sealed class PlateOverlapReport
{ {
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues, internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null, PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
List<CutOffClampFinding> cutOffClampFindings = null) List<CutOffClampFinding> cutOffClampFindings = null, List<PlateSpacingPair> spacingPairs = null,
List<PlateOverlapIssue> spacingIssues = null)
{ {
Pairs = pairs.AsReadOnly(); Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly(); Issues = issues.AsReadOnly();
Snapshot = snapshot; Snapshot = snapshot;
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly(); ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly(); CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
SpacingPairs = (spacingPairs ?? new List<PlateSpacingPair>()).AsReadOnly();
SpacingIssues = (spacingIssues ?? new List<PlateOverlapIssue>()).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>
@@ -33,7 +36,22 @@ public sealed class PlateOverlapReport
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary> /// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; } public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
public bool IsComplete => Issues.Count == 0; /// <summary>
/// Pairs that share no material but sit closer than the plate's part spacing. Only a plate
/// capture carries the spacing; see <see cref="SpacingChecked"/>.
/// </summary>
public IReadOnlyList<PlateSpacingPair> SpacingPairs { get; }
/// <summary>
/// Parts or pairs the spacing scan could not read. Kept apart from <see cref="Issues"/>,
/// which incremental rechecks reuse by overlap pair.
/// </summary>
public IReadOnlyList<PlateOverlapIssue> SpacingIssues { get; }
/// <summary>True when the input required a gap, so empty SpacingPairs means something.</summary>
public bool SpacingChecked => PartSpacingReview.Enforces(Snapshot.PartSpacing);
public bool IsComplete => Issues.Count == 0 && SpacingIssues.Count == 0;
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance; public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
} }
@@ -95,7 +113,7 @@ public sealed class PlateOverlapPair
/// its pole of inaccessibility, or an interior scan-line point when a thin island defeats /// its pole of inaccessibility, or an interior scan-line point when a thin island defeats
/// the bounded pole search. Markers, hit testing and inspection rings use this point. /// the bounded pole search. Markers, hit testing and inspection rings use this point.
/// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps /// A tolerance-free tiny-clamp finding (a clamp narrower than the chord tolerance) keeps
/// its conservative whole-clamp region, so its marker is the clamp's centre, within half /// its conservative whole-clamp region, so its marker is the clamp's center, within half
/// the clamp's size of the material. Returned by value. /// the clamp's size of the material. Returned by value.
/// </summary> /// </summary>
public Vector Marker { get; } public Vector Marker { get; }
@@ -103,6 +121,13 @@ public sealed class PlateOverlapPair
public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width); public Box Bounds => new(bounds.X, bounds.Y, bounds.Length, bounds.Width);
} }
/// <summary>
/// Two parts closer than the plate's part spacing, ordered by zero-based input index.
/// <see cref="Marker"/> is the middle of the narrowest gap, in world coordinates.
/// </summary>
public sealed record PlateSpacingPair(int PartAId, int PartBId, string PartAName, string PartBName,
double Gap, Vector Marker);
/// <summary> /// <summary>
/// One connected island of shared material in world coordinates, after hole subtraction: /// One connected island of shared material in world coordinates, after hole subtraction:
/// a counterclockwise outline and its clockwise holes. /// a counterclockwise outline and its clockwise holes.
@@ -144,12 +169,14 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
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) List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null,
double partSpacing = 0)
{ {
Parts = parts.AsReadOnly(); Parts = parts.AsReadOnly();
Issues = issues.AsReadOnly(); Issues = issues.AsReadOnly();
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly(); Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly(); CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
PartSpacing = partSpacing;
} }
internal IReadOnlyList<CapturedOverlapPart> Parts { get; } internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
@@ -158,6 +185,9 @@ public sealed class PlateOverlapSnapshot
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary> /// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
internal IReadOnlyList<CapturedClamp> Clamps { get; } internal IReadOnlyList<CapturedClamp> Clamps { get; }
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; } internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
/// <summary>The plate's part spacing; zero (no spacing check) for a bare group of parts.</summary>
internal double PartSpacing { get; }
} }
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source, internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
@@ -19,12 +19,15 @@ public static class PostVerificationAnalyzer
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var report = Analyze(nest, cancellationToken); var report = Analyze(nest, cancellationToken);
if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true }) var change = postProcessor is IPostVerificationSupport support
? support.PlacedProgramOrderChange(nest)
: "does not declare that it preserves placed part/contour order and pierce positions";
if (change == null)
return report; return report;
var findings = report.Findings.ToList(); var findings = report.Findings.ToList();
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null, findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
$"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " + $"Post '{postProcessor?.Name ?? "unknown"}' {change}. " +
"and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review.")); "Nest-level checks ran, but the final rapid sequence requires manual review."));
return new PostVerificationReport(findings); return new PostVerificationReport(findings);
} }
@@ -135,12 +138,16 @@ public static class PostVerificationAnalyzer
if (part == null || !double.IsFinite(part.Rotation)) if (part == null || !double.IsFinite(part.Rotation))
throw new ArgumentException("Missing part or invalid rotation."); throw new ArgumentException("Missing part or invalid rotation.");
var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions; var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions;
// Executed reads stop at an applied program's display tail, so any display
// perimeter left is an unplanned blank whose outline a post would refuse.
if (moves.Any(move => move.Layer == LayerType.DisplayPerimeter))
Incomplete("This part has a display perimeter (a supplied blank's outline that is never cut) "
+ "but no cutting plan; posts refuse it. Plan cutting first (Plate > Plan Cutting..., or --plan-cutting) so only its cutouts are cut.");
if (expectsCuts && !moves.Any(move => !move.Rapid if (expectsCuts && !moves.Any(move => !move.Rapid
&& move.Layer is LayerType.Cut or LayerType.Display && move.Layer is LayerType.Cut or LayerType.Display
&& 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);
proveOpenCuts: true);
if (clamps is { Count: > 0 }) if (clamps is { Count: > 0 })
foreach (var move in moves) foreach (var move in moves)
{ {
@@ -219,6 +226,8 @@ public static class PostVerificationAnalyzer
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message)); issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
// Parts that do not overlap may still be closer than the plate's part spacing. // Parts that do not overlap may still be closer than the plate's part spacing.
PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken); PartSpacingReview.Add(plate, plateNumber, materialParts, indices, overlap, findings, cancellationToken);
// Parts the support bed cannot hold level need a tab that keeps them level.
PartSupportReview.Add(plate, plateNumber, findings, cancellationToken);
} }
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
return new PostVerificationReport(findings); return new PostVerificationReport(findings);
@@ -49,7 +49,7 @@ internal static class PostVerificationGeometry
/// <summary> /// <summary>
/// The relative rounding allowance of native line/circle intersections. Their discriminant /// The relative rounding allowance of native line/circle intersections. Their discriminant
/// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times /// b^2 - 4ac cancels: its absolute error stays below about 30 units of roundoff times
/// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's centre to the line's start), so a /// |d1|^2 (|d2|^2 + r^2) (d1 the line, d2 from the circle's center to the line's start), so a
/// spurious root can lie at most about 1e-7 times the span of both extents beyond the true /// spurious root can lie at most about 1e-7 times the span of both extents beyond the true
/// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a /// distance between line and circle (about 2e-9 is seen in practice). A long lead beside a
/// small circle needs the full check; a short lead beside a nearby part does not. /// small circle needs the full check; a short lead beside a nearby part does not.
@@ -68,6 +68,10 @@ internal static class PostVerificationGeometry
return IsClearOf(other, ClearMargin + reach + SpanRounding * span); return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
} }
/// <summary>True when <paramref name="point"/> lies within the extent, its edges included.</summary>
internal bool Contains(Vector point) =>
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary> /// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin); && (y > MaxY + ClearMargin || y < MinY - ClearMargin);
@@ -78,7 +82,7 @@ internal static class PostVerificationGeometry
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds // Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
// (including the empty extent's) fail this test too. // (including the empty extent's) fail this test too.
private bool IsWellConditioned => internal bool IsWellConditioned =>
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)), System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit; System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
} }
@@ -110,17 +114,56 @@ internal static class PostVerificationGeometry
// If there are no contacts except possibly departure, all open-segment points // If there are no contacts except possibly departure, all open-segment points
// have the same inside/outside status. A midpoint catches travel entirely inside // have the same inside/outside status. A midpoint catches travel entirely inside
// and departure into the interior, without flagging start-only outward contact. // and departure into the interior, without flagging start-only outward contact.
var midpoint = start + delta * 0.5; return Inside(start + delta * 0.5, curves, token);
}
/// <summary>
/// Ray parity of <paramref name="point"/> against closed <paramref name="curves"/>, cast at
/// <see cref="RayY"/> so no vertex of them lies on the ray.
/// </summary>
internal static bool Inside(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
{
var rayY = RayY(point, curves, token);
var inside = false; var inside = false;
foreach (var curve in curves) foreach (var curve in curves)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (curve.CrossesRay(midpoint)) if (curve.CrossesRay(point, rayY))
inside = !inside; inside = !inside;
} }
return inside; return inside;
} }
/// <summary>
/// The height a parity ray from <paramref name="point"/> runs at: just above it, by about
/// <see cref="RaySeamTolerance"/>, and clear of every vertex height of <paramref name="curves"/>.
/// The two copies of a seam vertex can differ in the last bits; a ray running between them
/// counts one edge and not the other, so a point that happens to sit a tolerance below another
/// contour's vertex (text baselines a micro-inch apart) would read as inside it.
/// </summary>
internal static double RayY(Vector point, IReadOnlyList<Curve> curves, CancellationToken token)
{
const int steps = 8;
const double band = RaySeamTolerance / (4 * steps);
for (var step = 0; step < steps; step++)
{
var rayY = point.Y + RaySeamTolerance * (1 + (double)step / steps);
var clear = true;
foreach (var curve in curves)
{
token.ThrowIfCancellationRequested();
if (curve.HasVertexNear(rayY, band))
{
clear = false;
break;
}
}
if (clear)
return rayY;
}
return point.Y + RaySeamTolerance;
}
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end, internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
IReadOnlyList<Curve> curves, CancellationToken token) IReadOnlyList<Curve> curves, CancellationToken token)
{ {
@@ -279,6 +322,18 @@ internal static class PostVerificationGeometry
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary> /// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point); internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
/// <summary>The native point of this finite curve closest to <paramref name="point"/>.</summary>
internal Vector ClosestPoint(Vector point) => nativeEntity.Value.ClosestPointTo(point);
/// <summary>
/// This curve from its start up to <paramref name="point"/>, which must be one of its own
/// interior points (see <see cref="ClosestPoint"/>); an arc keeps its center and radius.
/// </summary>
internal Curve Until(Vector point) => Center is { } center
? new Curve(Start, point, center, Radius,
System.Math.Sign(Sweep) * Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)))
: new Curve(Start, point, null, 0, 0);
internal bool Contains(Vector point) => Contains(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> /// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
@@ -296,33 +351,53 @@ internal static class PostVerificationGeometry
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap) internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
{ {
var entity = nativeEntity.Value; var same = SameSupport(other);
var candidate = other.nativeEntity.Value; // Two curves that meet end to end share that vertex, and the native query is
// Native arc filters discard NaN supporting-circle intersections. Inspect // ill-conditioned there: circles meeting at a shallow angle, or a line meeting its own
// both unfiltered queries first: roundoff can differ by operand direction. // fillet, put their root up to 1e-5 off the joint, which no joint tolerance can tell
// Coincident supports have separate overlap handling below. // from a cut that touches beside it, and a fillet's circle tangent inside the arc it
if (Center is { } center && other.Center is { } otherCenter && !SameSupport(other)) // continues gives the supporting-circle query no root at all. Both shapes are known,
{ // so their other intersection, if there is one, follows from the shared vertex
var support = new Circle(center, Radius); // exactly instead, and no native query runs for the pair.
var otherSupport = new Circle(otherCenter, other.Radius); var joint = same ? null : SharedVertex(other);
support.Intersects(otherSupport, out var forward);
otherSupport.Intersects(support, out var reverse);
foreach (var point in forward)
Validate(point);
foreach (var point in reverse)
Validate(point);
}
List<Vector> points; List<Vector> points;
bool intersects; if (joint is { } vertex)
switch (candidate)
{ {
case Line line: intersects = entity.Intersects(line, out points); break; points = [vertex];
case Arc arc: intersects = entity.Intersects(arc, out points); break; if (SecondIntersection(other, vertex) is { } second && second.DistanceTo(vertex) > Epsilon
case Circle circle: intersects = entity.Intersects(circle, out points); break; && Contains(second) && other.Contains(second)
default: throw new NotSupportedException("Unsupported native boundary."); && !IsOutsideArcSpan(second) && !other.IsOutsideArcSpan(second))
points.Add(second);
}
else
{
var entity = nativeEntity.Value;
var candidate = other.nativeEntity.Value;
// Native arc filters discard NaN supporting-circle intersections. Inspect
// both unfiltered queries first: roundoff can differ by operand direction.
// Coincident supports have separate overlap handling below.
if (Center is { } center && other.Center is { } otherCenter && !same)
{
var support = new Circle(center, Radius);
var otherSupport = new Circle(otherCenter, other.Radius);
support.Intersects(otherSupport, out var forward);
otherSupport.Intersects(support, out var reverse);
foreach (var point in forward)
Validate(point);
foreach (var point in reverse)
Validate(point);
}
bool intersects;
switch (candidate)
{
case Line line: intersects = entity.Intersects(line, out points); break;
case Arc arc: intersects = entity.Intersects(arc, out points); break;
case Circle circle: intersects = entity.Intersects(circle, out points); break;
default: throw new NotSupportedException("Unsupported native boundary.");
}
if (!intersects)
points.Clear();
} }
if (!intersects)
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.
// Supports that agree only within Epsilon (arcs of one circle whose centers were // Supports that agree only within Epsilon (arcs of one circle whose centers were
@@ -331,24 +406,25 @@ internal static class PostVerificationGeometry
// still refuse, while arcs that only meet at a joint or lie apart are not overlap. // 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 var witness = Center is { } c && other.Center is { } oc
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon; && (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other, witness) overlap = same && (InteriorWitness(Midpoint, other, witness)
|| InteriorWitness(other.Midpoint, this, witness) || InteriorWitness(other.Midpoint, this, witness)
|| InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness) || InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
|| InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness)); || InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
if (SameSupport(other)) if (same)
points.Clear(); points.Clear();
foreach (var point in points) foreach (var point in points)
Validate(point); Validate(point);
var nativeContactCount = points.Count; // Analytic contacts of adjacent curves are certain; the ray guard below is for native queries.
var nativeContactCount = joint.HasValue ? int.MaxValue : points.Count;
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))
points.Add(point); points.Add(point);
// Existing exact line/ray contact semantics guard native queries which // Existing exact line/ray contact semantics guard native queries which
// suppress very short or nearly parallel intersections. Uncertainty refuses. // suppress very short or nearly parallel intersections. Uncertainty refuses.
if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other)) if (Center is null && !SameSupport(other) && nativeContactCount == 0 && ExactLineContact(this, other, points))
throw new NotSupportedException("Native contact query is numerically uncertain."); throw new NotSupportedException("Native contact query is numerically uncertain.");
if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this)) if (other.Center is null && Center.HasValue && nativeContactCount == 0 && ExactLineContact(other, this, points))
throw new NotSupportedException("Native contact query is numerically uncertain."); throw new NotSupportedException("Native contact query is numerically uncertain.");
return points; return points;
@@ -357,6 +433,49 @@ internal static class PostVerificationGeometry
|| (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance)); || (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
} }
/// <summary>
/// The end this curve and <paramref name="other"/> share, within <see cref="Epsilon"/>, as
/// this curve has it; null when they share none or either is a full circle.
/// </summary>
private Vector? SharedVertex(Curve other)
{
if (Center.HasValue && !IsFiniteArc || other.Center.HasValue && !other.IsFiniteArc)
return null;
foreach (var end in new[] { End, Start })
if (end.DistanceTo(other.Start) <= Epsilon || end.DistanceTo(other.End) <= Epsilon)
return end;
return null;
}
/// <summary>
/// Where the supports of this curve and <paramref name="other"/> meet again, given that
/// they meet at <paramref name="vertex"/>: two circles meet again at the vertex's mirror
/// image across the line through their centers, and a line meets a circle again at the
/// far end of its chord through the vertex. Two lines meet only once. The result is on
/// both supports; it may be the vertex itself (tangency) or off either finite span.
/// </summary>
private Vector? SecondIntersection(Curve other, Vector vertex)
{
if (Center is { } center && other.Center is { } otherCenter)
{
var axis = otherCenter - center;
var distance = otherCenter.DistanceTo(center);
if (distance <= Epsilon)
return null;
var unit = axis * (1 / distance);
var foot = center + unit * Dot(vertex - center, unit);
return foot * 2 - vertex;
}
if (Center is null && other.Center is null)
return null;
var line = Center is null ? this : other;
var circle = Center is null ? other.Center!.Value : Center!.Value;
if (line.Length <= Epsilon)
return null;
var direction = (line.End - line.Start) * (1 / line.Length);
return vertex + direction * (2 * Dot(circle - vertex, direction));
}
/// <summary> /// <summary>
/// Whether exact ray semantics find <paramref name="curve"/> touching <paramref name="line"/> /// Whether exact ray semantics find <paramref name="curve"/> touching <paramref name="line"/>
/// anywhere except at a line endpoint the curve contains, which is already a recorded contact. /// anywhere except at a line endpoint the curve contains, which is already a recorded contact.
@@ -364,16 +483,35 @@ internal static class PostVerificationGeometry
/// endpoint (both stop half-way when both endpoints are contained): a line meeting a /// endpoint (both stop half-way when both endpoints are contained): a line meeting a
/// tangent arc at their shared vertex is otherwise rediscovered there as a contact the /// tangent arc at their shared vertex is otherwise rediscovered there as a contact the
/// native query missed, because rounding can drop the tangent root of its quadratic. /// native query missed, because rounding can drop the tangent root of its quadratic.
/// For the same reason a ray hit within <see cref="RaySeamTolerance"/> of a
/// <paramref name="recorded"/> contact is that contact: an arc lead ending tangent on a
/// straight edge, mid-edge, has its end recorded and its double root dropped.
/// </summary> /// </summary>
private static bool ExactLineContact(Curve line, Curve curve) private static bool ExactLineContact(Curve line, Curve curve, IReadOnlyList<Vector> recorded)
{ {
var direction = (line.End - line.Start) * (1 / line.Length); var direction = (line.End - line.Start) * (1 / line.Length);
var startRecorded = curve.Contains(line.Start); var startRecorded = curve.Contains(line.Start);
var endRecorded = curve.Contains(line.End); var endRecorded = curve.Contains(line.End);
var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length; var forward = endRecorded ? startRecorded ? line.Length / 2 : 0 : line.Length;
var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length; var backward = startRecorded ? endRecorded ? line.Length / 2 : 0 : line.Length;
return (forward > 0 && curve.ContactAfterStart(line.Start, direction, forward)) return (forward > 0 && Unrecorded(line.Start, direction, forward))
|| (backward > 0 && curve.ContactAfterStart(line.End, direction * -1, backward)); || (backward > 0 && Unrecorded(line.End, direction * -1, backward));
bool Unrecorded(Vector origin, Vector ray, double length)
{
var hits = new List<double>();
curve.ContactAfterStart(origin, ray, length, hits);
foreach (var distance in hits)
{
var hit = origin + ray * distance;
var known = false;
foreach (var point in recorded)
known |= point.DistanceTo(hit) <= RaySeamTolerance;
if (!known)
return true;
}
return false;
}
} }
@@ -399,11 +537,11 @@ internal static class PostVerificationGeometry
point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius point.X - Center.Value.X)) <= System.Math.Abs(Sweep) + Epsilon / Radius
|| point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon; || point.DistanceTo(Start) <= Epsilon || point.DistanceTo(End) <= Epsilon;
// How far past r^2 a squared distance from the centre still counts as touching the arc. // How far past r^2 a squared distance from the center still counts as touching the arc.
// For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0). // For small arcs this band reaches well beyond the radius (sqrt(1e-8) = 1e-4 as r -> 0).
private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2); private double ContactSlack => Epsilon * System.Math.Max(1, Radius * 2);
// The largest distance from the centre that ContactAfterStart can count as contact. // The largest distance from the center that ContactAfterStart can count as contact.
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack); 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,
@@ -455,7 +593,20 @@ internal static class PostVerificationGeometry
} }
} }
internal bool CrossesRay(Vector point) /// <summary>
/// Whether an endpoint, or the top or bottom of an arc's circle, lies within
/// <paramref name="band"/> of height <paramref name="y"/>.
/// </summary>
internal bool HasVertexNear(double y, double band) =>
System.Math.Abs(Start.Y - y) <= band || System.Math.Abs(End.Y - y) <= band
|| Center is { } center && (System.Math.Abs(center.Y + Radius - y) <= band
|| System.Math.Abs(center.Y - Radius - y) <= band);
/// <summary>
/// Whether a ray running in +X from <paramref name="point"/>, at height
/// <paramref name="rayY"/> (see <see cref="RayY"/>), crosses this curve.
/// </summary>
internal bool CrossesRay(Vector point, double rayY)
{ {
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray // 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 // that vertex is on. Rings are only nominally closed: capture admits closure seams and
@@ -466,7 +617,6 @@ internal static class PostVerificationGeometry
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and // 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 // 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. // 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 > rayY) != (End.Y > rayY) 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;
@@ -34,7 +34,13 @@ public enum PostVerificationKind
/// Two parts are closer than the plate's part spacing without sharing material, judged as /// Two parts are closer than the plate's part spacing without sharing material, judged as
/// Auto Nest's layout check judges them. /// Auto Nest's layout check judges them.
/// </summary> /// </summary>
PartSpacing PartSpacing,
/// <summary>
/// A part its plate's support bed cannot hold level has no tab that keeps it level, so it may
/// tip once cut free (see docs/support-tabs.md).
/// </summary>
PartSupport
} }
/// <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>
@@ -84,6 +90,8 @@ public sealed class PostVerificationReport
Summary(PostVerificationKind.PierceZone, "Pierce zones"); Summary(PostVerificationKind.PierceZone, "Pierce zones");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing)) if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSpacing))
Summary(PostVerificationKind.PartSpacing, "Part spacing"); Summary(PostVerificationKind.PartSpacing, "Part spacing");
if (Findings.Any(finding => finding.Kind == PostVerificationKind.PartSupport))
Summary(PostVerificationKind.PartSupport, "Part support");
foreach (var finding in Findings) foreach (var finding in Findings)
{ {
text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}"); text.Append(finding.PlateNumber == 0 ? "Post processor" : $"Plate {finding.PlateNumber}");
@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Dimensions
{
/// <summary>
/// Resolves dimensions against the entities being cut (visible, with roles, breakoffs and
/// tabbed cutouts applied), so a dimension measures the part as it will come off the table.
/// Radius and diameter dimensions find their circle or arc by ID among the visible entities.
/// </summary>
public static class DimensionBuilder
{
private const double DefaultOffsetFactor = 0.1;
private const double RadialOffsetFactor = 0.05;
/// <summary>
/// The extents of the geometry that bounds the part, or null if there is none. Source marks
/// keep their own layer objects, so <see cref="SpecialLayers.IsMaterial"/> alone would count
/// them; break cuts are skipped because they stay inside the outline they join.
/// </summary>
public static Box Extents(IEnumerable<Entity> entities)
{
var measured = Measured(entities);
return measured.Count == 0 ? null : measured.GetBoundingBox();
}
private static List<Entity> Measured(IEnumerable<Entity> entities) =>
entities
.Where(e => SpecialLayers.IsMaterial(e.Layer) && !ScribeLayerRepair.IsMarkLayer(e.Layer))
.ToList();
public static DimensionLayout Layout(IEnumerable<Entity> entities, DrawingDimension dimension)
{
var kind = dimension.Kind;
if (dimension.Offset == 0 || !double.IsFinite(dimension.Offset))
return new DimensionLayout { Kind = kind, Error = "its offset is not usable" };
if (dimension.IsRadial)
return RadialLayout(entities, dimension);
var measured = Measured(entities);
if (measured.Count == 0)
return new DimensionLayout { Kind = kind, Error = "there is no cut geometry to measure" };
var box = measured.GetBoundingBox();
var offset = dimension.Offset;
if (kind == DimensionKind.OverallHorizontal)
{
if (box.Length <= 0)
return new DimensionLayout { Kind = kind, Error = "the part has no horizontal size" };
var edge = offset > 0 ? box.Top : box.Bottom;
return new DimensionLayout
{
Kind = kind,
Extents = box,
Profile = measured,
Value = box.Length,
ExtensionStart = new Vector(box.Left, edge),
ExtensionEnd = new Vector(box.Right, edge),
LineStart = new Vector(box.Left, edge + offset),
LineEnd = new Vector(box.Right, edge + offset),
};
}
if (box.Width <= 0)
return new DimensionLayout { Kind = kind, Error = "the part has no vertical size" };
var side = offset > 0 ? box.Right : box.Left;
return new DimensionLayout
{
Kind = kind,
Extents = box,
Profile = measured,
Value = box.Width,
ExtensionStart = new Vector(side, box.Bottom),
ExtensionEnd = new Vector(side, box.Top),
LineStart = new Vector(side + offset, box.Bottom),
LineEnd = new Vector(side + offset, box.Top),
};
}
/// <summary>
/// The overall dimensions <paramref name="existing"/> does not have yet, below and to the
/// right of the part and a tenth of its larger size away. Empty when there is nothing to
/// measure.
/// </summary>
public static List<DrawingDimension> MissingOverall(IEnumerable<Entity> entities,
IEnumerable<DrawingDimension> existing)
{
var added = new List<DrawingDimension>();
var box = Extents(entities);
if (box == null)
return added;
var offset = System.Math.Max(box.Length, box.Width) * DefaultOffsetFactor;
if (offset <= 0)
return added;
var kinds = existing.Select(d => d.Kind).ToHashSet();
if (!kinds.Contains(DimensionKind.OverallHorizontal))
added.Add(new DrawingDimension { Kind = DimensionKind.OverallHorizontal, Offset = -offset });
if (!kinds.Contains(DimensionKind.OverallVertical))
added.Add(new DrawingDimension { Kind = DimensionKind.OverallVertical, Offset = offset });
return added;
}
/// <summary>
/// A <paramref name="kind"/> dimension for each circle or arc in <paramref name="curves"/>
/// that does not have one yet. On a circle the leader points up and right for a radius and
/// up and left for a diameter, so the two do not overlap; on an arc it points through the
/// middle. The label sits outside the curve by half its radius, or a twentieth of
/// the part's larger size when that is more.
/// </summary>
public static List<DrawingDimension> MissingRadial(IEnumerable<Entity> curves, DimensionKind kind,
IEnumerable<Entity> part, IEnumerable<DrawingDimension> existing)
{
var added = new List<DrawingDimension>();
if (!DrawingDimension.IsRadialKind(kind))
return added;
var box = Extents(part);
var partSize = box == null ? 0 : System.Math.Max(box.Length, box.Width);
var taken = existing.Where(d => d.Kind == kind).Select(d => d.EntityId).ToHashSet();
foreach (var curve in curves)
{
if (curve.Id == Guid.Empty || !taken.Add(curve.Id) || !TryCurve(curve, out _, out var radius, out var arc))
continue;
added.Add(new DrawingDimension
{
Kind = kind,
EntityId = curve.Id,
Angle = arc != null ? MidAngle(arc)
: kind == DimensionKind.Radius ? Angle.HalfPI / 2 : Angle.HalfPI * 1.5,
Offset = System.Math.Max(radius / 2, partSize * RadialOffsetFactor),
});
}
return added;
}
/// <summary>Whether <paramref name="entity"/> is a circle or arc a radius or diameter can measure.</summary>
public static bool IsRadialTarget(Entity entity) => TryCurve(entity, out _, out _, out _);
private static bool TryCurve(Entity entity, out Vector center, out double radius, out Arc arc)
{
arc = null;
switch (entity)
{
case Circle circle:
center = circle.Center;
radius = circle.Radius;
break;
case Arc a:
center = a.Center;
radius = a.Radius;
arc = a.IsFullCircle() ? null : a;
break;
default:
center = default;
radius = 0;
return false;
}
return radius > 0 && double.IsFinite(radius);
}
private static DimensionLayout RadialLayout(IEnumerable<Entity> entities, DrawingDimension dimension)
{
var kind = dimension.Kind;
if (!double.IsFinite(dimension.Angle))
return new DimensionLayout { Kind = kind, Error = "its leader angle is not usable" };
var entity = dimension.EntityId == Guid.Empty
? null
: entities.FirstOrDefault(e => e.Id == dimension.EntityId);
if (entity == null)
return new DimensionLayout { Kind = kind, Error = "its circle or arc is not cut" };
if (!TryCurve(entity, out var center, out var radius, out var arc))
return new DimensionLayout { Kind = kind, Error = "it no longer measures a circle or arc" };
var reach = radius + dimension.Offset;
if (reach <= 0)
return new DimensionLayout { Kind = kind, Error = "its offset is not usable" };
var angle = arc != null && kind == DimensionKind.Radius
? ClampToSweep(arc, dimension.Angle)
: dimension.Angle;
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
var arrow = center + direction * radius;
var far = center - direction * radius;
return new DimensionLayout
{
Kind = kind,
Value = kind == DimensionKind.Diameter ? radius * 2 : radius,
Center = center,
Radius = radius,
Angle = angle,
Arc = arc,
ArrowPoint = arrow,
FarArrowPoint = far,
LineStart = kind == DimensionKind.Diameter ? far : center,
LineEnd = center + direction * reach,
Extents = new Box(center.X - radius, center.Y - radius, radius * 2, radius * 2),
};
}
/// <summary><paramref name="angle"/> if it is on the arc's sweep, else the nearer end's angle.</summary>
public static double ClampToSweep(Arc arc, double angle)
{
if (Angle.IsBetweenRad(angle, arc.StartAngle, arc.EndAngle, arc.IsReversed))
return angle;
var toStart = AngularDistance(angle, arc.StartAngle);
var toEnd = AngularDistance(angle, arc.EndAngle);
return toStart <= toEnd ? arc.StartAngle : arc.EndAngle;
}
private static double AngularDistance(double a, double b)
{
var d = Angle.NormalizeRad(a - b);
return System.Math.Min(d, Angle.TwoPI - d);
}
private static double MidAngle(Arc arc)
{
// SweepAngle runs counterclockwise from the start, or from the end on a reversed arc.
var from = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
return Angle.NormalizeRad(from + arc.SweepAngle() / 2);
}
}
}
+148
View File
@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using OpenNest.Geometry;
namespace OpenNest.Dimensions
{
/// <summary>
/// A dimension resolved against a part's geometry. Extension lines run along the extents, on
/// the lines through <see cref="ExtensionStart"/> and <see cref="LineStart"/> and through
/// <see cref="ExtensionEnd"/> and <see cref="LineEnd"/>, out from the part to
/// <see cref="ExtensionTip"/>; the dimension line joins the last two.
/// </summary>
public sealed class DimensionLayout
{
private const int MaxExtensionSteps = 64;
private const double ExtensionPrecision = 0.05;
public DimensionKind Kind { get; init; }
/// <summary>Why the dimension could not be resolved; null when it was.</summary>
public string Error { get; init; }
public bool IsValid => Error == null;
public double Value { get; init; }
public Vector ExtensionStart { get; init; }
public Vector ExtensionEnd { get; init; }
public Vector LineStart { get; init; }
public Vector LineEnd { get; init; }
/// <summary>The part's extents the dimension was measured from.</summary>
public Box Extents { get; init; }
/// <summary>The geometry behind <see cref="Extents"/>.</summary>
public IReadOnlyList<Entity> Profile { get; init; } = Array.Empty<Entity>();
public string ValueText => Value.ToString("0.000", CultureInfo.InvariantCulture);
/// <summary>The value as drawn: radius and diameter carry their drafting prefix.</summary>
public string DisplayText => Kind switch
{
DimensionKind.Radius => "R" + ValueText,
DimensionKind.Diameter => "\u00D8" + ValueText,
_ => ValueText,
};
public Vector MidPoint => new((LineStart.X + LineEnd.X) / 2, (LineStart.Y + LineEnd.Y) / 2);
public bool IsRadial => DrawingDimension.IsRadialKind(Kind);
// Radius and diameter: the leader runs from LineStart (the center for a radius, the far
// side of the curve for a diameter) through ArrowPoint on the curve to LineEnd, the label.
/// <summary>Radius and diameter: the measured curve's center.</summary>
public Vector Center { get; init; }
/// <summary>Radius and diameter: the measured curve's radius.</summary>
public double Radius { get; init; }
/// <summary>Radius and diameter: the leader's direction from the center, in radians.</summary>
public double Angle { get; init; }
/// <summary>Radius and diameter: where the leader meets the curve on the label's side.</summary>
public Vector ArrowPoint { get; init; }
/// <summary>Diameter: where the leader meets the curve opposite <see cref="ArrowPoint"/>.</summary>
public Vector FarArrowPoint { get; init; }
/// <summary>Radius and diameter on an arc: the measured arc; null for a circle.</summary>
public Arc Arc { get; init; }
/// <summary>
/// Radius and diameter: the leader angle and offset that put the label at
/// <paramref name="point"/>, with the label at least <paramref name="minimum"/> from both the
/// curve and the center. A radius on an arc stays on the arc's sweep.
/// </summary>
public (double Offset, double Angle) RadialPlacementAt(Vector point, double minimum)
{
var dx = point.X - Center.X;
var dy = point.Y - Center.Y;
var distance = System.Math.Sqrt(dx * dx + dy * dy);
var angle = distance > 0 ? System.Math.Atan2(dy, dx) : Angle;
if (Arc != null && Kind == DimensionKind.Radius)
angle = DimensionBuilder.ClampToSweep(Arc, angle);
var offset = distance - Radius;
if (offset > -minimum && offset < minimum)
offset = offset >= 0 ? minimum : -minimum;
if (offset < minimum - Radius)
offset = minimum - Radius <= -minimum ? minimum - Radius : minimum;
return (offset, Math.Angle.NormalizeRad(angle));
}
/// <summary>
/// Where an extension line leaving <paramref name="foot"/> on the dimension line first
/// meets the profile offset by <paramref name="clearance"/>, walking toward the part by the
/// distance known to be free at each step. <paramref name="foot"/> itself when it is
/// already that close.
/// </summary>
public Vector ExtensionTip(Vector foot, double clearance)
{
var horizontal = Kind == DimensionKind.OverallHorizontal;
var position = horizontal ? foot.Y : foot.X;
var center = horizontal
? (Extents.Bottom + Extents.Top) / 2
: (Extents.Left + Extents.Right) / 2;
var sign = System.Math.Sign(center - position);
var direction = horizontal ? new Vector(0, sign) : new Vector(sign, 0);
// The walk ends at the far side of the part at the latest: the line touches the part.
var limit = System.Math.Abs(center - position) + (horizontal ? Extents.Width : Extents.Length) / 2;
var travelled = 0.0;
for (var i = 0; i < MaxExtensionSteps && travelled < limit; i++)
{
var free = DistanceToProfile(foot + direction * travelled) - clearance;
if (free <= clearance * ExtensionPrecision)
break;
travelled += free;
}
return foot + direction * System.Math.Min(travelled, limit);
}
private double DistanceToProfile(Vector point)
{
var nearest = double.MaxValue;
foreach (var entity in Profile)
nearest = System.Math.Min(nearest, point.DistanceTo(entity.ClosestPointTo(point)));
return nearest;
}
/// <summary>
/// The offset that puts the dimension line through <paramref name="point"/>, on the side of
/// the part's center the point is on and at least <paramref name="minimum"/> clear of it.
/// </summary>
public double OffsetAt(Vector point, double minimum)
{
var horizontal = Kind == DimensionKind.OverallHorizontal;
var position = horizontal ? point.Y : point.X;
var low = horizontal ? Extents.Bottom : Extents.Left;
var high = horizontal ? Extents.Top : Extents.Right;
return position >= (low + high) / 2
? System.Math.Max(minimum, position - high)
: System.Math.Min(-minimum, position - low);
}
}
}
@@ -0,0 +1,48 @@
using System;
namespace OpenNest.Dimensions
{
public enum DimensionKind
{
/// <summary>The part's overall size along X, drawn above or below it.</summary>
OverallHorizontal,
/// <summary>The part's overall size along Y, drawn right or left of it.</summary>
OverallVertical,
/// <summary>A circle's or arc's radius, on a leader from its center.</summary>
Radius,
/// <summary>A circle's or arc's diameter, on a leader across it.</summary>
Diameter,
}
/// <summary>
/// An annotation on a drawing. Only its placement is stored: the value is measured from the
/// geometry each time it is resolved, so it cannot go stale. It never affects the program.
/// </summary>
public sealed class DrawingDimension
{
public DimensionKind Kind { get; set; }
/// <summary>
/// Never zero. Overall dimensions: distance from the part's extents to the dimension line,
/// positive above a horizontal dimension's part or right of a vertical one's. Radius and
/// diameter: distance from the curve to the label along the leader, positive outside.
/// </summary>
public double Offset { get; set; }
/// <summary>The circle or arc a radius or diameter measures; empty for overall dimensions.</summary>
public Guid EntityId { get; set; }
/// <summary>Radius and diameter: the leader's direction from the center, in radians.</summary>
public double Angle { get; set; }
public bool IsRadial => IsRadialKind(Kind);
public static bool IsRadialKind(DimensionKind kind) =>
kind is DimensionKind.Radius or DimensionKind.Diameter;
public DrawingDimension Clone() => new() { Kind = Kind, Offset = Offset, EntityId = EntityId, Angle = Angle };
}
}
+133
View File
@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Dimensions
{
/// <summary>One hole diameter in a <see cref="HoleTable"/> and every hole cut at that size.</summary>
public sealed class HoleSize
{
public HoleSize(double diameter, bool isKeyed, IReadOnlyList<Vector> centers, IReadOnlyList<Guid> entityIds)
{
Diameter = diameter;
IsKeyed = isKeyed;
Centers = centers;
EntityIds = entityIds;
}
/// <summary>The diameter, rounded to <see cref="HoleTable.Decimals"/> places.</summary>
public double Diameter { get; }
/// <summary>True when the holes are round except for a keyway, flat or other interruption.</summary>
public bool IsKeyed { get; }
public int Quantity => Centers.Count;
public IReadOnlyList<Vector> Centers { get; }
/// <summary>The IDs of every entity that makes up a hole of this size.</summary>
public IReadOnlyList<Guid> EntityIds { get; }
public string DiameterText => "\u00D8" + Diameter.ToString("0.0000", System.Globalization.CultureInfo.InvariantCulture)
+ (IsKeyed ? " keyed" : "");
}
/// <summary>
/// Counts the round and keyed holes in a drawing by diameter. A round hole is a cut circle, or a
/// closed contour made only of arcs on one center and radius; a keyed hole is a closed contour
/// around its center with at least <see cref="MinKeyedSweep"/> of arc on one center and radius,
/// and anything else for the rest. Either must lie inside another cut contour, so the outline of
/// a round part is not a hole. Marks (scribe, etch, rapids, break cuts) are not holes. Pass the
/// entities as they will be cut (visible, with roles applied).
/// </summary>
public static class HoleTable
{
/// <summary>Holes whose diameters agree to this many decimal places share a row.</summary>
public const int Decimals = 4;
/// <summary>The least arc, in radians, that makes a contour a keyed hole: 270 degrees.</summary>
public const double MinKeyedSweep = 1.5 * System.Math.PI;
private const double SweepTolerance = 1e-6;
public static IReadOnlyList<HoleSize> Build(IEnumerable<Entity> entities)
{
var cut = entities
.Where(e => SpecialLayers.IsMaterial(e.Layer) && !ScribeLayerRepair.IsMarkLayer(e.Layer))
.ToList();
if (cut.Count == 0)
return Array.Empty<HoleSize>();
var shapes = ShapeBuilder.GetShapes(cut);
var holes = new List<(double Diameter, bool Keyed, Vector Center, List<Guid> Ids)>();
foreach (var shape in shapes)
{
if (!TryRound(shape, out var center, out var radius, out var keyed))
continue;
var box = shape.BoundingBox;
var area = box.Area();
if (!shapes.Any(other => !ReferenceEquals(other, shape)
&& other.BoundingBox.Area() > area + Tolerance.Epsilon
&& other.BoundingBox.Contains(box)))
continue;
holes.Add((System.Math.Round(radius * 2, Decimals), keyed, center, shape.Entities.Select(e => e.Id).ToList()));
}
return holes
.GroupBy(h => (h.Diameter, h.Keyed))
.OrderBy(g => g.Key.Diameter)
.ThenBy(g => g.Key.Keyed)
.Select(g => new HoleSize(
g.Key.Diameter,
g.Key.Keyed,
g.Select(h => h.Center).ToList(),
g.SelectMany(h => h.Ids).ToList()))
.ToList();
}
private static bool TryRound(Shape shape, out Vector center, out double radius, out bool keyed)
{
center = default;
radius = 0;
keyed = false;
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
{
center = circle.Center;
radius = circle.Radius;
return radius > 0;
}
if (!shape.IsClosed())
return false;
var arcs = shape.Entities.OfType<Arc>().ToList();
var tolerance = Tolerance.ChainTolerance;
var sweep = 0.0;
foreach (var arc in arcs)
{
var total = arcs
.Where(a => a.Center.DistanceTo(arc.Center) <= tolerance
&& System.Math.Abs(a.Radius - arc.Radius) <= tolerance)
.Sum(a => a.SweepAngle());
if (total <= sweep)
continue;
sweep = total;
center = arc.Center;
radius = arc.Radius;
}
if (radius <= 0 || sweep > Angle.TwoPI + SweepTolerance)
return false;
if (System.Math.Abs(sweep - Angle.TwoPI) <= SweepTolerance && arcs.Count == shape.Entities.Count)
return true;
// The center test turns away a curved slot, whose long arcs wrap around solid material.
keyed = true;
return sweep >= MinKeyedSweep - SweepTolerance && shape.ToPolygon().ContainsPoint(center);
}
}
}
+7
View File
@@ -66,6 +66,7 @@ namespace OpenNest
public string Customer { get; set; } public string Customer { get; set; }
/// <summary>Nesting order: higher numbers nest first. Zero is the default.</summary>
public int Priority { get; set; } public int Priority { get; set; }
public DwgQty Quantity; public DwgQty Quantity;
@@ -151,6 +152,12 @@ namespace OpenNest
/// </summary> /// </summary>
public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new List<TabbedCutouts.TabbedCutout>(); public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new List<TabbedCutouts.TabbedCutout>();
/// <summary>
/// Annotations shown in the CAD Converter. They store placement only, are measured from the
/// geometry when shown and never affect <see cref="Program"/>.
/// </summary>
public List<Dimensions.DrawingDimension> Dimensions { get; set; } = new List<Dimensions.DrawingDimension>();
/// <summary> /// <summary>
/// Complete set of source entities with stable GUIDs. /// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file. /// Null when the drawing was created from G-code or an older nest file.
+23
View File
@@ -85,5 +85,28 @@ namespace OpenNest.Geometry
return new Box(left, bottom, right - left, top - bottom); return new Box(left, bottom, right - left, top - bottom);
} }
/// <summary>
/// Whether <paramref name="inner"/> lies inside <paramref name="outer"/>, allowing
/// <see cref="Math.Tolerance.Epsilon"/> on every side.
/// </summary>
internal static bool IsWithin(Box inner, Box outer)
{
var eps = Math.Tolerance.Epsilon;
return inner.Left >= outer.Left - eps
&& inner.Right <= outer.Right + eps
&& inner.Bottom >= outer.Bottom - eps
&& inner.Top <= outer.Top + eps;
}
/// <summary>The smallest box covering both boxes.</summary>
internal static Box Union(Box a, Box b)
{
var left = System.Math.Min(a.Left, b.Left);
var bottom = System.Math.Min(a.Bottom, b.Bottom);
var right = System.Math.Max(a.Right, b.Right);
var top = System.Math.Max(a.Top, b.Top);
return new Box(left, bottom, right - left, top - bottom);
}
} }
} }
+1 -1
View File
@@ -7,7 +7,7 @@ namespace OpenNest.Geometry
/// <summary> /// <summary>
/// Polygon overlap test with hole subtraction, used by engine and validation per-pair /// Polygon overlap test with hole subtraction, used by engine and validation per-pair
/// checks. It was written as the reference for a GPU kernel; that port is on hold in /// checks. It was written as the reference for a GPU kernel; that port is on hold in
/// favour of Clipper2, starting with the material-overlap diagnostic. Engine callers /// favor of Clipper2, starting with the material-overlap diagnostic. Engine callers
/// stay on this kernel until a Clipper2 near-contact differential and the layout gate /// stay on this kernel until a Clipper2 near-contact differential and the layout gate
/// pass. /// pass.
/// <para> /// <para>
+15 -22
View File
@@ -44,7 +44,7 @@ namespace OpenNest.Geometry
/// <summary> /// <summary>
/// Aligns <paramref name="newProgram"/> onto <paramref name="oldProgram"/>. /// Aligns <paramref name="newProgram"/> onto <paramref name="oldProgram"/>.
/// Both programs are treated as read-only. Throws /// Both programs are treated as read-only. Throws
/// <see cref="OperationCanceledException"/> only when cancelled before any /// <see cref="OperationCanceledException"/> only when canceled before any
/// measurable work; work limits reached mid-run are reported as reasons on /// measurable work; work limits reached mid-run are reported as reasons on
/// the result instead. /// the result instead.
/// </summary> /// </summary>
@@ -504,7 +504,7 @@ namespace OpenNest.Geometry
var newC = Centroid(@new.Outer.Samples); var newC = Centroid(@new.Outer.Samples);
for (var i = 0; i < seeds.Count; i++) for (var i = 0; i < seeds.Count; i++)
{ {
var rotated = Rotate(newC, seeds[i].Item1); var rotated = newC.Rotate(seeds[i].Item1);
seeds[i] = (seeds[i].Item1, oldC - rotated); seeds[i] = (seeds[i].Item1, oldC - rotated);
} }
return seeds; return seeds;
@@ -569,7 +569,7 @@ namespace OpenNest.Geometry
{ {
var p = @new.Outer.Samples[i].Position; var p = @new.Outer.Samples[i].Position;
var m = preMirror(p.X, p.Y); var m = preMirror(p.X, p.Y);
positions[idx++] = Rotate(new Vector(m.X, m.Y), rotation) + translation; positions[idx++] = new Vector(m.X, m.Y).Rotate(rotation) + translation;
} }
if (!includeHoles) if (!includeHoles)
return; return;
@@ -578,7 +578,7 @@ namespace OpenNest.Geometry
{ {
var p = @new.Holes[h].Samples[i].Position; var p = @new.Holes[h].Samples[i].Position;
var m = preMirror(p.X, p.Y); var m = preMirror(p.X, p.Y);
positions[idx++] = Rotate(new Vector(m.X, m.Y), rotation) + translation; positions[idx++] = new Vector(m.X, m.Y).Rotate(rotation) + translation;
} }
} }
@@ -650,7 +650,7 @@ namespace OpenNest.Geometry
return null; // nonfinite update return null; // nonfinite update
rotation += dTheta; rotation += dTheta;
translation = Rotate(translation, dTheta) + dT; translation = translation.Rotate(dTheta) + dT;
var rms = System.Math.Sqrt(order.Average(i => residuals[i] * residuals[i])); var rms = System.Math.Sqrt(order.Average(i => residuals[i] * residuals[i]));
@@ -790,7 +790,7 @@ namespace OpenNest.Geometry
if (double.IsNaN(dTheta) || double.IsInfinity(dTheta)) if (double.IsNaN(dTheta) || double.IsInfinity(dTheta))
return false; return false;
var r = Rotate(cq, dTheta); var r = cq.Rotate(dTheta);
dTranslation = ct - r; dTranslation = ct - r;
return !double.IsNaN(dTranslation.X) && !double.IsNaN(dTranslation.Y); return !double.IsNaN(dTranslation.X) && !double.IsNaN(dTranslation.Y);
} }
@@ -866,11 +866,11 @@ namespace OpenNest.Geometry
var outerCenter = Centroid(old.Outer.Samples); var outerCenter = Centroid(old.Outer.Samples);
foreach (var flip in new[] { System.Math.PI, System.Math.PI / 2, -System.Math.PI / 2 }) foreach (var flip in new[] { System.Math.PI, System.Math.PI / 2, -System.Math.PI / 2 })
{ {
var anchor = Rotate(-outerCenter, flip) + outerCenter; var anchor = (-outerCenter).Rotate(flip) + outerCenter;
var probe = new Candidate var probe = new Candidate
{ {
Rotation = NormalizeAngle(best.Rotation + flip), Rotation = NormalizeAngle(best.Rotation + flip),
Translation = Rotate(best.Translation, flip) + anchor, Translation = best.Translation.Rotate(flip) + anchor,
}; };
var refined = FitIcp( var refined = FitIcp(
old, old,
@@ -971,8 +971,8 @@ namespace OpenNest.Geometry
var limit = options.SamplingSpacing * 0.5; var limit = options.SamplingSpacing * 0.5;
foreach (var s in @new.Outer.Samples) foreach (var s in @new.Outer.Samples)
{ {
var pa = Rotate(s.Position, a.Rotation) + a.Translation; var pa = s.Position.Rotate(a.Rotation) + a.Translation;
var pb = Rotate(s.Position, best.Rotation) + best.Translation; var pb = s.Position.Rotate(best.Rotation) + best.Translation;
if (pa.DistanceTo(pb) > limit) if (pa.DistanceTo(pb) > limit)
return true; return true;
} }
@@ -993,7 +993,7 @@ namespace OpenNest.Geometry
var newTotal = 0; var newTotal = 0;
foreach (var s in AllNewSamples(@new)) foreach (var s in AllNewSamples(@new))
{ {
var p = Rotate(s.Position, pose.Rotation) + pose.Translation; var p = s.Position.Rotate(pose.Rotation) + pose.Translation;
newTotal++; newTotal++;
if (TryClosestSegment(p, oldSegments, out var c) && p.DistanceTo(c) <= spanBound) if (TryClosestSegment(p, oldSegments, out var c) && p.DistanceTo(c) <= spanBound)
newMatched++; newMatched++;
@@ -1005,8 +1005,8 @@ namespace OpenNest.Geometry
var posed = AllNewSegments(@new); var posed = AllNewSegments(@new);
for (var i = 0; i < posed.Length; i++) for (var i = 0; i < posed.Length; i++)
posedNewSegments[i] = new Line( posedNewSegments[i] = new Line(
Rotate(posed[i].StartPoint, pose.Rotation) + pose.Translation, posed[i].StartPoint.Rotate(pose.Rotation) + pose.Translation,
Rotate(posed[i].EndPoint, pose.Rotation) + pose.Translation posed[i].EndPoint.Rotate(pose.Rotation) + pose.Translation
); );
var oldMatched = 0; var oldMatched = 0;
@@ -1034,7 +1034,7 @@ namespace OpenNest.Geometry
var distances = new List<double>(); var distances = new List<double>();
foreach (var s in AllNewSamples(@new)) foreach (var s in AllNewSamples(@new))
{ {
var p = Rotate(s.Position, pose.Rotation) + pose.Translation; var p = s.Position.Rotate(pose.Rotation) + pose.Translation;
if (TryClosestSegment(p, oldSegments, out var c)) if (TryClosestSegment(p, oldSegments, out var c))
distances.Add(p.DistanceTo(c)); distances.Add(p.DistanceTo(c));
} }
@@ -1096,7 +1096,7 @@ namespace OpenNest.Geometry
var path = new PathD(points.Length); var path = new PathD(points.Length);
foreach (var p in points) foreach (var p in points)
{ {
var q = Rotate(p, rotation) + translation; var q = p.Rotate(rotation) + translation;
if (double.IsNaN(q.X) || double.IsNaN(q.Y)) if (double.IsNaN(q.X) || double.IsNaN(q.Y))
return null; return null;
path.Add(new PointD(q.X, q.Y)); path.Add(new PointD(q.X, q.Y));
@@ -1137,13 +1137,6 @@ namespace OpenNest.Geometry
return samples.Length > 0 ? sum * (1.0 / samples.Length) : new Vector(); return samples.Length > 0 ? sum * (1.0 / samples.Length) : new Vector();
} }
private static Vector Rotate(Vector v, double angle)
{
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
return new Vector(v.X * cos - v.Y * sin, v.X * sin + v.Y * cos);
}
private static double NormalizeAngle(double angle) private static double NormalizeAngle(double angle)
{ {
var twoPi = 2 * System.Math.PI; var twoPi = 2 * System.Math.PI;
+26
View File
@@ -0,0 +1,26 @@
namespace OpenNest.Geometry
{
internal static class EntityEndpoints
{
/// <summary>The start and end of a line or arc; false for any other entity.</summary>
public static bool TryGet(Entity entity, out Vector start, out Vector end)
{
switch (entity)
{
case Line line:
start = line.StartPoint;
end = line.EndPoint;
return true;
case Arc arc:
start = arc.StartPoint();
end = arc.EndPoint();
return true;
default:
start = end = default;
return false;
}
}
}
}
+132 -43
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using OpenNest.Math; using OpenNest.Math;
@@ -10,14 +11,14 @@ namespace OpenNest.Geometry
public static void Optimize(IList<Arc> arcs) => public static void Optimize(IList<Arc> arcs) =>
MergePass( MergePass(
arcs, arcs,
(list, item, i) => list.GetCoradialArs(item, i), (a, b) => a.IsCoradialTo(b),
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined) (Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined)
); );
public static void Optimize(IList<Line> lines) => public static void Optimize(IList<Line> lines) =>
MergePass( MergePass(
lines, lines,
(list, item, i) => list.GetCollinearLines(item, i), (a, b) => a.IsCollinearTo(b),
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined) (Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)
); );
@@ -57,11 +58,120 @@ namespace OpenNest.Geometry
} }
} }
/// <summary>
/// Replaces arcs of one center, radius and layer that together cover the whole circle with
/// a <see cref="Circle"/>. CAM exports often overshoot the seam of a hole and return along
/// a short chord; such a line, lying wholly on the overlap, goes with the arcs. Arcs that
/// leave a gap, or overlap by more than <see cref="MaxSeamOverlap"/>, are left alone.
/// </summary>
public static void JoinFullCircles(List<Entity> entities)
{
var groups = new List<List<Arc>>();
foreach (var arc in entities.OfType<Arc>())
{
if (!SpecialLayers.IsMaterial(arc.Layer))
continue;
var group = groups.Find(g => IsSameCircle(g[0], arc));
if (group == null)
groups.Add(new List<Arc> { arc });
else
group.Add(arc);
}
foreach (var group in groups)
{
var longest = group.OrderByDescending(a => a.SweepAngle()).First();
var tolerance = Tolerance.ChainTolerance / longest.Radius;
var spans = group.Select(ToSpan).OrderBy(s => s.Start).ToList();
var overlap = spans.Sum(s => s.Sweep) - Angle.TwoPI;
if (group.Count < 2 || overlap > MaxSeamOverlap || !CoversCircle(spans, tolerance))
continue;
var chords = entities
.OfType<Line>()
.Where(line => IsSeamChord(line, longest, spans, tolerance))
.ToList();
var index = entities.FindIndex(e => e is Arc arc && group.Contains(arc));
entities.Insert(
index,
new Circle(longest.Center, longest.Radius)
{
Layer = longest.Layer,
Color = longest.Color,
}
);
entities.RemoveAll(e => e is Arc arc && group.Contains(arc));
entities.RemoveAll(e => e is Line line && chords.Contains(line));
}
}
/// <summary>The most the arcs of a joined circle may overlap, in radians: 30 degrees.</summary>
public const double MaxSeamOverlap = System.Math.PI / 6;
private static bool IsSameCircle(Arc a, Arc b) =>
a.Layer?.Name == b.Layer?.Name
&& a.Center.DistanceTo(b.Center) <= Tolerance.ChainTolerance
&& System.Math.Abs(a.Radius - b.Radius) <= Tolerance.ChainTolerance;
private static (double Start, double Sweep) ToSpan(Arc arc) =>
(Angle.NormalizeRad(arc.IsReversed ? arc.EndAngle : arc.StartAngle), arc.SweepAngle());
// Walks the spans from angle zero, first crediting whatever part of them wraps past it.
private static bool CoversCircle(List<(double Start, double Sweep)> spans, double tolerance)
{
var reach = spans.Max(s => s.Start + s.Sweep - Angle.TwoPI);
foreach (var (start, sweep) in spans)
{
if (start > reach + tolerance)
return false;
reach = System.Math.Max(reach, start + sweep);
}
return reach >= Angle.TwoPI - tolerance;
}
private static bool IsSeamChord(
Line line,
Arc circle,
List<(double Start, double Sweep)> spans,
double tolerance
)
{
if (line.Layer?.Name != circle.Layer?.Name)
return false;
var center = circle.Center;
bool OnCircle(Vector point) =>
System.Math.Abs(point.DistanceTo(center) - circle.Radius) <= Tolerance.ChainTolerance;
bool OnOverlap(Vector point)
{
var angle = System.Math.Atan2(point.Y - center.Y, point.X - center.X);
return spans.Count(s =>
{
var offset = Angle.NormalizeRad(angle - s.Start);
return offset <= s.Sweep + tolerance || offset >= Angle.TwoPI - tolerance;
}) >= 2;
}
var middle = new Vector(
(line.StartPoint.X + line.EndPoint.X) / 2,
(line.StartPoint.Y + line.EndPoint.Y) / 2
);
return OnCircle(line.StartPoint)
&& OnCircle(line.EndPoint)
&& OnOverlap(line.StartPoint)
&& OnOverlap(line.EndPoint)
&& OnOverlap(middle);
}
private delegate bool TryJoin<T>(T a, T b, out T joined); private delegate bool TryJoin<T>(T a, T b, out T joined);
private static void MergePass<T>( private static void MergePass<T>(
IList<T> items, IList<T> items,
Func<IList<T>, T, int, List<T>> findCandidates, Func<T, T, bool> related,
TryJoin<T> tryJoin TryJoin<T> tryJoin
) )
where T : class where T : class
@@ -69,7 +179,7 @@ namespace OpenNest.Geometry
for (var i = 0; i < items.Count; ++i) for (var i = 0; i < items.Count; ++i)
{ {
var item = items[i]; var item = items[i];
var candidates = findCandidates(items, item, i); var candidates = FindRelated(items, item, i, related);
var index = 0; var index = 0;
while (index < candidates.Count) while (index < candidates.Count)
@@ -214,62 +324,41 @@ namespace OpenNest.Geometry
return true; return true;
} }
private static List<Line> GetCollinearLines( /// <summary>
this IList<Line> lines, /// The items from <paramref name="startIndex"/> on, other than <paramref name="item"/>
Line line, /// itself, that are <paramref name="related"/> to it.
int startIndex /// </summary>
private static List<T> FindRelated<T>(
IList<T> items,
T item,
int startIndex,
Func<T, T, bool> related
) )
where T : class
{ {
var collinearLines = new List<Line>(); var matches = new List<T>();
Parallel.For( Parallel.For(
startIndex, startIndex,
lines.Count, items.Count,
index => index =>
{ {
var compareLine = lines[index]; var candidate = items[index];
if (Object.ReferenceEquals(line, compareLine)) if (Object.ReferenceEquals(item, candidate))
return; return;
if (!line.IsCollinearTo(compareLine)) if (!related(item, candidate))
return; return;
lock (collinearLines) lock (matches)
{ {
collinearLines.Add(compareLine); matches.Add(candidate);
} }
} }
); );
return collinearLines; return matches;
}
private static List<Arc> GetCoradialArs(this IList<Arc> arcs, Arc arc, int startIndex)
{
var coradialArcs = new List<Arc>();
Parallel.For(
startIndex,
arcs.Count,
index =>
{
var compareArc = arcs[index];
if (Object.ReferenceEquals(arc, compareArc))
return;
if (!arc.IsCoradialTo(compareArc))
return;
lock (coradialArcs)
{
coradialArcs.Add(compareArc);
}
}
);
return coradialArcs;
} }
} }
} }
+15 -28
View File
@@ -555,13 +555,7 @@ public class GeometrySimplifier
if (start > 0) if (start > 0)
{ {
var prev = entities[start - 1]; var prev = entities[start - 1];
var prevEnd = prev switch if (EntityEndpoints.TryGet(prev, out _, out var prevEnd) && prevEnd.DistanceTo(points[0]) < 1e-6)
{
Line l => l.EndPoint,
Arc a => a.EndPoint(),
_ => Vector.Invalid,
};
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
{ {
if (prev is Arc) if (prev is Arc)
return new TangentEstimate(GetExitDirection(prev), true); return new TangentEstimate(GetExitDirection(prev), true);
@@ -596,13 +590,7 @@ public class GeometrySimplifier
if (k + 1 < entities.Count) if (k + 1 < entities.Count)
{ {
var next = entities[k + 1]; var next = entities[k + 1];
var nextStart = next switch if (EntityEndpoints.TryGet(next, out var nextStart, out _) && nextStart.DistanceTo(points[^1]) < 1e-6)
{
Line l => l.StartPoint,
Arc a => a.StartPoint(),
_ => Vector.Invalid,
};
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
{ {
if (next is Arc nextArc) if (next is Arc nextArc)
return new TangentEstimate(GetEntryDirection(nextArc), true); return new TangentEstimate(GetEntryDirection(nextArc), true);
@@ -654,16 +642,20 @@ public class GeometrySimplifier
private static Vector GetEntryDirection(Entity entity) => private static Vector GetEntryDirection(Entity entity) =>
entity switch entity switch
{ {
Line line => new Vector( Line line => LineDirection(line),
line.EndPoint.X - line.StartPoint.X, Arc arc => ArcTangent(arc, arc.StartAngle),
line.EndPoint.Y - line.StartPoint.Y
),
Arc arc => arc.IsReversed
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
_ => Vector.Invalid, _ => Vector.Invalid,
}; };
private static Vector LineDirection(Line line) =>
new(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y);
/// <summary>The direction of travel along an arc at <paramref name="angle"/>.</summary>
private static Vector ArcTangent(Arc arc, double angle) =>
arc.IsReversed
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
/// <summary> /// <summary>
/// Computes the tangent direction at the last point of a fitted arc, /// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc. /// used to chain tangent continuity to the next arc.
@@ -806,13 +798,8 @@ public class GeometrySimplifier
private static Vector GetExitDirection(Entity entity) => private static Vector GetExitDirection(Entity entity) =>
entity switch entity switch
{ {
Line line => new Vector( Line line => LineDirection(line),
line.EndPoint.X - line.StartPoint.X, Arc arc => ArcTangent(arc, arc.EndAngle),
line.EndPoint.Y - line.StartPoint.Y
),
Arc arc => arc.IsReversed
? new Vector(System.Math.Sin(arc.EndAngle), -System.Math.Cos(arc.EndAngle))
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)),
_ => Vector.Invalid, _ => Vector.Invalid,
}; };
+4 -1
View File
@@ -192,7 +192,10 @@ namespace OpenNest.Geometry
double t; double t;
pts = new List<Vector>(); pts = new List<Vector>();
if (det.IsEqualTo(0)) // A small circle's whole discriminant is tiny: tangent also means the line stays at its rim.
var halfChord = det / (4 * a);
var depth = circle.Radius - System.Math.Sqrt(System.Math.Max(circle.Radius * circle.Radius - halfChord, 0));
if (det.IsEqualTo(0) && depth <= Tolerance.Epsilon)
{ {
t = -b / (2 * a); t = -b / (2 * a);
var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y); var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
+4 -4
View File
@@ -69,7 +69,7 @@ public static class KeepOutOverlap
/// <summary> /// <summary>
/// True when the material (perimeter minus holes) shares positive area with the open /// 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 /// rectangle: some boundary edge runs through its interior, or its interior lies wholly
/// inside the material (tested at its centre). /// inside the material (tested at its center).
/// </summary> /// </summary>
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box) public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box)
{ {
@@ -80,12 +80,12 @@ public static class KeepOutOverlap
if (RingEnters(hole, box)) if (RingEnters(hole, box))
return true; return true;
var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2); var center = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
if (!Contains(perimeter, centre)) if (!Contains(perimeter, center))
return false; return false;
if (holes != null) if (holes != null)
foreach (var hole in holes) foreach (var hole in holes)
if (Contains(hole, centre)) if (Contains(hole, center))
return false; return false;
return true; return true;
} }
Loaded 100 of 1142 files, more files were not shown because too many files have changed in this diff. Show more