212 Commits
Author SHA1 Message Date
ajandClaude Opus 5.5 032854895d fix(reporting): fill parts whose arc ends carry post roundoff
The nest report joined contour segments only within 1e-12, but converted
arcs rebuild their end from the start radius and end angle. Rounded or
incremental programs (end radius a few 1e-7 off the start radius, which
the report's own validator accepts up to Tolerance.Epsilon) left every
arc a hair short of the next move, so outlines split into open pieces
and N26-27K's parts printed unshaded as if they had tab gaps.

Join within Tolerance.Epsilon instead, still far below ShapeBuilder's
welding tolerance and real tab gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 03:09:15 -05:00
aj be5fef6875 fix(release): enforce one finish per commit and keep real unplaced reasons
Delta review fixes:

- ReleaseRenest throws when a commit does not call finish, or calls it
  twice: either would leave ledger plates uncoded or count a sheet twice
  for later nests of the run. The ledger sheet count is the codes given.
- A stage whose layout is not kept now lists the copies its layout
  missed with their own reasons (a part no sheet fits stays "does not
  fit"); only the copies it placed are "the proposal cannot be kept".
- A valid ledger layout that places nothing says "Auto Nest placed
  nothing on it" rather than claiming no part fits.
- ReleaseLedgerOffer rejects null rows; docs list every ledger note.
2026-11-03 18:03:24 -05:00
aj 9efeb4e5b6 test(release): check the ledger step's own undo state keeps its code 2026-11-03 17:55:16 -05:00
aj 3e35821ab1 docs(release): note bundle sharing, merge drafts and every ledger note 2026-11-03 17:53:26 -05:00
aj c5b6e680cb fix(release): keep ledger codes in the undo step and share bundles per sheet
Review fixes for ledger-first Make Nests:

- The release renest (offer, staged run, codes, note) moves into
  OpenNest.Desktop.ReleaseRenest, shared by the headless panel, the
  live main window and the tests. Codes are given inside each commit, so
  the live tab's Auto Nest undo step records them; before, undo and redo
  dropped every ledger code.
- A bundle repeats one code per sheet: a later nest of the run lost the
  whole bundle once an earlier nest took one sheet. Taken sheets are now
  counted per code (LedgerCodeFilter.ExcludeCopies).
- A stage whose layout is not kept lists every copy it asked for as not
  placed (the proposal cannot be kept), and a rejected ledger layout
  says "MaterialLedger not used: Auto Nest could not keep a layout on
  its stock" instead of a bare "No MaterialLedger stock used".
- "no free stock" is said only when the ledger had none; otherwise the
  note counts what was left out (on other nests, taken by earlier nests
  of this release, too small, unknown shape).
- The ledger is not read for a nest with nothing to nest, the live read
  runs under the tab's stop token, a reason's own full stop is not
  doubled, and the sheet count is the codes given.
2026-11-03 17:53:26 -05:00
aj 31ee77914f docs(release): describe ledger-first nesting in Make Nests 2026-11-03 17:38:07 -05:00
aj 3b5cf92af2 feat(desktop): Make Nests nests each group on its MaterialLedger stock
With Nest after release ticked, one ReleaseLedgerStock is shared by the
whole run and passed to the renest step, headless or live in the main
window. The live tab runs one Auto Nest per stage (ledger stock, then
the Nest Defaults sheet), records the ledger locations for the plate
tooltips, and the group's note says how many sheets came from the
ledger or why it was not used.
2026-11-03 17:38:07 -05:00
aj 2939fe0802 feat(release): nest released groups on MaterialLedger stock first
NestRenest gains a staged run: a nest is nested on finite first stock
alone, then whatever is left on its plate defaults and plate-size
options. Each stage keeps only a valid layout, and the result lists the
stock id each committed plate was solved on.

ReleaseLedgerStock is the ledger stock of one Make Nests run: each nest
is offered the free sheets and drops Auto Nest's ledger load would tick
for its material, grade and thickness (substitutes included), minus
codes on other open server nests (read once per run) and codes earlier
nests of the same run took, which are not on the server until the
release uploads them. Committed ledger plates get their codes as Auto
Nest gives them. An unusable ledger nests on the plate defaults and the
note says why the ledger was not used.
2026-11-03 17:38:07 -05:00
aj 072e96c08e fix(ci): expect the role-aware image healthcheck in the server smoke
The image HEALTHCHECK became /app/healthcheck.sh when the worker role was
added (54ad7ee8), but the container smoke still required the old inline curl
probe, so every Server image validation since then failed before publishing.
Expect the script form and describe it in the storage docs.
2026-11-03 16:19:28 -05:00
ajandClaude Opus 5.5 53d46303cd fix(planning): raise the head out of a cutout's slug after its lead-out
A cutout's lead-out ends in its slug, so the rapid after it always crossed
the cutout just cut and the strict search refused every plan with cutout
lead-outs. The slug is free and may drop or tip, so emission now marks that
rapid to raise the head, and the rapid check accepts a raised rapid that
starts at the lead-out's end and leaves its cutout exactly once. Cutouts
without a lead-out, or with an overcut, still leave from the cut head down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:45:12 -05:00
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
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
825 changed files with 45223 additions and 6190 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" ]
+8
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'
+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__/
+19 -7
View File
@@ -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,9 +41,11 @@ 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` (in Database mode, the server's shared copy once it holds one). Posts live in `Posts/OpenNest.Posts.<Name>/` and deploy to the desktop output's `Posts/` directory. Desktop nest edits go through `BeginChange`/`RecordChange`; see [undo and redo](docs/undo-redo.md). - `OpenNest`: 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
@@ -48,28 +54,34 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `FillLinear` geometry caches are per public call, keyed by `Program` reference identity; never share them across calls/threads. `PartOverlapChecker` is per check; parts/programs must not mutate during its lifetime. - `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. - 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. The one designed raise is the rapid out of a cutout's slug after its lead-out, marked at emission (see the cutting planner doc); no lead-out or an overcut keeps the head down. 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) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback - [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in and the stop at a feature's center line) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material - [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 - [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 - [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 - [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. - [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install. - [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;
} }
+4 -1
View File
@@ -17,7 +17,10 @@ namespace OpenNest.Avalonia
var args = desktop.Args ?? System.Array.Empty<string>(); var args = desktop.Args ?? System.Array.Empty<string>();
if (args.Length > 0) if (args.Length > 0)
window.OpenNest(args[0]); window.OpenNest(args[0]);
// --snapshot <png> [plate]: render the window to an image and exit, for checking the drawing. // --auto-nest: run Auto Nest on the opened nest (with --snapshot, before rendering).
if (System.Array.IndexOf(args, "--auto-nest") >= 0)
window.AutoNestOnOpen();
// --snapshot <png> [plate]: render the window to an image (status line in <png>.txt) and exit.
var snapshot = System.Array.IndexOf(args, "--snapshot"); var snapshot = System.Array.IndexOf(args, "--snapshot");
if (snapshot >= 0 && snapshot + 1 < args.Length) if (snapshot >= 0 && snapshot + 1 < args.Length)
window.SaveSnapshotAndClose(args[snapshot + 1], window.SaveSnapshotAndClose(args[snapshot + 1],
+6 -2
View File
@@ -8,10 +8,13 @@
<DockPanel> <DockPanel>
<Menu DockPanel.Dock="Top"> <Menu DockPanel.Dock="Top">
<MenuItem Header="_File"> <MenuItem Header="_File">
<MenuItem Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" /> <MenuItem x:Name="OpenMenuItem" Header="_Open..." InputGesture="Ctrl+O" Click="OnOpen" />
<Separator /> <Separator />
<MenuItem Header="E_xit" Click="OnExit" /> <MenuItem Header="E_xit" Click="OnExit" />
</MenuItem> </MenuItem>
<MenuItem Header="_Nest">
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
</MenuItem>
<MenuItem Header="_View"> <MenuItem Header="_View">
<MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" /> <MenuItem Header="Zoom to _Fit" InputGesture="F" Click="OnFit" />
<MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" /> <MenuItem Header="_Previous Plate" InputGesture="PageUp" Click="OnPreviousPlate" />
@@ -21,10 +24,11 @@
<Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0"> <Border DockPanel.Dock="Top" Padding="4" BorderThickness="0,0,0,1" BorderBrush="#D0D0D0">
<StackPanel Orientation="Horizontal" Spacing="4"> <StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="Open" Click="OnOpen" /> <Button x:Name="OpenButton" Content="Open" Click="OnOpen" />
<Button Content="Fit" Click="OnFit" /> <Button Content="Fit" Click="OnFit" />
<Button Content="Previous" Click="OnPreviousPlate" /> <Button Content="Previous" Click="OnPreviousPlate" />
<Button Content="Next" Click="OnNextPlate" /> <Button Content="Next" Click="OnNextPlate" />
<Button x:Name="AutoNestButton" Content="Auto Nest" Click="OnAutoNest" />
</StackPanel> </StackPanel>
</Border> </Border>
+119 -3
View File
@@ -1,10 +1,16 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using OpenNest.Desktop;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Forms;
using OpenNest.IO; using OpenNest.IO;
namespace OpenNest.Avalonia namespace OpenNest.Avalonia
@@ -13,12 +19,22 @@ namespace OpenNest.Avalonia
public partial class MainWindow : Window public partial class MainWindow : Window
{ {
private Nest nest; private Document document;
private string fileName; private string fileName;
private string notice;
private CancellationTokenSource nesting;
private Task autoNest = Task.CompletedTask;
private Nest nest => document?.Nest;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); 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(); Canvas.ViewChanged += (_, _) => UpdateStatus();
KeyBindings.Add(new KeyBinding KeyBindings.Add(new KeyBinding
{ {
@@ -31,9 +47,11 @@ namespace OpenNest.Avalonia
public void OpenNest(string path) public void OpenNest(string path)
{ {
if (nesting != null)
return;
try try
{ {
nest = new NestReader(path).Read(); document = new Document { Nest = new NestReader(path).Read() };
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -43,30 +61,44 @@ namespace OpenNest.Avalonia
fileName = Path.GetFileName(path); fileName = Path.GetFileName(path);
Title = $"{fileName} - OpenNest (Avalonia prototype)"; Title = $"{fileName} - OpenNest (Avalonia prototype)";
notice = null;
Canvas.ClearCache(); Canvas.ClearCache();
RefreshPlateList(0);
}
private void RefreshPlateList(int selectIndex)
{
PlateList.ItemsSource = nest.Plates PlateList.ItemsSource = nest.Plates
.Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)") .Select((plate, i) => $"Plate {i + 1} {plate.Size.Length:0.###} x {plate.Size.Width:0.###} ({plate.Parts.Count} parts)")
.ToList(); .ToList();
PlateList.SelectedIndex = nest.Plates.Count > 0 ? 0 : -1; PlateList.SelectedIndex = nest.Plates.Count > 0 ? System.Math.Clamp(selectIndex, 0, nest.Plates.Count - 1) : -1;
} }
/// <summary>Runs Auto Nest once the window opens, for --auto-nest.</summary>
public void AutoNestOnOpen() => Opened += (_, _) => autoNest = RunAutoNestAsync();
public void SaveSnapshotAndClose(string path, int plateNumber) public void SaveSnapshotAndClose(string path, int plateNumber)
{ {
Opened += async (_, _) => Opened += async (_, _) =>
{ {
if (nest?.Plates.Count > 0) if (nest?.Plates.Count > 0)
PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1); PlateList.SelectedIndex = System.Math.Clamp(plateNumber - 1, 0, nest.Plates.Count - 1);
await autoNest;
await System.Threading.Tasks.Task.Delay(500); await System.Threading.Tasks.Task.Delay(500);
var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height); var size = new global::Avalonia.PixelSize((int)Bounds.Width, (int)Bounds.Height);
using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size); using var bitmap = new global::Avalonia.Media.Imaging.RenderTargetBitmap(size);
bitmap.Render(this); bitmap.Render(this);
bitmap.Save(path); bitmap.Save(path);
// The status line beside the image, so headless checks can read it.
File.WriteAllText(path + ".txt", Status.Text);
Close(); Close();
}; };
} }
private async void OnOpen(object sender, RoutedEventArgs e) private async void OnOpen(object sender, RoutedEventArgs e)
{ {
if (nesting != null)
return;
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{ {
Title = "Open Nest", Title = "Open Nest",
@@ -80,6 +112,88 @@ namespace OpenNest.Avalonia
private void OnExit(object sender, RoutedEventArgs e) => Close(); 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 OnFit(object sender, RoutedEventArgs e) => Canvas.ZoomToFit();
private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1); private void OnPreviousPlate(object sender, RoutedEventArgs e) => StepPlate(-1);
@@ -120,6 +234,8 @@ namespace OpenNest.Avalonia
var part = Canvas.HoveredPart ?? Canvas.SelectedPart; var part = Canvas.HoveredPart ?? Canvas.SelectedPart;
if (part != null) if (part != null)
text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}"; text += $" {(ReferenceEquals(part, Canvas.SelectedPart) ? "Selected" : "Over")}: {part.BaseDrawing.Name}";
if (notice != null)
text += $" | {notice}";
Status.Text = text; Status.Text = text;
} }
@@ -11,6 +11,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" /> <ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.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" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
+7 -2
View File
@@ -47,6 +47,10 @@ namespace OpenNest.Benchmark
/// <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; }
@@ -90,10 +94,11 @@ namespace OpenNest.Benchmark
parts, parts,
stock, stock,
new NestJobOptions( new NestJobOptions(
"Fill", "Lattice",
maxPlates, maxPlates,
salvageRate ?? SalvageRate, salvageRate ?? SalvageRate,
minimumSalvageDimension ?? 0 minimumSalvageDimension ?? 0,
offcutCut: OffcutCut
) )
); );
} }
+30 -6
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
) )
); );
@@ -214,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;
@@ -234,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);
@@ -356,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);
@@ -368,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; }
} }
} }
} }
+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;
} }
} }
+102 -20
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)
@@ -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)
{ {
@@ -981,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)
@@ -990,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
@@ -1037,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;
@@ -1059,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;
} }
} }
+12 -7
View File
@@ -196,7 +196,7 @@ public static class AutomaticCutOffPlanner
var y = ySign * horizontalDistance.Value; var y = ySign * horizontalDistance.Value;
var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y)); var limited = plate.CutOffs.Any(c => c?.Axis == CutOffAxis.Horizontal && Near(c.Position.Y, y));
var clamp = clamps.FirstOrDefault(zone => var clamp = clamps.FirstOrDefault(zone =>
y >= zone.Bounds.Bottom - plate.EdgeSpacing.Bottom && y <= zone.Bounds.Top + plate.EdgeSpacing.Bottom); y >= zone.Bounds.Bottom - plate.ClampClearance && y <= zone.Bounds.Top + plate.ClampClearance);
if (limited || clamp != null) if (limited || clamp != null)
{ {
diagnostics.Add(new AutomaticCutOffDiagnostic( diagnostics.Add(new AutomaticCutOffDiagnostic(
@@ -371,7 +371,7 @@ public static class AutomaticCutOffPlanner
// A full-span line, or one with the limits this plan gives its own vertical lines. // A full-span line, or one with the limits this plan gives its own vertical lines.
bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) || bool Spans(CutOff cut) => FullSpanLimits(cut, bounds, settings) ||
horizontalY.HasValue && horizontalY.HasValue && cut.Gaps.Count == 0 &&
Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) && Near(cut.StartLimit ?? bounds.Bottom, startLimit ?? bounds.Bottom) &&
Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel)); Near(cut.EndLimit ?? (bounds.Top + settings.Overtravel), endLimit ?? (bounds.Top + settings.Overtravel));
@@ -460,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);
@@ -473,7 +473,7 @@ public static class AutomaticCutOffPlanner
var candidate = existing == null var candidate = existing == null
? NewVertical(x) ? 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);
@@ -522,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,
@@ -544,10 +544,12 @@ 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, Require(!settings.LockHeadHeight || double.IsFinite(settings.HeadLockOvercut) && settings.HeadLockOvercut > 0,
@@ -652,7 +654,9 @@ public static class AutomaticCutOffPlanner
} }
} }
// A gapped line never runs full span, whatever its limits.
private static bool FullSpanLimits(CutOff cut, Box bounds, CutOffSettings settings) => 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);
@@ -673,6 +677,7 @@ public static class AutomaticCutOffPlanner
} }
private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) => private static bool FullLengthLimits(CutOff cut, Box bounds, CutOffSettings settings) =>
cut.Gaps.Count == 0 &&
Near(cut.StartLimit ?? bounds.Left, bounds.Left) && Near(cut.StartLimit ?? bounds.Left, bounds.Left) &&
Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel); Near(cut.EndLimit ?? (bounds.Right + settings.Overtravel), bounds.Right + settings.Overtravel);
+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)
{ {
@@ -53,7 +53,7 @@ internal enum AutomaticEntryKind
/// <summary> /// <summary>
/// Which automatic entries a perimeter tabbed for support offers (see /// Which automatic entries a perimeter tabbed for support offers (see
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue. /// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalog.
/// </summary> /// </summary>
internal enum SupportEntries internal enum SupportEntries
{ {
@@ -70,9 +70,11 @@ internal enum SupportEntries
/// <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);
@@ -62,7 +62,7 @@ public enum CuttingCommitStatus
Applied, Applied,
Stale, Stale,
InvalidInput, InvalidInput,
Cancelled, Canceled,
Failed Failed
} }
@@ -107,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.");
@@ -126,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);
@@ -200,7 +200,7 @@ internal static class CuttingPlanCommit
// 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)>(); (CutOff CutOff, bool FarEdgeFirst, Program Program)[] CutOffs)>();
@@ -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,8 +43,10 @@ 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.");
@@ -70,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;
} }
@@ -91,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
{ {
@@ -106,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;
} }
@@ -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;
@@ -121,11 +121,16 @@ public sealed class LeadMaterialSnapshot
? "A display perimeter must enclose every other contour." ? "A display perimeter must enclose every other contour."
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); : $"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.");
@@ -142,10 +147,11 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("A display perimeter must enclose every other contour."); throw new ArgumentException("A display perimeter must enclose every other contour.");
if (ringDisplay[outer] && rings.Count == 1) if (ringDisplay[outer] && rings.Count == 1)
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut."); throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
var holes = rings.Where((_, i) => i != outer).ToArray(); var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
for (var i = 0; i < holes.Length; i++) var holes = holeRings.Select(i => rings[i]).ToArray();
for (var j = i + 1; j < holes.Length; j++) for (var i = 0; i < holeRings.Length; i++)
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token)) 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, ringDisplay[outer]); new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
@@ -294,13 +294,25 @@ public static class LeadPathValidator
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;
@@ -56,17 +55,4 @@ internal static class NestedPartContainment
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 or LayerType.DisplayPerimeter && 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);
@@ -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,7 @@ 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, : new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit, c.Gaps,
c.HeadLockOvercut, c.FarEdgeFirst)).ToArray(); c.HeadLockOvercut, c.FarEdgeFirst)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings, settings); return new(plate, records.ToArray(), cutOffs, drawings, settings);
@@ -157,6 +157,8 @@ public sealed class PlateCuttingState
|| cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X) || 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) || !Bits(cutOff.HeadLockOvercut, record.HeadLockOvercut) || cutOff.FarEdgeFirst != record.FarEdgeFirst)
return $"Cutoff {i + 1} definition changed."; return $"Cutoff {i + 1} definition changed.";
} }
@@ -206,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 HeadLockOvercut, bool FarEdgeFirst); double? StartLimit, double? EndLimit, IReadOnlyList<CutOffGap> Gaps, double HeadLockOvercut,
bool FarEdgeFirst);
} }
@@ -39,6 +39,8 @@ 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;
@@ -61,8 +63,9 @@ public sealed class PreparedContours
} }
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, Shape displayOutline) CuttingParameters parameters, Shape displayOutline, PierceZoneMemo pierceZones = null)
{ {
this.pierceZones = pierceZones ?? new PierceZoneMemo();
this.displayOutline = displayOutline; this.displayOutline = displayOutline;
this.shapes = shapes; this.shapes = shapes;
this.scribes = scribes; this.scribes = scribes;
@@ -75,7 +78,7 @@ 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.displayOutline, unpinned.pierceZones)
{ {
contourParameters = new CuttingParameters[shapes.Length]; contourParameters = new CuttingParameters[shapes.Length];
var rejected = new List<CutPinResolution>(); var rejected = new List<CutPinResolution>();
@@ -113,7 +116,7 @@ public sealed class PreparedContours
// a pinned perimeter keeps its pin's settings: the analysis is only kept for review. // 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) private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab)
: this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters, : this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters,
source.displayOutline) source.displayOutline, source.pierceZones)
{ {
for (var contour = 0; contour < Count; contour++) for (var contour = 0; contour < Count; contour++)
{ {
@@ -171,6 +174,9 @@ public sealed class PreparedContours
/// <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();
/// <summary>The pierce-zone resolutions these contours' emissions share; for tests.</summary>
internal PierceZoneMemo PierceZones => pierceZones;
/// <summary> /// <summary>
/// The cut perimeter, emitted after every hole; <see cref="Count"/> (no contour) when the outside /// 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. /// is a display perimeter, so every contour below it is a hole.
@@ -180,7 +186,7 @@ public sealed class PreparedContours
/// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary> /// <summary>False when the outside is a display perimeter that is never cut: all contours are holes.</summary>
public bool HasCutPerimeter => hasCutPerimeter; public bool HasCutPerimeter => hasCutPerimeter;
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary> /// <summary>Resolved pins this preparation could not honor: a tab on an internal contour.</summary>
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>(); 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>
@@ -465,7 +471,9 @@ public sealed class PreparedContours
if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null) if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
return Array.Empty<ContourChoice>(); return Array.Empty<ContourChoice>();
targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray(); targets = targets.Where(t => t.Owner == this && t.ContourOrdinal == contourOrdinal).Take(max).ToArray();
var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(), var depth = System.Math.Max(ParametersFor(contourOrdinal).GetEffectivePierceClearance(),
ApplicableLeadInLength(contourOrdinal));
var found = ErodedPierce.Find(shape, depth,
targets.Select(t => t.Point).ToArray(), max); targets.Select(t => t.Point).ToArray(), max);
var entries = new List<ContourChoice>(); var entries = new List<ContourChoice>();
for (var i = 0; i < found.Pierces.Count; i++) for (var i = 0; i < found.Pierces.Count; i++)
@@ -510,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
@@ -526,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 SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying); 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
@@ -539,7 +547,7 @@ 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,
@@ -560,7 +568,7 @@ public sealed class PreparedContours
/// <summary> /// <summary>
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>): /// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
/// its automatic catalogue then keeps to steadying points unless asked for a fallback. /// its automatic catalog then keeps to steadying points unless asked for a fallback.
/// </summary> /// </summary>
internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal); internal bool HasSupportTab => support != null && !IsPinned(PerimeterOrdinal);
@@ -601,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>
@@ -647,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();
@@ -761,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)
{ {
@@ -783,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);
@@ -792,29 +802,65 @@ 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>
@@ -842,7 +888,7 @@ public sealed class PreparedContours
// reads only the chosen contour and the contour count, so only that contour is cloned. // reads only the chosen contour and the contour count, so only that contour is cloned.
var owned = new Shape[shapes.Length]; var owned = new Shape[shapes.Length];
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone(); owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated( return new ContourCuttingStrategy { Parameters = parameters, PierceZones = pierceZones }.EmitCandidateIsolated(
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal); owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
} }
@@ -855,7 +901,7 @@ public sealed class PreparedContours
// Only a complete emission carries a display perimeter's tail: prefixes are search probes. // Only a complete emission carries a display perimeter's tail: prefixes are search probes.
private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) => private Program EmitOwned(IReadOnlyList<ContourChoice> choices, bool complete) =>
new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared( 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(), PerimeterOrdinal, tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal,
@@ -925,20 +971,20 @@ 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> /// <summary>The bounding box of contour <paramref name="contourOrdinal"/>, local. A routing hint only.</summary>
@@ -960,7 +1006,7 @@ public sealed class PreparedContours
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and /// 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 /// 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 /// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
/// stored centre sits beside the chord's midpoint) is never cut. /// stored center sits beside the chord's midpoint) is never cut.
/// </summary> /// </summary>
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point) private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
{ {
@@ -12,6 +12,9 @@ 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 // 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 // (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
// motions appended to it are judged. See Continue. // motions appended to it are judged. See Continue.
@@ -22,14 +25,20 @@ public sealed class ReleasedContourState
/// <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, partContours = partContours,
partClosed = partClosed, partClosed = partClosed,
@@ -54,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)
{ {
@@ -67,8 +78,10 @@ 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> /// <summary>
@@ -97,7 +110,7 @@ public sealed class ReleasedContourState
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) 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>();
@@ -113,11 +126,19 @@ public sealed class ReleasedContourState
var contourNumber = contoursBefore; var contourNumber = contoursBefore;
var inBreakCut = false; var inBreakCut = false;
var reportedTab = false; var reportedTab = false;
// The contour last closed, and the one a lead-out has just left it into: a cutout's
// lead-out ends in its slug, so the rapid after it must leave over that cutout's own cut.
CutGroup lastClosed = null;
Obstacle lastObstacle = null;
Obstacle exited = null;
var exitPoint = Vector.Zero;
for (var index = 0; index < moves.Count; index++) for (var index = 0; index < moves.Count; index++)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var move = moves[index]; var move = moves[index];
var owned = isFixed?.Invoke(index) == true; var owned = isFixed?.Invoke(index) == true;
if (!move.Rapid && move.Layer != LayerType.Leadout)
exited = null;
if (!move.Rapid && move.Layer == LayerType.Break) if (!move.Rapid && move.Layer == LayerType.Break)
{ {
// A break cut is intentionally open and retained by its uncut tabs: it is never a // A break cut is intentionally open and retained by its uncut tabs: it is never a
@@ -148,8 +169,15 @@ public sealed class ReleasedContourState
loose.Leads.AddRange(leadIns); loose.Leads.AddRange(leadIns);
leadIns.Clear(); leadIns.Clear();
previous = null; previous = null;
var leaving = exited;
exited = null;
if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon) if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon)
continue; continue;
// A raised rapid out of a slug its cutout's lead-out ended in is the designed way
// out: it may cross that cutout once, and passes over the rest with the head up.
if (move.HeadRaised && leaving != null && start.DistanceTo(exitPoint) <= PostVerificationGeometry.Epsilon
&& LeavesOnce(leaving, start, move.End, token))
continue;
var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X), var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X),
System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X), System.Math.Min(start.Y, move.End.Y), System.Math.Max(start.X, move.End.X),
System.Math.Max(start.Y, move.End.Y)); System.Math.Max(start.Y, move.End.Y));
@@ -166,16 +194,24 @@ public sealed class ReleasedContourState
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance) && start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End)); from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token)) if (!PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part, continue;
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " + var crossing = new PostVerificationFinding(PostVerificationKind.RapidCrossing, plate, part,
$"of part {obstacle.Part}.") obstacle.Part, (move.HeadRaised ? "Head-raised" : "Direct") + " XY rapid crosses or touches "
{ + (obstacle.TabMayNotHold
Location = (start + move.End) * 0.5, ? $"the tabbed outside of part {obstacle.Part}, which its tab may not hold level."
RapidStart = start, : $"completed untabbed contour {obstacle.Contour} of part {obstacle.Part}."))
RapidEnd = move.End, {
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token) Location = (start + move.End) * 0.5,
}, owned); 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)
@@ -196,6 +232,11 @@ public sealed class ReleasedContourState
"A lead-out follows an open cutting contour and may cut through its retention gap. " + "A lead-out follows an open cutting contour and may cut through its retention gap. " +
"Rapid safety for that contour requires manual review."), owned); "Rapid safety for that contour requires manual review."), owned);
(previous ?? loose).Leads.Add(move.Curve); (previous ?? loose).Leads.Add(move.Curve);
if (previous != null && ReferenceEquals(previous, lastClosed))
{
exited = lastObstacle;
exitPoint = move.Curve.End;
}
} }
Finish(); Finish();
hasLead = false; hasLead = false;
@@ -251,14 +292,13 @@ public sealed class ReleasedContourState
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.
@@ -267,6 +307,16 @@ 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; partContours = contourNumber;
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
&& !inBreakCut && loose.Leads.Count == 0; && !inBreakCut && loose.Leads.Count == 0;
@@ -294,8 +344,10 @@ public sealed class ReleasedContourState
// no flag or tab configuration is used as evidence of retention. // no flag or tab configuration is used as evidence of retention.
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves))) if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
{ {
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd)); lastObstacle = new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd);
obstacles.Add(lastObstacle);
closed.Add(current); closed.Add(current);
lastClosed = current;
} }
else if (!cutoff) else if (!cutoff)
unfinished.Add(current); unfinished.Add(current);
@@ -303,6 +355,16 @@ public sealed class ReleasedContourState
} }
} }
/// <summary>
/// Whether the rapid from <paramref name="start"/> inside <paramref name="cutout"/> to
/// <paramref name="end"/> outside it meets its cut just once, on the way out: a rapid that
/// leaves a re-entrant cutout and passes back over it crosses the cut where the slug may lie.
/// </summary>
private static bool LeavesOnce(Obstacle cutout, Vector start, Vector end, CancellationToken token) =>
PostVerificationGeometry.Inside(start, cutout.Curves, token)
&& !PostVerificationGeometry.Inside(end, cutout.Curves, token)
&& PostVerificationGeometry.ContactPoints(start, end, cutout.Curves, token).Count == 1;
/// <summary> /// <summary>
/// The closing piece of a contour cut on past its own start along its first cut, as a source /// 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 /// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
@@ -333,7 +395,7 @@ public sealed class ReleasedContourState
/// 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;
@@ -493,5 +555,8 @@ public sealed class ReleasedContourState
{ {
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; }
} }
} }
@@ -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);
@@ -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)
@@ -306,15 +312,25 @@ namespace OpenNest.CNC.CuttingStrategy
var shared = Parameters; var shared = Parameters;
try try
{ {
var inSlug = false;
foreach (var choice in choices) foreach (var choice in choices)
{ {
// A contour's own settings apply to its emission alone; scribes and every // A contour's own settings apply to its emission alone; scribes and every
// other contour keep the shared settings. // other contour keep the shared settings.
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], var arrival = result.Codes.Count;
var leftInSlug = EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
choice.ContourOrdinal == perimeterOrdinal ? 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);
// A cutout's lead-out ends in its slug, which may drop or tip once cut free, so
// the rapid out over the cut is made head up. An overcut or no lead-out leaves
// from the cut itself and stays head down.
if (inSlug && arrival < result.Codes.Count && result.Codes[arrival] is RapidMove or SubProgramCall)
result.Codes.Insert(arrival, new Comment(HeadRaise.Marker));
inSlug = leftInSlug;
} }
} }
finally finally
@@ -433,14 +449,17 @@ namespace OpenNest.CNC.CuttingStrategy
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y); private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b); private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
private void EmitContour( /// <returns>Whether the contour is a cutout left by a lead-out, which ends in its slug.</returns>
private bool EmitContour(
Program program, Program program,
Shape shape, Shape shape,
Vector point, Vector point,
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);
@@ -472,9 +491,11 @@ namespace OpenNest.CNC.CuttingStrategy
leadIn = StraightLeadIn(point, holePierce, out normal); leadIn = StraightLeadIn(point, holePierce, out normal);
else else
{ {
if (stopAtCenterLine)
leadIn = StopAtCenterLine(shape, point, leadIn, normal, winding);
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal, leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
Parameters.GetEffectivePierceClearance()); Parameters.GetEffectivePierceClearance());
leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding); leadIn = PierceZone(memoContour, shape, point, leadIn, normal, winding);
} }
// Build hole sub-program relative to (0,0) // Build hole sub-program relative to (0,0)
@@ -492,7 +513,8 @@ namespace OpenNest.CNC.CuttingStrategy
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle); var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint)); subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding)); var holeLeadOut = leadOut.Generate(relativePoint, normal, winding);
subPgm.Codes.AddRange(holeLeadOut);
subPgm.Mode = Mode.Incremental; subPgm.Mode = Mode.Incremental;
// Deduplicate: check if an identical sub-program already exists // Deduplicate: check if an identical sub-program already exists
@@ -511,11 +533,12 @@ namespace OpenNest.CNC.CuttingStrategy
} }
); );
return; return holeLeadOut.Count > 0;
} }
var turn = LeadTurn(contourType, winding); 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);
@@ -524,9 +547,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(), turn); {
if (stopAtCenterLine)
leadIn = StopAtCenterLine(shape, point, leadIn, leadInNormal, turn);
leadIn = PierceZone(memoContour, shape, point, leadIn, leadInNormal, turn);
}
} }
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn));
var reindexedShape = shape.ReindexAt(point, entity); var reindexedShape = shape.ReindexAt(point, entity);
@@ -539,6 +565,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:
@@ -548,6 +575,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
@@ -556,8 +595,11 @@ 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, turn)); var leadOutCodes = leadOut.Generate(leadOutPoint, leadOutNormal, turn);
program.Codes.AddRange(leadOutCodes);
return contourType != ContourType.External && leadOutCodes.Count > 0;
} }
/// <summary> /// <summary>
@@ -867,10 +909,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)
@@ -899,6 +949,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
@@ -1050,16 +1140,28 @@ 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. <paramref name="turn"/> is the hole's /// manual placement preview. <paramref name="turn"/> is the hole's
/// <see cref="LeadTurn"/>, which places a curved lead's pierce. /// <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, RotationType turn) double pierceRadius, RotationType turn)
{ {
@@ -1071,29 +1173,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, turn))) 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);
@@ -1114,6 +1229,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.
@@ -1127,6 +1333,11 @@ 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
@@ -1147,6 +1358,22 @@ namespace OpenNest.CNC.CuttingStrategy
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);
@@ -1323,7 +1550,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>
@@ -81,9 +81,9 @@ namespace OpenNest.CNC.CuttingStrategy
/// With <see cref="SupportTabs"/> on a plate with a support bed, the longest part (the /// 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 /// 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 /// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies
/// it. 0 turns the rule off. /// it. 0 turns the rule off. New settings start at 6; settings saved without it read as 0.
/// </summary> /// </summary>
public double TabHeldMaxLength { get; set; } 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> /// <summary>Whether a part <paramref name="length"/> long is held by its tab alone (see <see cref="TabHeldMaxLength"/>).</summary>
public bool TabHoldsAt(double length) => public bool TabHoldsAt(double length) =>
@@ -19,10 +19,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// <summary> /// <summary>
/// The pierce search for cutouts too small for their configured lead-in (text, slots, small /// 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. /// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
/// The erosion goes as deep as the pierce clearance when the cutout has room for it, else /// The erosion goes as deep as the caller asks when the cutout has room for it, else almost
/// almost to its pole of inaccessibility. Each target, an entry the planner ranked, gets the /// 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 /// nearest point of the deepest erosion that still reaches the wall beside it, stepping down to
/// half that depth: a letter stroke is pierced on its own centre line next to the entry, and /// half that depth: a letter stroke is pierced on its own center line next to the entry, and
/// only a target with no stroke beside it falls back to the deepest place. The lead then runs /// 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. /// straight from the pierce to the wall.
/// </summary> /// </summary>
@@ -30,7 +30,7 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
/// <summary> /// <summary>
/// The share of a cutout's inscribed radius the erosion keeps when the full clearance does /// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
/// not fit: deep enough to stay near the centre line, with room for every place the /// not fit: deep enough to stay near the center line, with room for every place the
/// cutout is about as wide as its widest. /// cutout is about as wide as its widest.
/// </summary> /// </summary>
public const double DepthFraction = 0.9; public const double DepthFraction = 0.9;
@@ -47,10 +47,10 @@ namespace OpenNest.CNC.CuttingStrategy
/// too small to erode is pierced at its pole for every target. Nothing for an open or /// too small to erode is pierced at its pole for every target. Nothing for an open or
/// degenerate contour. /// degenerate contour.
/// </summary> /// </summary>
public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList<Vector> targets, int max) public static ErodedPierces Find(Shape hole, double depth, IReadOnlyList<Vector> targets, int max)
{ {
if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0 if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
|| !double.IsFinite(clearance)) || !double.IsFinite(depth))
return ErodedPierces.None; return ErodedPierces.None;
// The inscribed radius, from the true wall: the deepest any erosion can go. // The inscribed radius, from the true wall: the deepest any erosion can go.
@@ -64,10 +64,12 @@ namespace OpenNest.CNC.CuttingStrategy
var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole); var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
if (!(inscribed > 0)) if (!(inscribed > 0))
return ErodedPierces.None; 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, // 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. // each by the tolerance; Clipper rounds to its precision. Erode past both.
var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction); depth = System.Math.Min(System.Math.Max(depth, 0), inscribed * DepthFraction);
var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision)); var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision); var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
if (depth + margin >= inscribed - tolerance) if (depth + margin >= inscribed - tolerance)
@@ -84,7 +86,7 @@ namespace OpenNest.CNC.CuttingStrategy
return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed); return new(targets.Take(max).Select(_ => pole).ToArray(), inscribed);
// Each target gets the deepest level that reaches the wall beside it: a stroke // Each target gets the deepest level that reaches the wall beside it: a stroke
// narrower than the cutout's widest place is still pierced on its own centre line, near // narrower than the cutout's widest place is still pierced on its own center line, near
// the entry, rather than only at the widest place. // the entry, rather than only at the widest place.
var pierces = new List<Vector>(); var pierces = new List<Vector>();
var shallowest = depth; var shallowest = depth;
@@ -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);
}
}
+5 -26
View File
@@ -73,31 +73,10 @@ namespace OpenNest.CNC
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked /// 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. /// overcut needs the post's lock and unlock codes, and its markers must be well formed.
/// </summary> /// </summary>
public static void EnsurePostable(Nest nest, bool hasCodes, string postName) public static void EnsurePostable(Nest nest, bool hasCodes, string postName) =>
{ PostMarkers.EnsurePostable(nest, hasCodes, IsMarked, program => LockedMask(program.Codes),
ArgumentNullException.ThrowIfNull(nest); name => $"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
foreach (var plate in nest.Plates) + "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.");
foreach (var part in plate.Parts)
{
if (part?.Program == null || !IsMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCodes)
throw new InvalidOperationException(
$"{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.");
try
{
LockedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
} }
} }
+86
View File
@@ -0,0 +1,86 @@
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, and always
/// marks the rapid out of a cutout's slug after its lead-out (see docs/cutting-planner.md): 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.");
}
}
+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);
}
}
}
}
}
}
+1 -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)
{ {
+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
+201 -22
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,66 @@ 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> /// <summary>
/// How far past the nominal far sheet edge (the edge away from the plate's origin) the /// 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 /// segment reaching that edge cuts with the head height locked; zero for none. Stock can
@@ -189,21 +253,82 @@ 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;
@@ -221,13 +346,13 @@ namespace OpenNest
// A second pierce leaves an uncut gap; a head-locked rapid returns along // A second pierce leaves an uncut gap; a head-locked rapid returns along
// the overcut to where the cut toward the origin begins. // the overcut to where the cut toward the origin begins.
if (cutting && !locked) if (cutting && !locked)
return false; 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);
@@ -237,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;
@@ -262,7 +387,8 @@ 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. // A head-locked overcut replaces the overtravel at the high edge when that is the far edge.
var overtravel = LockedFarEdge(plate) is { High: true } ? 0 : settings.Overtravel; var lockedFarEdge = LockedFarEdge(plate);
var overtravel = lockedFarEdge is { High: true } ? 0 : settings.Overtravel;
double lineStart, double lineStart,
lineEnd, lineEnd,
@@ -281,6 +407,13 @@ namespace OpenNest
lineEnd = EndLimit ?? (bounds.X + bounds.Length + 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)
@@ -313,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)>();
@@ -325,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(
+11 -3
View File
@@ -8,9 +8,9 @@ namespace OpenNest
/// One placed cutting segment of a cut-off: the part that cuts it, where its cut runs along /// 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 /// 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 /// segment before it (or the line's start) to the start of the one after it (or the line's
/// end). The parts the nominal line passes through lie in the gaps between segments, so each /// end), stopping short of a <see cref="CutOff.Gaps">gap</see> between them. The parts the
/// is inside the span of the segment on either side of it; a segment is cut before exactly /// nominal line passes through lie in the gaps between segments, so each is inside the span
/// those parts. /// of the segment on either side of it; a segment is cut before exactly those parts.
/// </summary> /// </summary>
public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd); public sealed record CutOffSegment(Part Part, double Start, double End, double SpanStart, double SpanEnd);
@@ -55,6 +55,14 @@ namespace OpenNest
var (part, start, end) = measured[i]; var (part, start, end) = measured[i];
var spanStart = i == 0 ? System.Math.Min(low, start) : measured[i - 1].End; 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; 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.Add(new CutOffSegment(part, start, end, spanStart, spanEnd));
} }
result.AddRange(whole); result.AddRange(whole);
+2
View File
@@ -25,5 +25,7 @@ namespace OpenNest
/// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary> /// <summary>The head-locked overcut a new cut-off gets from these settings; zero when off.</summary>
public double NewCutOffHeadLockOvercut => LockHeadHeight ? HeadLockOvercut : 0; 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;
}
}
@@ -33,15 +33,21 @@ public static class ClampCutDiagnostics
var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant); var farHigh = CutOff.FarEdgeIsHigh(cut.Axis, plate.Quadrant);
var low = farHigh ? 0 : overcut; var low = farHigh ? 0 : overcut;
var high = farHigh ? overcut : 0; var high = farHigh ? overcut : 0;
var start = cut.Axis == CutOffAxis.Vertical var vertical = cut.Axis == CutOffAxis.Vertical;
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom - low) var from = cut.StartLimit ?? (vertical ? sheet.Bottom : sheet.Left) - low;
: new Vector(cut.StartLimit ?? sheet.Left - low, cut.Position.Y); var to = cut.EndLimit ?? (vertical ? sheet.Top : sheet.Right) + high;
var end = cut.Axis == CutOffAxis.Vertical // A clamp wholly inside a gap meets no cut.
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top + high)
: new Vector(cut.EndLimit ?? sheet.Right + high, cut.Position.Y);
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, long Overcut)[] cutoffs; // Gaps are replaced, never edited in place, so their reference stands for their content.
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End,
IReadOnlyList<CutOffGap> Gaps, long Overcut)[] cutoffs;
private readonly (long Width, long Length, int Quadrant) sheet; private 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?, 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,12 +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?, 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,
Bits(cut.HeadLockOvercut)); cut.Gaps, Bits(cut.HeadLockOvercut));
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
@@ -74,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;
+97 -28
View File
@@ -21,10 +21,19 @@ namespace OpenNest.Diagnostics;
/// is short of the spacing by more than the slack. Zero spacing enforces nothing, so contact stays /// is short of the spacing by more than the slack. Zero spacing enforces nothing, so 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>One part to scan: <see cref="Id"/> is its position in the overlap check's input.
/// The profile loads inside the scan's error boundary; null means no material.</summary>
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,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -36,14 +45,58 @@ 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 NativeContours[parts.Count]; var fine = new NativeContours[parts.Count];
var raw = new SpacingOutline[parts.Count]; var raw = new SpacingOutline[parts.Count];
@@ -51,24 +104,30 @@ internal static class PartSpacingReview
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));
} }
} }
@@ -88,7 +147,8 @@ 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;
@@ -101,20 +161,11 @@ internal static class PartSpacingReview
} }
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 });
} }
} }
} }
@@ -131,16 +182,34 @@ 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;
} }
@@ -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);
} }
@@ -144,8 +147,7 @@ public static class PostVerificationAnalyzer
&& 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)
{ {
@@ -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);
@@ -347,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
@@ -382,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;
@@ -408,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.
@@ -415,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;
}
} }
@@ -450,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,
@@ -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;
} }
+4 -13
View File
@@ -19,7 +19,7 @@ public static class MaximalRectangles
/// <remarks> /// <remarks>
/// The grid has a line through every vertex coordinate plus <paramref name="divisions"/> /// The grid has a line through every vertex coordinate plus <paramref name="divisions"/>
/// evenly spaced lines per axis. A cell is free only when no edge passes through its /// evenly spaced lines per axis. A cell is free only when no edge passes through its
/// interior and its centre is inside the region, so results are exact for regions whose /// interior and its center is inside the region, so results are exact for regions whose
/// edges are all horizontal or vertical. Slanted and curved edges are followed as a /// edges are all horizontal or vertical. Slanted and curved edges are followed as a
/// staircase: results stay inside, but can fall short of the true maximum by up to about /// staircase: results stay inside, but can fall short of the true maximum by up to about
/// one cell on each side. Rotate the region to search other rectangle angles. /// one cell on each side. Rotate the region to search other rectangle angles.
@@ -64,9 +64,9 @@ public static class MaximalRectangles
var crossings = new List<double>(); var crossings = new List<double>();
for (var r = 0; r < rows; r++) for (var r = 0; r < rows; r++)
{ {
// Even-odd scan along the row's centre line. No vertex lies on it, and an edge that // Even-odd scan along the row's center line. No vertex lies on it, and an edge that
// meets it strictly inside a cell has already marked that cell crossed, so each // meets it strictly inside a cell has already marked that cell crossed, so each
// uncrossed cell is on the same side as its centre. // uncrossed cell is on the same side as its center.
var y = (ys[r] + ys[r + 1]) / 2; var y = (ys[r] + ys[r + 1]) / 2;
crossings.Clear(); crossings.Clear();
foreach (var path in paths) foreach (var path in paths)
@@ -279,7 +279,7 @@ public static class MaximalRectangles
foreach (var larger in results) foreach (var larger in results)
{ {
if (IsContainedIn(box, larger)) if (BoundingBox.IsWithin(box, larger))
{ {
dominated = true; dominated = true;
break; break;
@@ -292,13 +292,4 @@ public static class MaximalRectangles
return results; return results;
} }
private static bool IsContainedIn(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;
}
} }
+67 -18
View File
@@ -17,7 +17,7 @@ namespace OpenNest.Geometry
/// <summary> /// <summary>
/// Bounded search for callers that cannot afford refinement along a long thin shape: /// Bounded search for callers that cannot afford refinement along a long thin shape:
/// stops after about <paramref name="maxCells"/> distance evaluations and returns the best /// stops after about <paramref name="maxCells"/> distance evaluations and returns the best
/// cell centre so far. When the starting grid would exceed a quarter of the budget it is /// cell center so far. When the starting grid would exceed a quarter of the budget it is
/// coarsened so the whole shape is sampled first. The result can lie outside a shape /// coarsened so the whole shape is sampled first. The result can lie outside a shape
/// thinner than the remaining refinement, so callers must check it. /// thinner than the remaining refinement, so callers must check it.
/// </summary> /// </summary>
@@ -28,12 +28,16 @@ namespace OpenNest.Geometry
/// A precision proportional to the shape's mean material width (twice the net area over /// A precision proportional to the shape's mean material width (twice the net area over
/// the total outline length, holes included), so small or slender shapes refine further /// the total outline length, holes included), so small or slender shapes refine further
/// than large solid ones. A fixed precision near the shape's size stops at the first /// than large solid ones. A fixed precision near the shape's size stops at the first
/// interior cell. /// interior cell. Holes that leave no net area (a second outline mistaken for a hole, so
/// lying outside the first) fall back to the outline alone: a near-zero precision would
/// refine a pole ridge, such as a rectangle's centerline, into billions of cells.
/// </summary> /// </summary>
public static double ThicknessPrecision(Polygon outer, IList<Polygon> holes = null, double fraction = 0.05) public static double ThicknessPrecision(Polygon outer, IList<Polygon> holes = null, double fraction = 0.05)
{ {
var area = outer.Area(); var outerArea = outer.Area();
var length = ClosedLength(outer); var outerLength = ClosedLength(outer);
var area = outerArea;
var length = outerLength;
if (holes != null) if (holes != null)
{ {
@@ -44,6 +48,13 @@ namespace OpenNest.Geometry
} }
} }
// Equal outlines leave rounding noise, not material.
if (!(area > outerArea * 1e-6) || !(length > 0))
{
area = outerArea;
length = outerLength;
}
if (!(area > 0) || !(length > 0)) if (!(area > 0) || !(length > 0))
return 1e-9; return 1e-9;
@@ -106,6 +117,7 @@ namespace OpenNest.Geometry
} }
var halfCell = cellSize / 2; var halfCell = cellSize / 2;
var boxes = HoleBoxes(holes);
var initial = new List<Cell>(); var initial = new List<Cell>();
@@ -113,7 +125,7 @@ namespace OpenNest.Geometry
{ {
for (var y = minY; y < maxY; y += cellSize) for (var y = minY; y < maxY; y += cellSize)
{ {
initial.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes)); initial.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes, boxes));
// Far from the origin a step smaller than the coordinate's precision // Far from the origin a step smaller than the coordinate's precision
// no longer advances; the grid would never end. // no longer advances; the grid would never end.
if (y + cellSize == y) if (y + cellSize == y)
@@ -125,7 +137,7 @@ namespace OpenNest.Geometry
initial.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist)); initial.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
var bestCell = GetCentroidCell(outer, holes); var bestCell = GetCentroidCell(outer, holes, boxes);
evaluatedCells = initial.Count + 1; evaluatedCells = initial.Count + 1;
for (var i = 0; i < initial.Count; i++) for (var i = 0; i < initial.Count; i++)
@@ -157,7 +169,7 @@ namespace OpenNest.Geometry
halfCell = cell.HalfSize / 2; halfCell = cell.HalfSize / 2;
// Far from the origin a half-step may no longer move the centre; such a cell // Far from the origin a half-step may no longer move the center; such a cell
// cannot be split, and its identical children would multiply without end. // cannot be split, and its identical children would multiply without end.
if (cell.X - halfCell == cell.X || cell.X + halfCell == cell.X if (cell.X - halfCell == cell.X || cell.X + halfCell == cell.X
|| cell.Y - halfCell == cell.Y || cell.Y + halfCell == cell.Y) || cell.Y - halfCell == cell.Y || cell.Y + halfCell == cell.Y)
@@ -165,10 +177,10 @@ namespace OpenNest.Geometry
var newCells = new[] var newCells = new[]
{ {
new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes), new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes, boxes),
new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes), new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes, boxes),
new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes), new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes, boxes),
new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes), new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes, boxes),
}; };
evaluatedCells += newCells.Length; evaluatedCells += newCells.Length;
@@ -182,7 +194,7 @@ namespace OpenNest.Geometry
return new Vector(bestCell.X, bestCell.Y); return new Vector(bestCell.X, bestCell.Y);
} }
private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes) private static Cell GetCentroidCell(Polygon outer, IList<Polygon> holes, Box[] boxes)
{ {
var area = 0.0; var area = 0.0;
var cx = 0.0; var cx = 0.0;
@@ -202,12 +214,12 @@ namespace OpenNest.Geometry
area *= 0.5; area *= 0.5;
if (System.Math.Abs(area) < 1e-10) if (System.Math.Abs(area) < 1e-10)
return new Cell(verts[0].X, verts[0].Y, 0, outer, holes); return new Cell(verts[0].X, verts[0].Y, 0, outer, holes, boxes);
cx /= (6 * area); cx /= (6 * area);
cy /= (6 * area); cy /= (6 * area);
return new Cell(cx, cy, 0, outer, holes); return new Cell(cx, cy, 0, outer, holes, boxes);
} }
private static double PointToPolygonDist(double x, double y, Polygon polygon) private static double PointToPolygonDist(double x, double y, Polygon polygon)
@@ -256,7 +268,7 @@ namespace OpenNest.Geometry
public readonly double Dist; public readonly double Dist;
public readonly double MaxDist; public readonly double MaxDist;
public Cell(double x, double y, double halfSize, Polygon outer, IList<Polygon> holes) public Cell(double x, double y, double halfSize, Polygon outer, IList<Polygon> holes, Box[] boxes)
{ {
X = x; X = x;
Y = y; Y = y;
@@ -269,7 +281,7 @@ namespace OpenNest.Geometry
{ {
for (var i = 0; i < holes.Count; i++) for (var i = 0; i < holes.Count; i++)
{ {
if (holes[i].ContainsPoint(pt)) if (boxes[i].Contains(x, y) && holes[i].ContainsPoint(pt))
{ {
inside = false; inside = false;
break; break;
@@ -277,7 +289,7 @@ namespace OpenNest.Geometry
} }
} }
Dist = PointToAllEdgesDist(x, y, outer, holes); Dist = PointToAllEdgesDist(x, y, outer, holes, boxes);
if (!inside) if (!inside)
Dist = -Dist; Dist = -Dist;
@@ -290,7 +302,8 @@ namespace OpenNest.Geometry
double x, double x,
double y, double y,
Polygon outer, Polygon outer,
IList<Polygon> holes IList<Polygon> holes,
Box[] boxes
) )
{ {
var minDist = PointToPolygonDist(x, y, outer); var minDist = PointToPolygonDist(x, y, outer);
@@ -299,6 +312,9 @@ namespace OpenNest.Geometry
{ {
for (var i = 0; i < holes.Count; i++) for (var i = 0; i < holes.Count; i++)
{ {
// No edge is nearer than its hole's box, so a farther box cannot lower the minimum.
if (boxes[i].Distance(x, y) > minDist)
continue;
var d = PointToPolygonDist(x, y, holes[i]); var d = PointToPolygonDist(x, y, holes[i]);
if (d < minDist) if (d < minDist)
minDist = d; minDist = d;
@@ -307,5 +323,38 @@ namespace OpenNest.Geometry
return minDist; return minDist;
} }
// Each hole's vertex bounds: a cell skips the containment test outside them and the
// edge distances when they are farther than the nearest edge found so far. Both skips
// leave the result exactly as without them; parts with many small holes search far faster.
private static Box[] HoleBoxes(IList<Polygon> holes)
{
if (holes == null)
return null;
var boxes = new Box[holes.Count];
for (var i = 0; i < holes.Count; i++)
{
var box = new Box(double.MaxValue, double.MaxValue, double.MinValue, double.MinValue);
foreach (var v in holes[i].Vertices)
box = new Box(System.Math.Min(box.MinX, v.X), System.Math.Min(box.MinY, v.Y),
System.Math.Max(box.MaxX, v.X), System.Math.Max(box.MaxY, v.Y));
boxes[i] = box;
}
return boxes;
}
private readonly record struct Box(double MinX, double MinY, double MaxX, double MaxY)
{
// Inclusive, so a point on a hole's edge is still tested.
public bool Contains(double x, double y) => x >= MinX && x <= MaxX && y >= MinY && y <= MaxY;
public double Distance(double x, double y)
{
var dx = System.Math.Max(System.Math.Max(MinX - x, x - MaxX), 0);
var dy = System.Math.Max(System.Math.Max(MinY - y, y - MaxY), 0);
return System.Math.Sqrt(dx * dx + dy * dy);
}
}
} }
} }
+2 -2
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Math; using OpenNest.Math;
@@ -120,7 +120,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public List<Line> ToLines() public List<Line> ToLines()
{ {
var list = new List<Line>(); var list = new List<Line>(Vertices.Count);
if (Vertices.Count < 2) if (Vertices.Count < 2)
return list; return list;
+15 -74
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
@@ -585,8 +585,8 @@ namespace OpenNest.Geometry
} }
if ( if (
!TryGetEnds(last.Offset, out _, out var gapStart) !EntityEndpoints.TryGet(last.Offset, out _, out var gapStart)
|| !TryGetEnds(next.Offset, out var gapEnd, out _) || !EntityEndpoints.TryGet(next.Offset, out var gapEnd, out _)
) )
return; return;
@@ -625,7 +625,7 @@ namespace OpenNest.Geometry
corner = default; corner = default;
if ( if (
!TryGetEnds(last, out _, out corner) !EntityEndpoints.TryGet(last, out _, out corner)
|| !TryGetTangents(last, out _, out var d1) || !TryGetTangents(last, out _, out var d1)
|| !TryGetTangents(next, out var d2, out _) || !TryGetTangents(next, out var d2, out _)
) )
@@ -637,26 +637,6 @@ namespace OpenNest.Geometry
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon); || (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
} }
private static bool TryGetEnds(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;
}
}
/// <summary> /// <summary>
/// Direction of travel at the start and end of a line or arc. /// Direction of travel at the start and end of a line or arc.
/// </summary> /// </summary>
@@ -750,64 +730,25 @@ namespace OpenNest.Geometry
/// Normalizes to CW winding before offsetting Left (which is outward for CW), /// Normalizes to CW winding before offsetting Left (which is outward for CW),
/// making the method independent of the original contour winding direction. /// making the method independent of the original contour winding direction.
/// </summary> /// </summary>
internal Shape OffsetOutward(double distance) internal Shape OffsetOutward(double distance) =>
{ OffsetLeftWithWinding(distance, RotationType.CW);
var poly = ToPolygon();
if (
poly == null
|| poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CW
)
return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Shape is CCW — reverse to CW so Left offset goes outward.
var copy = new Shape();
for (var i = Entities.Count - 1; i >= 0; i--)
{
switch (Entities[i])
{
case Line l:
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break;
case Arc a:
copy.Entities.Add(
new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed)
{
Layer = a.Layer,
}
);
break;
case Circle c:
copy.Entities.Add(
new Circle(c.Center, c.Radius)
{
Layer = c.Layer,
Rotation = RotationType.CW,
}
);
break;
}
}
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
}
/// <summary> /// <summary>
/// Offsets the shape inward by the given distance. /// Offsets the shape inward by the given distance.
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW), /// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
/// making the method independent of the original contour winding direction. /// making the method independent of the original contour winding direction.
/// </summary> /// </summary>
internal Shape OffsetInward(double distance) internal Shape OffsetInward(double distance) =>
OffsetLeftWithWinding(distance, RotationType.CCW);
/// <summary>
/// Offsets Left after normalizing the contour to <paramref name="winding"/>.
/// </summary>
private Shape OffsetLeftWithWinding(double distance, RotationType winding)
{ {
var poly = ToPolygon(); var poly = ToPolygon();
if ( if (poly == null || poly.Vertices.Count < 3 || poly.RotationDirection() == winding)
poly == null
|| poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CCW
)
return OffsetEntity(distance, OffsetSide.Left) as Shape; return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Create a reversed copy to avoid mutating shared entity objects. // Create a reversed copy to avoid mutating shared entity objects.
@@ -833,7 +774,7 @@ namespace OpenNest.Geometry
new Circle(c.Center, c.Radius) new Circle(c.Center, c.Radius)
{ {
Layer = c.Layer, Layer = c.Layer,
Rotation = RotationType.CCW, Rotation = winding,
} }
); );
break; break;
+139 -33
View File
@@ -64,47 +64,28 @@ namespace OpenNest.Geometry
if (weldTolerance.HasValue) if (weldTolerance.HasValue)
WeldEndpoints(entityList, weldTolerance.Value); WeldEndpoints(entityList, weldTolerance.Value);
while (entityList.Count > 0) // Chain in list order: each shape starts at the first unused entity and takes the
// first unused entity, in list order, with an end at its current end. An endpoint
// grid finds those candidates without rescanning the whole list at every step.
var index = new EndpointIndex(entityList);
var used = new bool[entityList.Count];
for (var first = 0; first < entityList.Count; first++)
{ {
var next = entityList[0]; if (used[first])
continue;
var next = entityList[first];
used[first] = true;
var shape = new Shape(); var shape = new Shape();
shape.Entities.Add(next); shape.Entities.Add(next);
entityList.RemoveAt(0); var startPoint = EndOf(next);
Vector startPoint = new Vector();
Entity connected; Entity connected;
switch (next.Type) while ((connected = index.TakeConnected(startPoint, entityList, used)) != null)
{
case EntityType.Arc:
var arc = (Arc)next;
startPoint = arc.EndPoint();
break;
case EntityType.Line:
var line = (Line)next;
startPoint = line.EndPoint;
break;
}
while ((connected = GetConnected(startPoint, entityList)) != null)
{ {
shape.Entities.Add(connected); shape.Entities.Add(connected);
entityList.Remove(connected); startPoint = EndOf(connected);
switch (connected.Type)
{
case EntityType.Arc:
var arc = (Arc)connected;
startPoint = arc.EndPoint();
break;
case EntityType.Line:
var line = (Line)connected;
startPoint = line.EndPoint;
break;
}
} }
shape.UpdateBounds(); shape.UpdateBounds();
@@ -205,6 +186,131 @@ namespace OpenNest.Geometry
} }
} }
private static Vector EndOf(Entity entity) => entity.Type switch
{
EntityType.Arc => ((Arc)entity).EndPoint(),
EntityType.Line => ((Line)entity).EndPoint,
_ => new Vector(),
};
// Matches GetConnected entity by entity: true and reversed when only its end matches.
private static bool Connects(Entity entity, Vector pt, double tol)
{
switch (entity.Type)
{
case EntityType.Arc:
var arc = (Arc)entity;
if (arc.StartPoint().DistanceTo(pt) <= tol)
return true;
if (arc.EndPoint().DistanceTo(pt) <= tol)
{
arc.Reverse();
return true;
}
return false;
case EntityType.Line:
var line = (Line)entity;
if (line.StartPoint.DistanceTo(pt) <= tol)
return true;
if (line.EndPoint.DistanceTo(pt) <= tol)
{
line.Reverse();
return true;
}
return false;
default:
return false;
}
}
/// <summary>
/// Entity positions by the grid cells of their endpoints, cells twice the chain
/// tolerance wide, so every endpoint within tolerance of a point lies in its cell or a
/// neighbor, rounding included. Reversing an entity swaps its endpoints, so the index stays valid.
/// Endpoints too far out or not finite to place are checked on every query.
/// </summary>
private sealed class EndpointIndex
{
private const double CellSize = 2 * Tolerance.ChainTolerance;
private const double MaxCell = 1e15;
private readonly Dictionary<(long, long), List<int>> cells = new();
private readonly List<int> unplaced = new();
private readonly List<int> candidates = new();
public EndpointIndex(List<Entity> entities)
{
for (var i = 0; i < entities.Count; i++)
{
var (start, end) = GetEndpoints(entities[i]);
if (!Place(i, start) | !Place(i, end))
unplaced.Add(i);
}
}
private bool Place(int i, Vector point)
{
if (!TryCell(point, out var cell))
return false;
if (!cells.TryGetValue(cell, out var list))
cells[cell] = list = new List<int>();
if (list.Count == 0 || list[^1] != i)
list.Add(i);
return true;
}
private static bool TryCell(Vector point, out (long, long) cell)
{
var x = System.Math.Floor(point.X / CellSize);
var y = System.Math.Floor(point.Y / CellSize);
cell = default;
if (!(System.Math.Abs(x) < MaxCell) || !(System.Math.Abs(y) < MaxCell))
return false;
cell = ((long)x, (long)y);
return true;
}
/// <summary>The first unused entity, in list order, that connects to <paramref name="pt"/>; marked used.</summary>
public Entity TakeConnected(Vector pt, List<Entity> entities, bool[] used)
{
candidates.Clear();
if (TryCell(pt, out var cell))
{
for (var dx = -1L; dx <= 1; dx++)
{
for (var dy = -1L; dy <= 1; dy++)
{
if (cells.TryGetValue((cell.Item1 + dx, cell.Item2 + dy), out var list))
candidates.AddRange(list);
}
}
candidates.AddRange(unplaced);
}
else
{
// A point that cannot be placed is compared with every entity, as before.
for (var i = 0; i < entities.Count; i++)
candidates.Add(i);
}
candidates.Sort();
var previous = -1;
foreach (var i in candidates)
{
if (i == previous || used[i])
continue;
previous = i;
if (Connects(entities[i], pt, Tolerance.ChainTolerance))
{
used[i] = true;
return entities[i];
}
}
return null;
}
}
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry) internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
{ {
var tol = Tolerance.ChainTolerance; var tol = Tolerance.ChainTolerance;
+14 -2
View File
@@ -15,10 +15,22 @@ namespace OpenNest.Geometry
Update(entities); Update(entities);
} }
private void Update(List<Entity> entities) private ShapeProfile()
{ {
var shapes = ShapeBuilder.GetShapes(entities); }
/// <summary>A profile of shapes already built, for callers that need them too; at least one.</summary>
public static ShapeProfile FromShapes(List<Shape> shapes)
{
var profile = new ShapeProfile();
profile.Update(shapes);
return profile;
}
private void Update(List<Entity> entities) => Update(ShapeBuilder.GetShapes(entities));
private void Update(List<Shape> shapes)
{
Perimeter = shapes[0]; Perimeter = shapes[0];
Cutouts = new List<Shape>(); Cutouts = new List<Shape>();
+217
View File
@@ -0,0 +1,217 @@
namespace OpenNest.Geometry
{
/// <summary>
/// Conservative perpendicular-extent tests for slides along an exact axis direction.
/// A ray parallel to an axis can only meet an entity whose extent across that axis holds
/// the ray's perpendicular coordinate. Each test answers "may emit" whenever the matching
/// kernel could still report a hit: margins cover the kernel's own tolerances and its
/// floating-point rounding. A pair with any coordinate or radius that is NaN, infinite
/// or beyond <see cref="CoordinateLimit"/> is always kept, because the kernels' products
/// can then become NaN and still emit.
/// </summary>
internal static class SlideBand
{
internal enum Axis
{
None,
/// <summary>Push along ±X; the perpendicular coordinate is Y.</summary>
PerpendicularY,
/// <summary>Push along ±Y; the perpendicular coordinate is X.</summary>
PerpendicularX,
}
/// <summary>
/// Largest coordinate or radius magnitude the tests reason about. Below it every
/// square and product in the kernels stays finite (the squares stay below 1e301).
/// </summary>
internal const double CoordinateLimit = 1e150;
// RayLine accepts a segment parameter in [-Eps, 1 + Eps], which reaches Eps of the
// segment's perpendicular span past either end. Twice that is kept: the computed
// parameter differs from the exact one by a few ulps, far less than the extra Eps.
// No absolute slack is needed: the kernels translate entities with the same
// expressions as these tests, and a circle point c + r·sinθ rounds monotonically, so
// it never lies outside fl(c ± r).
internal const double SpanSlack = 2 * Math.Tolerance.Epsilon;
// SolveRayCircle accepts a nonnegative discriminant b² − 4ac. For an axis ray that is
// 4(r² − oy²) up to rounding of terms of size ox² + oy² + r², so a hit is possible when
// oy² ≤ r² + ε(1 + ox² + oy² + r²). This ε is hundreds of ulps; the absolute 1 is a
// floor for squares that underflow.
internal const double QuadraticSlack = 1e-13;
public static Axis For(double dirX, double dirY)
{
if (dirY == 0 && (dirX == 1 || dirX == -1))
return Axis.PerpendicularY;
if (dirX == 0 && (dirY == 1 || dirY == -1))
return Axis.PerpendicularX;
return Axis.None;
}
/// <summary>
/// False only when <see cref="SlideEvents.Ray"/> from (vx, vy) along the axis cannot
/// emit against <paramref name="entity"/> translated by (dx, dy).
/// </summary>
public static bool RayMayHit(
Axis axis,
double vx,
double vy,
Entity entity,
double dx,
double dy
)
{
if (!Bounded(vx) || !Bounded(vy))
return true;
switch (entity)
{
case Line line:
{
var p1x = line.pt1.X + dx;
var p1y = line.pt1.Y + dy;
var p2x = line.pt2.X + dx;
var p2y = line.pt2.Y + dy;
if (!Bounded(p1x) || !Bounded(p1y) || !Bounded(p2x) || !Bounded(p2y))
return true;
return PointMayReachSegment(
Perp(axis, vx, vy),
Perp(axis, p1x, p1y),
Perp(axis, p2x, p2y)
);
}
case Arc arc:
return PointMayReachCircle(
axis,
vx,
vy,
arc.Center.X + dx,
arc.Center.Y + dy,
arc.Radius
);
case Circle circle:
return PointMayReachCircle(
axis,
vx,
vy,
circle.Center.X + dx,
circle.Center.Y + dy,
circle.Radius
);
default:
return true;
}
}
/// <summary>
/// False only when no point of the circle (cx, cy, r) can reach the segment along the
/// axis, which is what <see cref="SlideEvents.ArcToLine"/> tests for each arc/line pair.
/// </summary>
public static bool CircleMayReachLine(
Axis axis,
double cx,
double cy,
double r,
double p1x,
double p1y,
double p2x,
double p2y
)
{
if (
!Bounded(cx) || !Bounded(cy) || !Bounded(r)
|| !Bounded(p1x) || !Bounded(p1y) || !Bounded(p2x) || !Bounded(p2y)
)
return true;
var c = Perp(axis, cx, cy);
var a = Perp(axis, p1x, p1y);
var b = Perp(axis, p2x, p2y);
var lo = System.Math.Min(a, b);
var hi = System.Math.Max(a, b);
var radius = System.Math.Abs(r);
var margin = SpanSlack * (hi - lo);
if (c + radius < lo - margin || c - radius > hi + margin)
return false;
return true;
}
/// <summary>
/// False only when two circles sliding along the axis can never touch, the condition
/// <see cref="SlideEvents.CurveTangency"/> solves for both external and internal contact.
/// </summary>
public static bool CirclesMayTouch(
Axis axis,
double movingCx,
double movingCy,
double movingR,
double stationaryCx,
double stationaryCy,
double stationaryR
)
{
if (
!Bounded(movingCx) || !Bounded(movingCy) || !Bounded(movingR)
|| !Bounded(stationaryCx) || !Bounded(stationaryCy) || !Bounded(stationaryR)
)
return true;
var radius = System.Math.Abs(movingR) + System.Math.Abs(stationaryR);
return CenterMayReach(
Para(axis, movingCx, movingCy) - Para(axis, stationaryCx, stationaryCy),
Perp(axis, movingCx, movingCy) - Perp(axis, stationaryCx, stationaryCy),
radius
);
}
private static bool PointMayReachSegment(double v, double a, double b)
{
var lo = System.Math.Min(a, b);
var hi = System.Math.Max(a, b);
var margin = SpanSlack * (hi - lo);
if (v < lo - margin || v > hi + margin)
return false;
return true;
}
private static bool PointMayReachCircle(
Axis axis,
double vx,
double vy,
double cx,
double cy,
double r
)
{
if (!Bounded(cx) || !Bounded(cy) || !Bounded(r))
return true;
return CenterMayReach(
Para(axis, vx, vy) - Para(axis, cx, cy),
Perp(axis, vx, vy) - Perp(axis, cx, cy),
r
);
}
private static bool CenterMayReach(double ox, double oy, double r)
{
var oy2 = oy * oy;
var r2 = r * r;
var slack = QuadraticSlack * (1 + ox * ox + oy2 + r2);
if (oy2 > r2 + slack)
return false;
return true;
}
/// <summary>False for NaN, infinities and magnitudes beyond the coordinate limit.</summary>
private static bool Bounded(double value) => System.Math.Abs(value) <= CoordinateLimit;
internal static double Perp(Axis axis, double x, double y) =>
axis == Axis.PerpendicularY ? y : x;
internal static double Para(Axis axis, double x, double y) =>
axis == Axis.PerpendicularY ? x : y;
}
}
+513
View File
@@ -0,0 +1,513 @@
#nullable enable
using System.Collections.Generic;
namespace OpenNest.Geometry
{
/// <summary>
/// Buckets entity indices by their perpendicular extent for one axis-aligned slide, so a
/// band query returns, in ascending entity order, a superset of the entities the
/// <see cref="SlideBand"/> tests accept. Callers still apply those exact tests; the index
/// only avoids visiting entities that cannot pass them.
/// </summary>
/// <remarks>
/// Each extent is the exact acceptance bound of the matching <see cref="SlideBand"/> test,
/// widened by a reach that covers the quadratic slack of ray-circle and curve-curve tests
/// (which grows with the along-axis distance) plus rounding. Bucket numbers are a
/// monotone function of the coordinate, so a value inside an extent always lands in one of
/// the extent's buckets.
/// </remarks>
internal sealed class SlideBandIndex
{
/// <summary>Entities of one kind an index holds.</summary>
internal enum Kind
{
All,
Lines,
Curves,
}
// Below this many entities a plain scan is as cheap as building buckets.
internal const int MinimumEntities = 24;
private const int MaximumBuckets = 512;
// Merging more sorted bucket lists than this by repeated minimum is slower than a sort.
private const int MaximumMergedBuckets = 16;
private readonly double min;
private readonly double scale;
private readonly int bucketCount;
private readonly int[] starts;
private readonly int[] items;
private SlideBandIndex(double min, double scale, int bucketCount, int[] starts, int[] items)
{
this.min = min;
this.scale = scale;
this.bucketCount = bucketCount;
this.starts = starts;
this.items = items;
}
/// <summary>
/// The widening every extent needs on top of its own exact bound: 2√ε·(1 + separation),
/// where separation is twice the largest along-axis coordinate (the quadratic slack),
/// plus a relative rounding term.
/// NaN when a coordinate is nonfinite or beyond <see cref="SlideBand.CoordinateLimit"/>,
/// where the band tests keep every pair and an index could not stand in for them.
/// </summary>
public static double Reach(double maxAbsAlong, double maxAbsPerpendicular)
{
if (
!(maxAbsAlong <= SlideBand.CoordinateLimit)
|| !(maxAbsPerpendicular <= SlideBand.CoordinateLimit)
)
return double.NaN;
var separation = 2 * maxAbsAlong;
// The second term is headroom for rounding of the extents and the curve tests'
// 2ε·|r|; no test case needs it today, but it costs nothing measurable.
return 2 * System.Math.Sqrt(SlideBand.QuadraticSlack) * (1 + separation)
+ 1e-12 * (1 + separation + maxAbsPerpendicular);
}
/// <summary>
/// A curve's half-width before widening, |r|. The quadratic tests accept at most
/// |oy| ≤ |r|·(1 + 2ε) + 2√ε·(1 + |ox|) with ε = QuadraticSlack; the reach holds both the
/// second term and the first one's 2ε·|r|, since its rounding term is 1e-12 times a bound
/// that includes every radius.
/// </summary>
public static double CurveReach(double r) => System.Math.Abs(r);
/// <summary>
/// Indexes the entities of <paramref name="kind"/> in <paramref name="entities"/>
/// translated by (dx, dy), or returns null when the list is small or the geometry or
/// reach is not usable.
/// </summary>
public static SlideBandIndex? Build(
SlideBand.Axis axis,
List<Entity> entities,
double dx,
double dy,
double reach,
Kind kind
)
{
var count = entities.Count;
if (count < MinimumEntities || !double.IsFinite(reach) || reach < 0)
return null;
var lo = new double[count];
var hi = new double[count];
var indexed = new bool[count];
var members = 0;
var min = double.PositiveInfinity;
var max = double.NegativeInfinity;
for (var i = 0; i < count; i++)
{
if (!TryExtent(axis, entities[i], dx, dy, kind, out var a, out var b))
continue;
a -= reach;
b += reach;
if (!double.IsFinite(a) || !double.IsFinite(b))
return null;
lo[i] = a;
hi[i] = b;
indexed[i] = true;
members++;
if (a < min)
min = a;
if (b > max)
max = b;
}
if (members == 0)
return new SlideBandIndex(0, 0, 1, new int[2], System.Array.Empty<int>());
var buckets = System.Math.Min(MaximumBuckets, members);
var span = max - min;
var scale = span > 0 ? buckets / span : 0;
if (!double.IsFinite(scale) || scale <= 0)
{
scale = 0;
buckets = 1;
}
var shape = new SlideBandIndex(
min,
scale,
buckets,
System.Array.Empty<int>(),
System.Array.Empty<int>()
);
var next = new int[buckets + 1];
for (var i = 0; i < count; i++)
{
if (!indexed[i])
continue;
var last = shape.Bucket(hi[i]);
for (var b = shape.Bucket(lo[i]); b <= last; b++)
next[b + 1]++;
}
for (var b = 0; b < buckets; b++)
next[b + 1] += next[b];
var starts = (int[])next.Clone();
var items = new int[next[buckets]];
for (var i = 0; i < count; i++)
{
if (!indexed[i])
continue;
var last = shape.Bucket(hi[i]);
for (var b = shape.Bucket(lo[i]); b <= last; b++)
items[next[b]++] = i;
}
return new SlideBandIndex(min, scale, buckets, starts, items);
}
/// <summary>
/// Entities whose extent may contain <paramref name="value"/>, as the item positions
/// [start, end), ascending. Empty when the value lies below every extent; false for a
/// nonfinite value (scan everything).
/// </summary>
public bool TryPoint(double value, out int start, out int end)
{
start = end = 0;
if (!double.IsFinite(value))
return false;
if (value < min)
return true;
var b = Bucket(value);
start = starts[b];
end = starts[b + 1];
return true;
}
/// <summary>
/// Entities whose extent may overlap [<paramref name="from"/>, <paramref name="to"/>],
/// ascending and without repeats, written to <paramref name="result"/>. False for a
/// nonfinite interval (scan everything).
/// </summary>
public bool TryRange(double from, double to, List<int> result)
{
result.Clear();
if (!double.IsFinite(from) || !double.IsFinite(to))
return false;
if (to < min)
return true;
var first = Bucket(from);
var last = Bucket(to);
if (first == last)
{
for (var k = starts[first]; k < starts[first + 1]; k++)
result.Add(items[k]);
return true;
}
var lists = last - first + 1;
if (lists > MaximumMergedBuckets)
{
for (var k = starts[first]; k < starts[last + 1]; k++)
result.Add(items[k]);
result.Sort();
Deduplicate(result);
return true;
}
// Each bucket list is ascending: merge them by repeated minimum, dropping repeats.
System.Span<int> heads = stackalloc int[MaximumMergedBuckets];
for (var b = 0; b < lists; b++)
heads[b] = starts[first + b];
var previous = -1;
while (true)
{
var best = int.MaxValue;
var source = -1;
for (var b = 0; b < lists; b++)
{
var head = heads[b];
if (head < starts[first + b + 1] && items[head] < best)
{
best = items[head];
source = b;
}
}
if (source < 0)
return true;
heads[source]++;
if (best != previous)
result.Add(best);
previous = best;
}
}
public int Item(int position) => items[position];
private static void Deduplicate(List<int> sorted)
{
var write = 0;
for (var k = 0; k < sorted.Count; k++)
{
if (write > 0 && sorted[write - 1] == sorted[k])
continue;
sorted[write++] = sorted[k];
}
sorted.RemoveRange(write, sorted.Count - write);
}
/// <summary>Monotone in <paramref name="value"/>, clamped to the bucket range.</summary>
private int Bucket(double value)
{
if (scale == 0)
return 0;
var b = (value - min) * scale;
if (!(b >= 0))
return 0;
if (b >= bucketCount)
return bucketCount - 1;
return (int)b;
}
/// <summary>
/// The exact acceptance bound of the entity's <see cref="SlideBand"/> tests before
/// widening: a line's span with its RayLine slack, or a curve's circle ±|r| (its slack
/// is in the reach). False for entity types no slide kernel emits for, or not of
/// <paramref name="kind"/>.
/// </summary>
private static bool TryExtent(
SlideBand.Axis axis,
Entity entity,
double dx,
double dy,
Kind kind,
out double lo,
out double hi
)
{
switch (entity)
{
case Line line when kind != Kind.Curves:
{
var a = SlideBand.Perp(axis, line.pt1.X + dx, line.pt1.Y + dy);
var b = SlideBand.Perp(axis, line.pt2.X + dx, line.pt2.Y + dy);
var low = System.Math.Min(a, b);
var high = System.Math.Max(a, b);
var margin = SlideBand.SpanSlack * (high - low);
lo = low - margin;
hi = high + margin;
return true;
}
case Arc arc when kind != Kind.Lines:
return CurveExtent(
axis,
arc.Center.X + dx,
arc.Center.Y + dy,
arc.Radius,
out lo,
out hi
);
case Circle circle when kind != Kind.Lines:
return CurveExtent(
axis,
circle.Center.X + dx,
circle.Center.Y + dy,
circle.Radius,
out lo,
out hi
);
default:
lo = hi = 0;
return false;
}
}
private static bool CurveExtent(
SlideBand.Axis axis,
double cx,
double cy,
double r,
out double lo,
out double hi
)
{
var c = SlideBand.Perp(axis, cx, cy);
var radius = CurveReach(r);
lo = c - radius;
hi = c + radius;
return true;
}
/// <summary>
/// Raises the largest |along| and |perpendicular| coordinate (perpendicular plus radius
/// for curves) of translated vertices, line ends and curve centres; used to size
/// <see cref="Reach"/>. A NaN propagates. Returns the number of curves seen.
/// </summary>
public static int Measure(
SlideBand.Axis axis,
List<Entity> entities,
Vector[] vertices,
double dx,
double dy,
ref double maxAlong,
ref double maxPerpendicular
)
{
var curves = 0;
for (var i = 0; i < vertices.Length; i++)
{
var v = vertices[i];
Accumulate(axis, v.X + dx, v.Y + dy, 0, ref maxAlong, ref maxPerpendicular);
}
for (var i = 0; i < entities.Count; i++)
{
switch (entities[i])
{
case Line line:
Accumulate(
axis,
line.pt1.X + dx,
line.pt1.Y + dy,
0,
ref maxAlong,
ref maxPerpendicular
);
Accumulate(
axis,
line.pt2.X + dx,
line.pt2.Y + dy,
0,
ref maxAlong,
ref maxPerpendicular
);
break;
case Arc arc:
curves++;
Accumulate(
axis,
arc.Center.X + dx,
arc.Center.Y + dy,
arc.Radius,
ref maxAlong,
ref maxPerpendicular
);
break;
case Circle circle:
curves++;
Accumulate(
axis,
circle.Center.X + dx,
circle.Center.Y + dy,
circle.Radius,
ref maxAlong,
ref maxPerpendicular
);
break;
}
}
return curves;
}
private static void Accumulate(
SlideBand.Axis axis,
double x,
double y,
double r,
ref double maxAlong,
ref double maxPerpendicular
)
{
var along = System.Math.Abs(SlideBand.Para(axis, x, y));
var perpendicular = System.Math.Abs(SlideBand.Perp(axis, x, y)) + System.Math.Abs(r);
// Written so a NaN propagates instead of being ignored by Math.Max.
maxAlong = along > maxAlong || double.IsNaN(along) ? along : maxAlong;
maxPerpendicular = perpendicular > maxPerpendicular || double.IsNaN(perpendicular)
? perpendicular
: maxPerpendicular;
}
}
/// <summary>
/// The band indexes one <see cref="EntitySlideEvents"/> enumeration uses, built only for
/// the phases that have work: both sides for vertex rays, the line-only side an arc of the
/// other side queries, and the stationary curves the moving curves query.
/// </summary>
internal sealed class SlideBandIndexes
{
public SlideBandIndex? Stationary { get; private init; }
public SlideBandIndex? Moving { get; private init; }
public SlideBandIndex? StationaryLines { get; private init; }
public SlideBandIndex? MovingLines { get; private init; }
public SlideBandIndex? StationaryCurves { get; private init; }
/// <summary>Scratch list for range queries.</summary>
public List<int> Candidates { get; } = new();
/// <summary>
/// Null when neither side is large enough to index or a coordinate is unusable.
/// </summary>
public static SlideBandIndexes? Build(
SlideBand.Axis axis,
List<Entity> moving,
Vector[] movingVertices,
double movingDx,
double movingDy,
List<Entity> stationary,
Vector[] stationaryVertices,
bool arcToLine
)
{
if (
stationary.Count < SlideBandIndex.MinimumEntities
&& moving.Count < SlideBandIndex.MinimumEntities
)
return null;
double along = 0,
perpendicular = 0;
var stationaryCurves = SlideBandIndex.Measure(
axis,
stationary,
stationaryVertices,
0,
0,
ref along,
ref perpendicular
);
var movingCurves = SlideBandIndex.Measure(
axis,
moving,
movingVertices,
movingDx,
movingDy,
ref along,
ref perpendicular
);
var reach = SlideBandIndex.Reach(along, perpendicular);
if (!double.IsFinite(reach))
return null;
var all = SlideBandIndex.Kind.All;
var lines = SlideBandIndex.Kind.Lines;
var curves = SlideBandIndex.Kind.Curves;
return new SlideBandIndexes
{
Stationary = SlideBandIndex.Build(axis, stationary, 0, 0, reach, all),
Moving = SlideBandIndex.Build(axis, moving, movingDx, movingDy, reach, all),
StationaryLines = arcToLine && movingCurves > 0
? SlideBandIndex.Build(axis, stationary, 0, 0, reach, lines)
: null,
MovingLines = arcToLine && stationaryCurves > 0
? SlideBandIndex.Build(axis, moving, movingDx, movingDy, reach, lines)
: null,
StationaryCurves = movingCurves > 0 && stationaryCurves > 0
? SlideBandIndex.Build(axis, stationary, 0, 0, reach, curves)
: null,
};
}
}
}
+13 -26
View File
@@ -37,10 +37,15 @@ namespace OpenNest.Geometry
internal const double IncidenceTolerance = 1e-7; internal const double IncidenceTolerance = 1e-7;
// Drawing data fixes arc centers to finite precision, so a tangent joint's arc end // Drawing data fixes arc centers to finite precision, so a tangent joint's arc end
// can sit a little past its neighbour's end. A contact this close to a joint takes // can sit a little past its neighbor's end. A contact this close to a joint takes
// the joint's tangents; otherwise the neighbour reads as a second boundary there. // the joint's tangents; otherwise the neighbor reads as a second boundary there.
internal const double JointTolerance = 1e-6; internal const double JointTolerance = 1e-6;
// Loops are recovered with ShapeBuilder's chaining tolerance: a source contour that
// closes within it (PEP rounding leaves 1e-6 gaps) is a loop everywhere else, and an
// unrecovered loop leaves every contact with the whole part unresolved and blocking.
internal const double ChainTolerance = Tolerance.ChainTolerance;
private readonly List<Entity> entities; private readonly List<Entity> entities;
private readonly int[] loopOf; private readonly int[] loopOf;
private readonly int[] previous; private readonly int[] previous;
@@ -83,11 +88,11 @@ namespace OpenNest.Geometry
} }
else else
{ {
if (!TryEndpoints(entities[i], out var start, out _)) if (!EntityEndpoints.TryGet(entities[i], out var start, out _))
return Unresolved(entities); return Unresolved(entities);
var closed = false; var closed = false;
while (i < count && TryEndpoints(entities[i], out _, out var end)) while (i < count && EntityEndpoints.TryGet(entities[i], out _, out var end))
{ {
// A lone closed arc is a loop; a lone line cannot be, even when it // A lone closed arc is a loop; a lone line cannot be, even when it
// has zero length and so ends where it starts. // has zero length and so ends where it starts.
@@ -100,7 +105,7 @@ namespace OpenNest.Geometry
if ( if (
i + 1 >= count i + 1 >= count
|| !TryEndpoints(entities[i + 1], out var nextStart, out _) || !EntityEndpoints.TryGet(entities[i + 1], out var nextStart, out _)
|| !Near(nextStart, end) || !Near(nextStart, end)
) )
break; break;
@@ -258,11 +263,11 @@ namespace OpenNest.Geometry
} }
private bool StartsNear(int index, Vector point) => private bool StartsNear(int index, Vector point) =>
TryEndpoints(entities[index], out var start, out _) EntityEndpoints.TryGet(entities[index], out var start, out _)
&& start.DistanceTo(point) <= JointTolerance; && start.DistanceTo(point) <= JointTolerance;
private bool EndsNear(int index, Vector point) => private bool EndsNear(int index, Vector point) =>
TryEndpoints(entities[index], out _, out var end) EntityEndpoints.TryGet(entities[index], out _, out var end)
&& end.DistanceTo(point) <= JointTolerance; && end.DistanceTo(point) <= JointTolerance;
private bool InRun(int index, int first, int last) private bool InRun(int index, int first, int last)
@@ -355,25 +360,7 @@ namespace OpenNest.Geometry
} }
} }
private static bool TryEndpoints(Entity entity, out Vector start, out Vector end) private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
{
switch (entity)
{
case Line line:
start = line.pt1;
end = line.pt2;
return true;
case Arc arc:
start = arc.StartPoint();
end = arc.EndPoint();
return true;
default:
start = end = new Vector();
return false;
}
}
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= IncidenceTolerance;
private static double SignedArea(List<Entity> entities, int first, int last) private static double SignedArea(List<Entity> entities, int first, int last)
{ {
+141 -5
View File
@@ -228,7 +228,10 @@ namespace OpenNest.Geometry
double lineDy, double lineDy,
double rayX, double rayX,
double rayY, double rayY,
bool arcMoves bool arcMoves,
SlideBand.Axis band,
SlideBandIndex lineIndex,
List<int> candidates
) )
where TSink : struct, ISlideEventSink where TSink : struct, ISlideEventSink
{ {
@@ -241,8 +244,19 @@ namespace OpenNest.Geometry
var cx = localCx + arcDx; var cx = localCx + arcDx;
var cy = localCy + arcDy; var cy = localCy + arcDy;
for (var j = 0; j < lineEntities.Count; j++) // With an index, visit only the lines CircleMayReachLine can accept, ascending.
var indexed = false;
if (lineIndex != null)
{ {
var c = SlideBand.Perp(band, cx, cy);
var radius = System.Math.Abs(r);
indexed = lineIndex.TryRange(c - radius, c + radius, candidates);
}
var count = indexed ? candidates.Count : lineEntities.Count;
for (var n = 0; n < count; n++)
{
var j = indexed ? candidates[n] : n;
if (lineEntities[j] is not Line line) if (lineEntities[j] is not Line line)
continue; continue;
@@ -250,6 +264,13 @@ namespace OpenNest.Geometry
var p1y = line.pt1.Y + lineDy; var p1y = line.pt1.Y + lineDy;
var p2x = line.pt2.X + lineDx; var p2x = line.pt2.X + lineDx;
var p2y = line.pt2.Y + lineDy; var p2y = line.pt2.Y + lineDy;
if (
band != SlideBand.Axis.None
&& !SlideBand.CircleMayReachLine(band, cx, cy, r, p1x, p1y, p2x, p2y)
)
continue;
var ex = p2x - p1x; var ex = p2x - p1x;
var ey = p2y - p1y; var ey = p2y - p1y;
@@ -476,14 +497,59 @@ namespace OpenNest.Geometry
public void Enumerate<TSink>(ref TSink sink) public void Enumerate<TSink>(ref TSink sink)
where TSink : struct, ISlideEventSink where TSink : struct, ISlideEventSink
{ {
// An axis-aligned push only meets entities whose perpendicular extent can hold the
// ray. The band tests skip only pairs whose kernels cannot emit and keep the
// surviving pairs in their original order, so the emitted events are unchanged.
// For larger boundaries, bucket indexes over those extents limit each query to a
// superset of the accepted entities, still visited in ascending order.
var band = SlideBand.For(dirX, dirY);
var indexes = band == SlideBand.Axis.None
? null
: SlideBandIndexes.Build(
band,
moving,
movingVertices,
movingDx,
movingDy,
stationary,
stationaryVertices,
arcToLine
);
// Phase 1: moving vertices along the push against stationary entities. // Phase 1: moving vertices along the push against stationary entities.
for (var v = 0; v < movingVertices.Length; v++) for (var v = 0; v < movingVertices.Length; v++)
{ {
var vx = movingVertices[v].X + movingDx; var vx = movingVertices[v].X + movingDx;
var vy = movingVertices[v].Y + movingDy; var vy = movingVertices[v].Y + movingDy;
if (
indexes?.Stationary != null
&& indexes.Stationary.TryPoint(
SlideBand.Perp(band, vx, vy),
out var first,
out var last
)
)
{
for (var k = first; k < last; k++)
{
var j = indexes.Stationary.Item(k);
if (!SlideBand.RayMayHit(band, vx, vy, stationary[j], 0, 0))
continue;
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
if (sink.IsDone)
return;
}
continue;
}
for (var j = 0; j < stationary.Count; j++) for (var j = 0; j < stationary.Count; j++)
{ {
if (
band != SlideBand.Axis.None
&& !SlideBand.RayMayHit(band, vx, vy, stationary[j], 0, 0)
)
continue;
SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true); SlideEvents.Ray(ref sink, vx, vy, stationary[j], 0, 0, dirX, dirY, true);
if (sink.IsDone) if (sink.IsDone)
return; return;
@@ -496,8 +562,44 @@ namespace OpenNest.Geometry
var vx = stationaryVertices[v].X; var vx = stationaryVertices[v].X;
var vy = stationaryVertices[v].Y; var vy = stationaryVertices[v].Y;
if (
indexes?.Moving != null
&& indexes.Moving.TryPoint(
SlideBand.Perp(band, vx, vy),
out var first,
out var last
)
)
{
for (var k = first; k < last; k++)
{
var j = indexes.Moving.Item(k);
if (!SlideBand.RayMayHit(band, vx, vy, moving[j], movingDx, movingDy))
continue;
SlideEvents.Ray(
ref sink,
vx,
vy,
moving[j],
movingDx,
movingDy,
-dirX,
-dirY,
false
);
if (sink.IsDone)
return;
}
continue;
}
for (var j = 0; j < moving.Count; j++) for (var j = 0; j < moving.Count; j++)
{ {
if (
band != SlideBand.Axis.None
&& !SlideBand.RayMayHit(band, vx, vy, moving[j], movingDx, movingDy)
)
continue;
SlideEvents.Ray( SlideEvents.Ray(
ref sink, ref sink,
vx, vx,
@@ -527,7 +629,10 @@ namespace OpenNest.Geometry
0, 0,
dirX, dirX,
dirY, dirY,
true true,
band,
indexes?.StationaryLines,
indexes?.Candidates
); );
if (sink.IsDone) if (sink.IsDone)
return; return;
@@ -541,7 +646,10 @@ namespace OpenNest.Geometry
movingDy, movingDy,
-dirX, -dirX,
-dirY, -dirY,
false false,
band,
indexes?.MovingLines,
indexes?.Candidates
); );
if (sink.IsDone) if (sink.IsDone)
return; return;
@@ -553,11 +661,39 @@ namespace OpenNest.Geometry
if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr)) if (!SlideEvents.TryGetCurve(moving[i], out var mcx, out var mcy, out var mr))
continue; continue;
for (var j = 0; j < stationary.Count; j++) var indexed = false;
if (indexes?.StationaryCurves != null)
{ {
var c = SlideBand.Perp(band, mcx + movingDx, mcy + movingDy);
var reach = SlideBandIndex.CurveReach(mr);
indexed = indexes.StationaryCurves.TryRange(
c - reach,
c + reach,
indexes.Candidates
);
}
var count = indexed ? indexes.Candidates.Count : stationary.Count;
for (var n = 0; n < count; n++)
{
var j = indexed ? indexes.Candidates[n] : n;
if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr)) if (!SlideEvents.TryGetCurve(stationary[j], out var scx, out var scy, out var sr))
continue; continue;
if (
band != SlideBand.Axis.None
&& !SlideBand.CirclesMayTouch(
band,
mcx + movingDx,
mcy + movingDy,
mr,
scx,
scy,
sr
)
)
continue;
SlideEvents.CurveTangency( SlideEvents.CurveTangency(
ref sink, ref sink,
mcx + movingDx, mcx + movingDx,
+18
View File
@@ -85,6 +85,24 @@ namespace OpenNest.Geometry
return new SpacingOutline(perimeter, region.Holes); return new SpacingOutline(perimeter, region.Holes);
} }
/// <summary>This outline, built at the origin, moved to <paramref name="location"/>: what
/// <see cref="Build"/> returns there, without repeating the offset for identical parts.</summary>
public SpacingOutline MovedTo(Vector location)
{
var perimeter = (Polygon)Perimeter.Clone();
ToWorld(perimeter, location);
var holes = new List<Polygon>(Holes.Count);
foreach (var hole in Holes)
{
var moved = (Polygon)hole.Clone();
ToWorld(moved, location);
holes.Add(moved);
}
return new SpacingOutline(perimeter, holes);
}
/// <summary>True when the two outlines share material (holes subtracted).</summary> /// <summary>True when the two outlines share material (holes subtracted).</summary>
public bool Overlaps(SpacingOutline other) public bool Overlaps(SpacingOutline other)
{ {
+5 -1
View File
@@ -791,7 +791,11 @@ namespace OpenNest.Geometry
return vertices.ToArray(); return vertices.ToArray();
} }
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc) /// <summary>
/// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°)
/// if they fall within the arc's angular span.
/// </summary>
internal static void AddArcExtremeVertices(ICollection<Vector> points, Arc arc)
{ {
var a1 = arc.StartAngle; var a1 = arc.StartAngle;
var a2 = arc.EndAngle; var a2 = arc.EndAngle;
+10
View File
@@ -9,5 +9,15 @@ namespace OpenNest;
/// </summary> /// </summary>
public interface IPostVerificationSupport public interface IPostVerificationSupport
{ {
/// <summary>True when the post preserves the placed order for every nest.</summary>
bool PreservesPlacedProgramOrder { get; } bool PreservesPlacedProgramOrder { get; }
/// <summary>
/// Why posting <paramref name="nest"/> with the current settings would not move in the
/// placed order, or null when it would. A post whose order depends on the nest or its
/// settings overrides this; it must return null only when the posted motion sequence
/// provably equals the placed one.
/// </summary>
string PlacedProgramOrderChange(Nest nest) =>
PreservesPlacedProgramOrder ? null : "does not declare that it preserves placed part/contour order and pierce positions";
} }
+13
View File
@@ -0,0 +1,13 @@
namespace OpenNest;
/// <summary>
/// Nesting priority places higher numbers first. Files, the drawing library and server job
/// documents written before that stored a lower-first value under <c>priority</c>; they keep
/// writing it beside the new value so older readers see the same order. Converting negates,
/// which keeps the order and is its own inverse.
/// </summary>
public static class LegacyPriority
{
/// <summary>Converts between a higher-first priority and its lower-first legacy value, either way.</summary>
public static int Convert(int priority) => priority == int.MinValue ? int.MaxValue : -priority;
}
+23 -24
View File
@@ -7,12 +7,21 @@ namespace OpenNest
{ {
public static class PartGeometry public static class PartGeometry
{ {
/// <summary>
/// Nesting material of a program, without lead-ins and lead-outs: a lead is a cut
/// into scrap, not part material, and chaining it into the perimeter leaves an open
/// spur, so the offset contour no longer closes and every slide contact against it
/// blocks.
/// </summary>
private static List<Entity> MaterialEntities(CNC.Program program) =>
ConvertProgram.ToGeometry(program)
.Where(e => e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout)
.ForNesting();
public static List<Line> GetPartLines(Part part, double chordTolerance = 0.001) public static List<Line> GetPartLines(Part part, double chordTolerance = 0.001)
{ {
var entities = ConvertProgram.ToGeometry(part.Program); var entities = MaterialEntities(part.Program);
var shapes = ShapeBuilder.GetShapes( var shapes = ShapeBuilder.GetShapes(entities);
entities.ForNesting()
);
var lines = new List<Line>(); var lines = new List<Line>();
foreach (var shape in shapes) foreach (var shape in shapes)
@@ -55,10 +64,8 @@ namespace OpenNest
public static List<Entity> GetOffsetPerimeterEntities(CNC.Program program, double spacing) public static List<Entity> GetOffsetPerimeterEntities(CNC.Program program, double spacing)
{ {
PerfCounters.CountOffsetPerimeterEntities(); PerfCounters.CountOffsetPerimeterEntities();
var geoEntities = ConvertProgram.ToGeometry(program); var geoEntities = MaterialEntities(program);
var profile = new ShapeProfile( var profile = new ShapeProfile(geoEntities);
geoEntities.ForNesting().ToList()
);
var offsetShape = profile.Perimeter.OffsetOutward(spacing); var offsetShape = profile.Perimeter.OffsetOutward(spacing);
if (offsetShape == null) if (offsetShape == null)
@@ -73,10 +80,8 @@ namespace OpenNest
/// </summary> /// </summary>
public static List<Entity> GetOffsetPartEntities(Part part, double spacing) public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
{ {
var geoEntities = ConvertProgram.ToGeometry(part.Program); var geoEntities = MaterialEntities(part.Program);
var profile = new ShapeProfile( var profile = new ShapeProfile(geoEntities);
geoEntities.ForNesting().ToList()
);
var entities = new List<Entity>(); var entities = new List<Entity>();
var perimeter = profile.Perimeter.OffsetOutward(spacing); var perimeter = profile.Perimeter.OffsetOutward(spacing);
@@ -106,10 +111,8 @@ namespace OpenNest
/// </summary> /// </summary>
public static List<Entity> GetPerimeterEntities(Part part) public static List<Entity> GetPerimeterEntities(Part part)
{ {
var geoEntities = ConvertProgram.ToGeometry(part.Program); var geoEntities = MaterialEntities(part.Program);
var profile = new ShapeProfile( var profile = new ShapeProfile(geoEntities);
geoEntities.ForNesting().ToList()
);
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location); return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
} }
@@ -120,10 +123,8 @@ namespace OpenNest
/// </summary> /// </summary>
public static List<Entity> GetPartEntities(Part part) public static List<Entity> GetPartEntities(Part part)
{ {
var geoEntities = ConvertProgram.ToGeometry(part.Program); var geoEntities = MaterialEntities(part.Program);
var profile = new ShapeProfile( var profile = new ShapeProfile(geoEntities);
geoEntities.ForNesting().ToList()
);
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location); var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
foreach (var cutout in profile.Cutouts) foreach (var cutout in profile.Cutouts)
@@ -165,10 +166,8 @@ namespace OpenNest
Vector vectorDirection Vector vectorDirection
) )
{ {
var entities = ConvertProgram.ToGeometry(part.Program); var entities = MaterialEntities(part.Program);
var shapes = ShapeBuilder.GetShapes( var shapes = ShapeBuilder.GetShapes(entities);
entities.ForNesting()
);
var lines = new List<Line>(); var lines = new List<Line>();
foreach (var shape in shapes) foreach (var shape in shapes)
+136 -32
View File
@@ -98,6 +98,9 @@ namespace OpenNest
// A turned sheet is the same sheet; any other size is no longer the coded item. // A turned sheet is the same sheet; any other size is no longer the coded item.
if (stockItemCodes.Count > 0 && !SameSheet(size, value)) if (stockItemCodes.Count > 0 && !SameSheet(size, value))
stockItemCodes = Array.Empty<string>(); stockItemCodes = Array.Empty<string>();
// An outline is a particular sheet; any other size is a rectangle again.
if (outline != null && (outline.Size.Width != value.Width || outline.Size.Length != value.Length))
outline = null;
size = value; size = value;
ResolveClampRail(); ResolveClampRail();
} }
@@ -105,6 +108,26 @@ namespace OpenNest
private Size size; private Size size;
/// <summary>
/// The sheet's material when it is not a rectangle (see <see cref="SheetOutline"/>), in
/// plate coordinates; null for a rectangular sheet. Setting one makes <see cref="Size"/> its
/// bounding box, and setting any other size drops it. Only the Irregular whole-job engine
/// nests on an outlined sheet; cut-offs are trimmed to it, automatic cut-offs refuse it, and
/// <see cref="Area"/> is its material area.
/// </summary>
public SheetOutline Outline
{
get { return outline; }
set
{
outline = value;
if (value != null)
Size = value.Size;
}
}
private SheetOutline outline;
private static bool SameSheet(Size a, Size b) => private static bool SameSheet(Size a, Size b) =>
a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width; a.Width == b.Width && a.Length == b.Length || a.Width == b.Length && a.Length == b.Width;
@@ -146,6 +169,13 @@ namespace OpenNest
/// </summary> /// </summary>
public ObservableList<CutOff> CutOffs { get; set; } public ObservableList<CutOff> CutOffs { get; set; }
/// <summary>
/// A copy of the settings the cut-offs were last regenerated with, or null before the
/// first regeneration. Saved with the nest, so reopening it rebuilds the same cut-off
/// geometry rather than geometry from built-in defaults.
/// </summary>
public CutOffSettings CutOffSettings { get; set; }
/// <summary> /// <summary>
/// Fixed rectangular machine-clamp keep-outs in plate coordinates /// Fixed rectangular machine-clamp keep-outs in plate coordinates
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization, /// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
@@ -170,6 +200,14 @@ namespace OpenNest
/// </summary> /// </summary>
public ClampRail ClampRail { get; private set; } public ClampRail ClampRail { get; private set; }
/// <summary>
/// The gap part material keeps from every clamp zone: the rail's own
/// <see cref="OpenNest.ClampRail.Clearance"/>, else the bottom edge spacing (D6), never
/// negative. Part spacing never applies to clamps.
/// </summary>
public double ClampClearance =>
System.Math.Max(0, ClampRail?.Clearance ?? EdgeSpacing.Bottom);
/// <summary> /// <summary>
/// The machine support bed this sheet rests on (its nest's, see /// The machine support bed this sheet rests on (its nest's, see
/// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support /// <see cref="Nest.SetSupportBed"/>), or null for none. Display, analysis and support
@@ -303,11 +341,13 @@ namespace OpenNest
public void RegenerateCutOffs(CutOffSettings settings) public void RegenerateCutOffs(CutOffSettings settings)
{ {
var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>(); var saved = new Dictionary<CutOff, List<(int Index, double Start, double End)>>();
var places = new List<int>();
for (var i = Parts.Count - 1; i >= 0; i--) for (var i = Parts.Count - 1; i >= 0; i--)
{ {
if (!Parts[i].BaseDrawing.IsCutOff) if (!Parts[i].BaseDrawing.IsCutOff)
continue; continue;
places.Add(i);
var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing)); var cutoff = CutOffs.FirstOrDefault(c => ReferenceEquals(c.Drawing, Parts[i].BaseDrawing));
if (cutoff != null) if (cutoff != null)
{ {
@@ -343,7 +383,7 @@ namespace OpenNest
indices[k] = previous = best >= 0 ? best : previous; indices[k] = previous = best >= 0 ? best : previous;
} }
return indices; return indices;
}); }, _ => places);
} }
/// <summary> /// <summary>
@@ -353,38 +393,79 @@ namespace OpenNest
/// from it are added at the end. Callers must remove existing cut-off parts first. /// from it are added at the end. Callers must remove existing cut-off parts first.
/// </summary> /// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) => public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, int> sequence) =>
RegenerateCutOffs(settings, (cutoff, extents) => RegenerateCutOffs(settings, null, sequence);
sequence != null && sequence.TryGetValue(cutoff, out var index)
? Enumerable.Range(index, extents.Count).ToArray() : null);
/// <summary> /// <summary>
/// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>, /// As <see cref="RegenerateCutOffs(CutOffSettings, IReadOnlyDictionary{CutOff, int})"/>,
/// but each cut-off lists one index per segment in ascending order along its axis. A list /// but each cut-off lists one index per segment in ascending order along its axis. A list
/// whose length differs from the regenerated segment count places the segments /// whose length differs from the regenerated segment count places the segments
/// consecutively from its smallest index. /// consecutively at its smallest index.
/// </summary> /// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) => public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> sequence) =>
RegenerateCutOffs(settings, sequence, null);
/// <summary>
/// Places each cut-off's regenerated segments where its saved segments were, among the
/// parts saved around them: <paramref name="places"/> lists every saved segment's place;
/// <paramref name="firstPlaces"/> gives only the first, for files that recorded no more
/// (their segments were consecutive, but how many is unknown). Settings other than the
/// saved ones can regenerate a different number of segments; the parts keep their
/// places relative to the cut-offs either way. Where only a first place is known the
/// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it.
/// </summary>
public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary<CutOff, IReadOnlyList<int>> places,
IReadOnlyDictionary<CutOff, int> firstPlaces) =>
RegenerateCutOffs(settings, (cutoff, extents) => RegenerateCutOffs(settings, (cutoff, extents) =>
{ {
if (sequence == null || !sequence.TryGetValue(cutoff, out var indices) || indices == null || indices.Count == 0) if (places != null && places.TryGetValue(cutoff, out var indices) && indices is { Count: > 0 })
return null; {
if (indices.Count != extents.Count) if (indices.Count != extents.Count)
return Enumerable.Range(indices.Min(), extents.Count).ToArray(); return Enumerable.Repeat(indices.Min(), extents.Count).ToArray();
// Program order can run toward the origin; the file lists segments ascending. // Program order can run toward the origin; the file lists segments ascending.
var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray(); var ascending = Enumerable.Range(0, extents.Count).OrderBy(k => extents[k].Start).ToArray();
var result = new int[extents.Count]; var result = new int[extents.Count];
for (var rank = 0; rank < ascending.Length; rank++) for (var rank = 0; rank < ascending.Length; rank++)
result[ascending[rank]] = indices[rank]; result[ascending[rank]] = indices[rank];
return result; return result;
}); }
return firstPlaces != null && firstPlaces.TryGetValue(cutoff, out var first)
? Enumerable.Repeat(first, extents.Count).ToArray() : null;
}, counts =>
{
var saved = new HashSet<int>();
foreach (var list in places?.Values ?? [])
saved.UnionWith(list ?? []);
if (firstPlaces == null)
return saved;
// A cut-off saved by its first place alone held consecutive places, as many as it
// has segments now unless that runs into the next cut-off's first place.
var starts = saved.Concat(firstPlaces.Values).Distinct().Order().ToArray();
foreach (var (cutoff, first) in firstPlaces)
{
var next = starts.FirstOrDefault(start => start > first, int.MaxValue);
var count = System.Math.Max(1, System.Math.Min(counts.GetValueOrDefault(cutoff, 1), next - first));
saved.UnionWith(Enumerable.Range(first, count));
}
return saved;
}, cutoff => firstPlaces != null && firstPlaces.ContainsKey(cutoff));
// sequence gives a cut-off's segment indices in program order from its segments' extents // sequence gives a cut-off's saved place for each segment in program order, from its
// along its axis (NaN when unmeasurable), or null to append them. // segments' extents along its axis (NaN when unmeasurable), or null to append them.
// savedPlaces gives every place cut-off segments held in the saved order, from each
// cut-off's regenerated segment count; the parts held the others, in their order.
// separateLast says which cut-offs go after everything when they split the sheet.
private void RegenerateCutOffs(CutOffSettings settings, private void RegenerateCutOffs(CutOffSettings settings,
Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence) Func<CutOff, IReadOnlyList<(double Start, double End)>, IReadOnlyList<int>> sequence,
Func<IReadOnlyDictionary<CutOff, int>, IEnumerable<int>> savedPlaces,
Func<CutOff, bool> separateLast = null)
{ {
var cache = BuildPerimeterCache(this); var cache = BuildPerimeterCache(this);
var sheet = BoundingBox(includeParts: false);
var placed = new List<(int Index, int Order, Part Part)>(); var placed = new List<(int Index, int Order, Part Part)>();
var separating = new List<Part>();
var counts = new Dictionary<CutOff, int>();
var partCount = Parts.Count;
CutOffSettings = settings?.Copy();
// Regenerate and materialize each cut-off's segments // Regenerate and materialize each cut-off's segments
foreach (var cutoff in CutOffs) foreach (var cutoff in CutOffs)
@@ -399,20 +480,34 @@ namespace OpenNest
var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray(); var parts = programs.Select(program => new Part(cutoff.Drawing, program)).ToArray();
var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray(); var extents = parts.Select(part => CutOffSegments.Extent(part, vertical) ?? (double.NaN, double.NaN)).ToArray();
var indices = sequence?.Invoke(cutoff, extents); var indices = sequence?.Invoke(cutoff, extents);
counts[cutoff] = parts.Length;
for (var k = 0; k < parts.Length; k++) for (var k = 0; k < parts.Length; k++)
{ {
if (indices != null && k < indices.Count) // A line splitting the sheet comes after every part and every other cut-off.
if (separateLast?.Invoke(cutoff) == true && CutOff.SeparatesSheet(parts[k], cutoff, sheet))
separating.Add(parts[k]);
else if (indices != null && k < indices.Count)
placed.Add((indices[k], placed.Count, parts[k])); placed.Add((indices[k], placed.Count, parts[k]));
else else
Parts.Add(parts[k]); Parts.Add(parts[k]);
} }
} }
// Lowest index first: each insert then lands on its saved index, because every part // Saved places count every cut-off segment then on the plate, which may now be more or
// sequenced before it is already in place. Segments sharing an index keep their // fewer. A segment goes after the parts saved before it and the segments placed before
// program order (a later insert at the same index lands before an earlier one). // it, so a changed count never shifts a later cut-off or part. Segments sharing a place
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenByDescending(p => p.Order)) // keep their program order.
Parts.Insert(System.Math.Clamp(index, 0, Parts.Count), part); var saved = (savedPlaces?.Invoke(counts) ?? []).Distinct().Order().ToArray();
var inserted = 0;
foreach (var (index, _, part) in placed.OrderBy(p => p.Index).ThenBy(p => p.Order))
{
var savedBefore = Array.BinarySearch(saved, index);
savedBefore = savedBefore < 0 ? ~savedBefore : savedBefore;
var partsBefore = System.Math.Clamp(index - savedBefore, 0, partCount);
Parts.Insert(partsBefore + inserted++, part);
}
foreach (var part in separating)
Parts.Add(part);
} }
/// <summary> /// <summary>
@@ -456,10 +551,13 @@ namespace OpenNest
// Leads are cutting paths in scrap, not boundary edges. Chaining them // Leads are cutting paths in scrap, not boundary edges. Chaining them
// into the outline can make a closed part appear open and replace its // into the outline can make a closed part appear open and replace its
// recesses with a convex hull. Keep the full geometry for the existing // recesses with a convex hull. A cut-off may cross a lead, so the open-
// conservative fallback when the material contour really is open. // contour hull also leaves them out: hulling a lead's pierce point would
// stop cut-offs far short of the part.
var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin && var outline = entities.Where(e => e.Layer != SpecialLayers.Leadin &&
e.Layer != SpecialLayers.Leadout).ToList(); e.Layer != SpecialLayers.Leadout).ToList();
if (outline.Count == 0)
return null;
var profile = new Geometry.ShapeProfile(outline); var profile = new Geometry.ShapeProfile(outline);
if (profile.Perimeter.IsClosed()) if (profile.Perimeter.IsClosed())
@@ -469,7 +567,7 @@ namespace OpenNest
return perimeter; return perimeter;
} }
var points = entities.CollectPoints(); var points = outline.CollectPoints();
if (points.Count < 3) if (points.Count < 3)
return null; return null;
@@ -526,7 +624,9 @@ namespace OpenNest
{ {
const double oneAndHalfPI = System.Math.PI * 1.5; const double oneAndHalfPI = System.Math.PI * 1.5;
var turned = Outline?.Rotated90(rotationDirection);
Size = new Size(Size.Length, Size.Width); Size = new Size(Size.Length, Size.Width);
Outline = turned;
// After Size swap above, new Size.Width = old Length (old X extent), // After Size swap above, new Size.Width = old Length (old X extent),
// new Size.Length = old Width (old Y extent). // new Size.Length = old Width (old Y extent).
@@ -632,10 +732,12 @@ namespace OpenNest
} }
Rotate(System.Math.PI, centerpt); Rotate(System.Math.PI, centerpt);
Outline = Outline?.Rotated180();
} }
else else
{ {
Rotate(System.Math.PI); Rotate(System.Math.PI);
Outline = Outline?.Rotated180();
Quadrant = (Quadrant + 2) % 4; Quadrant = (Quadrant + 2) % 4;
if (Quadrant == 0) if (Quadrant == 0)
@@ -841,7 +943,8 @@ namespace OpenNest
/// </example> /// </example>
public void AutoSize(double roundingFactor = 1.0) public void AutoSize(double roundingFactor = 1.0)
{ {
if (Parts.Count == 0) // An outlined sheet is a particular piece of material; it cannot be resized to fit.
if (Parts.Count == 0 || Outline != null)
return; return;
var bounds = Parts.GetBoundingBox(); var bounds = Parts.GetBoundingBox();
@@ -890,7 +993,7 @@ namespace OpenNest
/// </summary> /// </summary>
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null) public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
{ {
if (Parts.Count == 0) if (Parts.Count == 0 || Outline != null)
return default; return default;
var bounds = Parts.GetBoundingBox(); var bounds = Parts.GetBoundingBox();
@@ -942,12 +1045,13 @@ namespace OpenNest
} }
/// <summary> /// <summary>
/// Gets the area of the top surface of the plate. /// Gets the area of the top surface of the plate: the material inside its
/// <see cref="Outline"/> when it has one, otherwise the whole rectangle.
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public double Area() public double Area()
{ {
return Size.Width * Size.Length; return Outline?.Area ?? Size.Width * Size.Length;
} }
/// <summary> /// <summary>
+1
View File
@@ -133,6 +133,7 @@ namespace OpenNest
var followsDefaults = trailing != null var followsDefaults = trailing != null
&& trailing.Parts.Count == 0 && trailing.Parts.Count == 0
&& trailing.CutOffs.Count == 0 && trailing.CutOffs.Count == 0
&& trailing.Outline == null
&& trailing.Size.Width == defaults.Size.Width && trailing.Size.Width == defaults.Size.Width
&& trailing.Size.Length == defaults.Size.Length && trailing.Size.Length == defaults.Size.Length
&& trailing.Quadrant == defaults.Quadrant && trailing.Quadrant == defaults.Quadrant
+21 -7
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Converters; using OpenNest.Converters;
@@ -38,10 +39,14 @@ public sealed class PlateRemnant
/// (no limit short of either sheet edge) with no part material beyond it defines the drop: the /// (no limit short of either sheet edge) with no part material beyond it defines the drop: the
/// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It /// sheet past its nominal position, less the plate's kerf for each bounding cut-off line. It
/// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that /// wins over a larger edge offcut. Otherwise the drop is the largest edge offcut that
/// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks. /// <see cref="LargestOffcut"/> finds. Material bounds ignore cut-off parts and marks. A plate
/// with an <see cref="Plate.Outline"/> reports none.
/// </summary> /// </summary>
public static PlateRemnant Expected(Plate plate, double minimumDimension) public static PlateRemnant Expected(Plate plate, double minimumDimension)
{ {
// No rectangular strip is guaranteed inside an outlined sheet.
if (plate.Outline != null)
return null;
// A part with no material (marks only) bounds nothing. // A part with no material (marks only) bounds nothing.
var bounds = plate.Parts var bounds = plate.Parts
.Where(p => !p.BaseDrawing.IsCutOff && p.Program != null) .Where(p => !p.BaseDrawing.IsCutOff && p.Program != null)
@@ -70,11 +75,16 @@ public sealed class PlateRemnant
/// material envelope (<paramref name="left"/> to <paramref name="top"/>) by /// material envelope (<paramref name="left"/> to <paramref name="top"/>) by
/// <paramref name="partSpacing"/>, with both sides at least <paramref name="minimum"/>; null /// <paramref name="partSpacing"/>, with both sides at least <paramref name="minimum"/>; null
/// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage /// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage
/// offcut whole-job costing credits. /// offcut whole-job costing credits. A <paramref name="cut"/> keeps only the strips one cut
/// along that axis frees: <see cref="CutOffAxis.Horizontal"/> the full-width bottom and top
/// strips, <see cref="CutOffAxis.Vertical"/> the full-length left and right ones.
/// </summary> /// </summary>
public static Box LargestOffcut(Box workArea, double partSpacing, public static Box LargestOffcut(Box workArea, double partSpacing,
double left, double bottom, double right, double top, double minimum) double left, double bottom, double right, double top, double minimum,
CutOffAxis? cut = null)
{ {
if (cut is { } axis && !Enum.IsDefined(axis))
throw new ArgumentOutOfRangeException(nameof(cut));
var work = workArea; var work = workArea;
var gap = partSpacing; var gap = partSpacing;
var candidates = new[] var candidates = new[]
@@ -85,8 +95,11 @@ public sealed class PlateRemnant
new Box(right + gap, work.Bottom, work.Right - right - gap, work.Width), new Box(right + gap, work.Bottom, work.Right - right - gap, work.Width),
}; };
Box best = null; Box best = null;
foreach (var candidate in candidates) for (var i = 0; i < candidates.Length; i++)
{ {
var candidate = candidates[i];
if (cut == CutOffAxis.Horizontal && i >= 2 || cut == CutOffAxis.Vertical && i < 2)
continue;
if (candidate.Length >= minimum && candidate.Width >= minimum if (candidate.Length >= minimum && candidate.Width >= minimum
&& (best == null || candidate.Length * candidate.Width > best.Length * best.Width)) && (best == null || candidate.Length * candidate.Width > best.Length * best.Width))
best = candidate; best = candidate;
@@ -148,8 +161,8 @@ public sealed class PlateRemnant
} }
/// <summary> /// <summary>
/// Whether the nominal line runs edge to edge. A head-locked overcut only extends the /// Whether the nominal line runs edge to edge with no gap on the sheet. A head-locked
/// program past the far edge, so the nominal limits decide. /// overcut only extends the program past the far edge, so the nominal limits decide.
/// </summary> /// </summary>
private static bool SpansSheet(CutOff cutOff, Box sheet) private static bool SpansSheet(CutOff cutOff, Box sheet)
{ {
@@ -157,7 +170,8 @@ public sealed class PlateRemnant
var alongMin = vertical ? sheet.Bottom : sheet.Left; var alongMin = vertical ? sheet.Bottom : sheet.Left;
var alongMax = vertical ? sheet.Top : sheet.Right; var alongMax = vertical ? sheet.Top : sheet.Right;
return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon) return (cutOff.StartLimit is not double start || start <= alongMin + Tolerance.Epsilon)
&& (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon); && (cutOff.EndLimit is not double end || end >= alongMax - Tolerance.Epsilon)
&& !cutOff.Gaps.Any(gap => gap.End > alongMin + Tolerance.Epsilon && gap.Start < alongMax - Tolerance.Epsilon);
} }
private static bool Reaches(Box material, bool vertical, double from, double to) private static bool Reaches(Box material, bool vertical, double from, double to)
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.IO;
using System.Reflection;
namespace OpenNest
{
/// <summary>
/// Where a post keeps its own settings: %APPDATA%\OpenNest\Posts\&lt;post assembly&gt;.json.
/// The install folder holds only the shipped defaults, because an update replaces it and
/// would silently reset every edit saved there. The shipped JSON is read until the first save.
/// </summary>
public static class PostSettingsFile
{
/// <summary>Replaces <see cref="Directory"/>, so test runs never read or write the user's settings.</summary>
public static string DirectoryOverride { get; set; }
public static string Directory => DirectoryOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenNest", "Posts");
public static string PathFor(Assembly post) =>
Path.Combine(Directory, Path.GetFileNameWithoutExtension(post.Location) + ".json");
/// <summary>The defaults deployed beside the post's assembly.</summary>
public static string ShippedPathFor(Assembly post) => Path.ChangeExtension(post.Location, ".json");
/// <summary>The saved settings, else the shipped defaults, else null.</summary>
public static string Read(Assembly post)
{
var saved = PathFor(post);
if (File.Exists(saved))
return File.ReadAllText(saved);
var shipped = ShippedPathFor(post);
return File.Exists(shipped) ? File.ReadAllText(shipped) : null;
}
/// <summary>
/// Saves through a temporary file, so a failed write leaves the previous settings intact.
/// </summary>
public static void Write(Assembly post, string json)
{
var path = PathFor(post);
System.IO.Directory.CreateDirectory(Path.GetDirectoryName(path));
var temp = path + ".tmp";
File.WriteAllText(temp, json);
File.Move(temp, path, overwrite: true);
}
}
}
+293
View File
@@ -0,0 +1,293 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// The material of a sheet that is not a rectangle: a remnant, a drop, or a sheet with a
/// burned or damaged area. A closed polygon in plate coordinates (the sheet's lower-left
/// frame, as <see cref="Plate.ClampZones"/>) with optional obstacle polygons for areas inside
/// it that must stay empty. The polygon lies inside the true material and every obstacle
/// polygon contains its true area, so chording never adds room. Its bounding box starts at
/// the origin and is the sheet's <see cref="Size"/>; rectangular code paths keep using that
/// box. Immutable.
/// </summary>
public sealed class SheetOutline : IEquatable<SheetOutline>
{
/// <summary>Chord deviation when importing arcs; the outline is also eroded, and each
/// obstacle inflated, by this much so the stored polygons never reach past the true edges.</summary>
public const double ChordTolerance = SpacingOutline.ChordTolerance;
private const int Precision = ClipperBridge.Precision;
private const double ArcTolerance = 0.001;
private const double MiterLimit = 2.0;
private readonly PathD outline;
private readonly PathsD obstacles;
private readonly PathsD material;
/// <summary>
/// Builds an outline from its vertices and optional obstacles, each a simple polygon of at
/// least three finite vertices with a positive area. Winding is normalized and the whole
/// is translated so the outline's bounds start at the origin; an obstacle wholly outside
/// the outline is dropped.
/// </summary>
/// <exception cref="ArgumentException">A polygon is missing, degenerate, nonfinite or
/// self-intersecting.</exception>
public SheetOutline(IEnumerable<Vector> points, IEnumerable<IEnumerable<Vector>> obstacles = null)
{
ArgumentNullException.ThrowIfNull(points);
var path = SimplePolygon(points, "outline");
var box = Clipper.GetBounds(path);
var shift = new PointD(-box.left, -box.top); // RectD top is the minimum Y.
outline = Clipper.TranslatePath(path, shift.x, shift.y);
Size = new Size(box.Height, box.Width);
this.obstacles = new PathsD();
if (obstacles != null)
{
var index = 0;
foreach (var obstacle in obstacles)
{
index++;
var obstaclePath = Clipper.TranslatePath(SimplePolygon(obstacle, $"obstacle {index}"), shift.x, shift.y);
var inside = Clipper.Intersect(new PathsD { outline }, new PathsD { obstaclePath }, FillRule.NonZero, Precision);
if (inside.Sum(Clipper.Area) > 0)
this.obstacles.Add(obstaclePath);
}
}
material = Clipper.Difference(new PathsD { outline }, this.obstacles, FillRule.NonZero, Precision);
Area = material.Sum(Clipper.Area);
if (!(Area > 0))
throw new ArgumentException("The obstacles leave no material inside the outline.", nameof(obstacles));
Points = outline.Select(p => new Vector(p.x, p.y)).ToArray();
Obstacles = this.obstacles
.Select(o => (IReadOnlyList<Vector>)o.Select(p => new Vector(p.x, p.y)).ToArray())
.ToArray();
}
/// <summary>Vertices of the outline, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<Vector> Points { get; }
/// <summary>Vertices of each obstacle, counterclockwise, in plate coordinates.</summary>
public IReadOnlyList<IReadOnlyList<Vector>> Obstacles { get; }
/// <summary>The bounding box of the outline: the plate's size.</summary>
public Size Size { get; }
/// <summary>Material area: the outline less its obstacles.</summary>
public double Area { get; }
/// <summary>
/// Where the plate's lower-left corner lies in the placement frame of its parts for
/// <paramref name="quadrant"/>: -Length on X in quadrants 2 and 3, -Width on Y in 3 and 4,
/// exactly as <see cref="ClampZoneFrame"/> and the engine stock translate.
/// </summary>
public Vector PlacementOrigin(int quadrant) =>
new(quadrant is 2 or 3 ? -Size.Length : 0, quadrant is 3 or 4 ? -Size.Width : 0);
/// <summary>
/// The outline less its obstacles, translated by <paramref name="origin"/>: outer paths
/// positive, holes negative.
/// </summary>
public PathsD Material(Vector origin) => Translate(material, origin);
/// <summary>
/// The material eroded by <paramref name="inset"/> (round joins) and translated by
/// <paramref name="origin"/>: where a part may lie when it must keep <paramref name="inset"/>
/// from every edge and obstacle. Empty when nothing is left. A nonpositive inset returns
/// the material itself.
/// </summary>
public PathsD Usable(double inset, Vector origin)
{
if (!(inset > 0))
return Material(origin);
var eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
return Translate(eroded, origin);
}
/// <summary>
/// Whether a region lies inside the material eroded by <paramref name="inset"/>, allowing
/// <paramref name="slack"/> outside it: nothing of <paramref name="region"/> (outer paths
/// positive, holes negative, in the frame of <paramref name="origin"/>) is left after
/// subtracting the usable area grown by the slack. The test is exact on the stored polygons;
/// it never loosens for arcs.
/// </summary>
public bool Holds(PathsD region, double inset, Vector origin, double slack)
{
var usable = Usable(inset, origin);
if (slack > 0)
{
usable = Clipper.InflatePaths(usable, slack, JoinType.Miter, EndType.Polygon,
MiterLimit, Precision, ArcTolerance);
}
var outside = Clipper.Difference(region, usable, FillRule.NonZero, Precision);
return outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0;
}
/// <summary>This outline turned a quarter turn about the origin, re-normalized to start
/// at the origin, as <see cref="Plate.Rotate90"/> turns the sheet.</summary>
public SheetOutline Rotated90(RotationType direction)
{
Vector Turn(Vector p) => direction == RotationType.CW ? new Vector(p.Y, -p.X) : new Vector(-p.Y, p.X);
return new SheetOutline(Points.Select(Turn), Obstacles.Select(o => o.Select(Turn)));
}
/// <summary>This outline turned half a turn, re-normalized to start at the origin.</summary>
public SheetOutline Rotated180() =>
new(Points.Select(p => new Vector(-p.X, -p.Y)), Obstacles.Select(o => o.Select(p => new Vector(-p.X, -p.Y))));
/// <summary>
/// An outline from imported geometry: the largest closed contour becomes the outline and
/// every other closed contour an obstacle. Arcs are chorded at <see cref="ChordTolerance"/>,
/// then the outline is eroded and each obstacle inflated by that much, so the stored
/// polygons stay within the true edges. Marks and open contours are ignored.
/// </summary>
/// <exception cref="ArgumentException">No closed contour with a positive area.</exception>
public static SheetOutline FromGeometry(IEnumerable<Entity> entities)
{
ArgumentNullException.ThrowIfNull(entities);
var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
ShapeProfile profile;
try
{
profile = material.Count == 0 ? null : new ShapeProfile(material);
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException)
{
throw new ArgumentException("The geometry has no closed outline.", nameof(entities), exception);
}
if (profile?.Perimeter == null)
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var perimeter = ClipperBridge.ToPath(ClipperBridge.Flatten(profile.Perimeter, ChordTolerance, circumscribe: false), positive: true);
if (perimeter.Count < 3 || !(Clipper.Area(perimeter) > 0))
throw new ArgumentException("The geometry has no closed outline.", nameof(entities));
var outer = Clipper.InflatePaths(new PathsD { perimeter },
-ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
var outline = outer.Where(p => p.Count >= 3).OrderByDescending(Clipper.Area).FirstOrDefault()
?? throw new ArgumentException("The outline is too thin to hold material.", nameof(entities));
var obstacles = new List<IEnumerable<Vector>>();
foreach (var cutout in profile.Cutouts)
{
var hole = ClipperBridge.ToPath(ClipperBridge.Flatten(cutout, ChordTolerance, circumscribe: false), positive: true);
if (hole.Count < 3 || !(Clipper.Area(hole) > 0))
continue;
var grown = Clipper.InflatePaths(new PathsD { hole },
ChordTolerance, JoinType.Round, EndType.Polygon, MiterLimit, Precision, ArcTolerance);
foreach (var path in grown.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
obstacles.Add(path.Select(p => new Vector(p.x, p.y)).ToList());
}
return new SheetOutline(outline.Select(p => new Vector(p.x, p.y)), obstacles);
}
public bool Equals(SheetOutline other)
{
if (other is null)
return false;
if (ReferenceEquals(this, other))
return true;
if (Points.Count != other.Points.Count || Obstacles.Count != other.Obstacles.Count)
return false;
for (var i = 0; i < Points.Count; i++)
if (!Same(Points[i], other.Points[i]))
return false;
for (var k = 0; k < Obstacles.Count; k++)
{
if (Obstacles[k].Count != other.Obstacles[k].Count)
return false;
for (var i = 0; i < Obstacles[k].Count; i++)
if (!Same(Obstacles[k][i], other.Obstacles[k][i]))
return false;
}
return true;
}
public override bool Equals(object obj) => Equals(obj as SheetOutline);
public override int GetHashCode()
{
var hash = new System.HashCode();
hash.Add(Points.Count);
hash.Add(Obstacles.Count);
foreach (var p in Points)
{
hash.Add(BitConverter.DoubleToInt64Bits(p.X));
hash.Add(BitConverter.DoubleToInt64Bits(p.Y));
}
return hash.ToHashCode();
}
public override string ToString() =>
$"{Size} outline, {Points.Count} vertices" + (Obstacles.Count == 0 ? "" : $", {Obstacles.Count} obstacle(s)");
/// <summary>Exact bits: two outlines are the same stock only when nothing moved.</summary>
private static bool Same(Vector a, Vector b) =>
BitConverter.DoubleToInt64Bits(a.X) == BitConverter.DoubleToInt64Bits(b.X)
&& BitConverter.DoubleToInt64Bits(a.Y) == BitConverter.DoubleToInt64Bits(b.Y);
private static PathsD Translate(PathsD paths, Vector origin) =>
origin.X == 0 && origin.Y == 0
? new PathsD(paths.Select(p => new PathD(p)))
: Clipper.TranslatePaths(paths, origin.X, origin.Y);
/// <summary>A counterclockwise copy without the closing or repeated vertices, after
/// checking it is one simple polygon of positive area.</summary>
private static PathD SimplePolygon(IEnumerable<Vector> points, string what)
{
ArgumentNullException.ThrowIfNull(points, nameof(points));
var path = new PathD();
foreach (var p in points)
{
if (!double.IsFinite(p.X) || !double.IsFinite(p.Y))
throw new ArgumentException($"The {what} has a nonfinite vertex.", nameof(points));
if (path.Count > 0 && path[^1].x == p.X && path[^1].y == p.Y)
continue;
path.Add(new PointD(p.X, p.Y));
}
if (path.Count > 1 && path[0].x == path[^1].x && path[0].y == path[^1].y)
path.RemoveAt(path.Count - 1);
if (path.Count < 3)
throw new ArgumentException($"The {what} needs at least three distinct vertices.", nameof(points));
var area = Clipper.Area(path);
if (!(System.Math.Abs(area) > 0))
throw new ArgumentException($"The {what} has no area.", nameof(points));
if (area < 0)
path.Reverse();
// Start at the lowest-left vertex so equal polygons list their vertices alike.
var start = 0;
for (var i = 1; i < path.Count; i++)
if (path[i].y < path[start].y || path[i].y == path[start].y && path[i].x < path[start].x)
start = i;
if (start != 0)
path = new PathD(path.Skip(start).Concat(path.Take(start)));
// A simple polygon unions to itself: one path of the same area. A figure eight or a
// bow tie does not, and a sheet edge that crosses itself is not a sheet.
var union = Clipper.Union(new PathsD { path }, new PathsD(), FillRule.NonZero, Precision);
var scale = System.Math.Pow(10, -Precision);
if (union.Count != 1 || System.Math.Abs(Clipper.Area(union[0]) - System.Math.Abs(area)) > scale * (path.Count + 1) * Perimeter(path))
throw new ArgumentException($"The {what} must be a simple polygon that does not cross itself.", nameof(points));
return path;
}
private static double Perimeter(PathD path)
{
var length = 0.0;
for (var i = 0; i < path.Count; i++)
{
var a = path[i];
var b = path[(i + 1) % path.Count];
length += System.Math.Sqrt((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y));
}
return length;
}
}
}
+9 -18
View File
@@ -252,13 +252,17 @@ public static class DrawingSplitter
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon; && line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
} }
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds) /// <summary>
/// The grid of pieces the split lines cut <paramref name="bounds"/> into, row by row
/// from the bottom-left.
/// </summary>
public static List<Box> BuildClipRegions(IEnumerable<SplitLine> lines, Box bounds)
{ {
var verticals = sortedLines var verticals = lines
.Where(l => l.Axis == CutOffAxis.Vertical) .Where(l => l.Axis == CutOffAxis.Vertical)
.OrderBy(l => l.Position) .OrderBy(l => l.Position)
.ToList(); .ToList();
var horizontals = sortedLines var horizontals = lines
.Where(l => l.Axis == CutOffAxis.Horizontal) .Where(l => l.Axis == CutOffAxis.Horizontal)
.OrderBy(l => l.Position) .OrderBy(l => l.Position)
.ToList(); .ToList();
@@ -737,7 +741,7 @@ public static class DrawingSplitter
continue; continue;
var bbB = shapes[j].BoundingBox; var bbB = shapes[j].BoundingBox;
if (!BoxContainsBox(bbB, bbA)) if (!BoundingBox.IsWithin(bbA, bbB))
continue; continue;
if (!polygons[j].ContainsPoint(repA)) if (!polygons[j].ContainsPoint(repA))
continue; continue;
@@ -775,7 +779,7 @@ public static class DrawingSplitter
continue; continue;
var bbJ = shapes[j].BoundingBox; var bbJ = shapes[j].BoundingBox;
if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) if (!BoundingBox.IsWithin(bbJ, shapes[i].BoundingBox))
continue; continue;
var rep = FirstVertexOf(shapes[j]); var rep = FirstVertexOf(shapes[j]);
@@ -812,19 +816,6 @@ public static class DrawingSplitter
return GetStartPoint(shape.Entities[0]); return GetStartPoint(shape.Entities[0]);
} }
/// <summary>
/// True iff box <paramref name="inner"/> is entirely inside box
/// <paramref name="outer"/> (tolerant comparison).
/// </summary>
private static bool BoxContainsBox(Box outer, Box inner)
{
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;
}
private static Vector GetStartPoint(Entity entity) private static Vector GetStartPoint(Entity entity)
{ {
return entity switch return entity switch
+45 -7
View File
@@ -180,9 +180,47 @@ namespace OpenNest.Support
return new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); return new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
} }
// The full angle range checks every hull edge. // The minimum-area rectangle over every hull edge. Shapes such as a right isosceles
private static BoundingRectangleResult BoundingRectangle(IReadOnlyList<Vector> outline) => // triangle have more than one (its legs' square and its hypotenuse's 2:1 box have the same
RotatingCalipers.MinimumBoundingRectangle(ConvexHull.Compute(outline.ToList()), 0, Math.Angle.TwoPI); // area), and which one rounding picks depends on the part's rotation; of rectangles tied
// for area, the one with the shorter long side wins, so a drawing measures the same
// however it is placed.
private static BoundingRectangleResult BoundingRectangle(IReadOnlyList<Vector> outline)
{
var hull = ConvexHull.Compute(outline.ToList()).Vertices;
var n = hull.Count > 1 && hull[0] == hull[^1] ? hull.Count - 1 : hull.Count;
if (n < 3)
return RotatingCalipers.MinimumBoundingRectangle(outline.ToList());
var candidates = new List<BoundingRectangleResult>(n);
for (var i = 0; i < n; i++)
{
var edge = hull[(i + 1) % n] - hull[i];
var length = System.Math.Sqrt(edge.X * edge.X + edge.Y * edge.Y);
if (length <= Math.Tolerance.Epsilon)
continue;
var u = new Vector(edge.X / length, edge.Y / length);
var v = new Vector(-u.Y, u.X);
double minU = double.MaxValue, maxU = double.MinValue, minV = double.MaxValue, maxV = double.MinValue;
for (var j = 0; j < n; j++)
{
var pu = hull[j].X * u.X + hull[j].Y * u.Y;
var pv = hull[j].X * v.X + hull[j].Y * v.Y;
minU = System.Math.Min(minU, pu);
maxU = System.Math.Max(maxU, pu);
minV = System.Math.Min(minV, pv);
maxV = System.Math.Max(maxV, pv);
}
candidates.Add(new BoundingRectangleResult(System.Math.Atan2(edge.Y, edge.X), maxU - minU, maxV - minV));
}
if (candidates.Count == 0)
return RotatingCalipers.MinimumBoundingRectangle(outline.ToList());
var least = candidates.Min(c => c.Area);
var slack = Math.Tolerance.Epsilon * System.Math.Max(least, 1);
return candidates.Where(c => c.Area <= least + slack)
.MinBy(c => System.Math.Max(c.Width, c.Height));
}
// A point `clear` from the nearest inside corner keeps `clearance` from it; with no // A point `clear` from the nearest inside corner keeps `clearance` from it; with no
// clearance it must still not be the corner. // clearance it must still not be the corner.
@@ -360,19 +398,19 @@ namespace OpenNest.Support
yield break; yield break;
var step = perimeter / TabSamples; var step = perimeter / TabSamples;
var travelled = 0.0; var traveled = 0.0;
var next = step; var next = step;
for (var i = 0; i < outline.Count; i++) for (var i = 0; i < outline.Count; i++)
{ {
var a = outline[i]; var a = outline[i];
var b = outline[(i + 1) % outline.Count]; var b = outline[(i + 1) % outline.Count];
var length = a.DistanceTo(b); var length = a.DistanceTo(b);
while (length > 0 && next < travelled + length) while (length > 0 && next < traveled + length)
{ {
yield return a + (b - a) * ((next - travelled) / length); yield return a + (b - a) * ((next - traveled) / length);
next += step; next += step;
} }
travelled += length; traveled += length;
} }
} }
} }
@@ -28,7 +28,7 @@ namespace OpenNest.TabbedCutouts
public double Length { get; internal set; } public double Length { get; internal set; }
/// <summary> /// <summary>
/// One open cut per span of the contour between tabs, in contour order, travelling /// One open cut per span of the contour between tabs, in contour order, traveling
/// counterclockwise (the slug on the left). /// counterclockwise (the slug on the left).
/// </summary> /// </summary>
public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; } public IReadOnlyList<IReadOnlyList<Entity>> Pieces { get; internal set; }
@@ -65,7 +65,7 @@ namespace OpenNest.TabbedCutouts
/// <summary> /// <summary>
/// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a /// <paramref name="center"/> for tab <paramref name="index"/> limited so the tab keeps a
/// gap from its neighbours around the contour and does not pass the contour's start /// gap from its neighbors around the contour and does not pass the contour's start
/// (which would reorder the tabs). /// (which would reorder the tabs).
/// </summary> /// </summary>
public double ClampTabCenter(int index, double center) public double ClampTabCenter(int index, double center)
+2 -2
View File
@@ -17,7 +17,7 @@ public interface IBrowsePage
/// <summary> /// <summary>
/// Browse state shared by the paged server lists: the requested search, sort and page, and the /// Browse state shared by the paged server lists: the requested search, sort and page, and the
/// last page applied. Every change issues one bounded server query. A response that a later /// last page applied. Every change issues one bounded server query. A response that a later
/// request superseded is discarded (and the earlier request is cancelled), so an older result /// request superseded is discarded (and the earlier request is canceled), so an older result
/// can never replace a newer one. Call from one thread (the UI thread). /// can never replace a newer one. Call from one thread (the UI thread).
/// </summary> /// </summary>
public abstract class BrowseSession<TPage, TSort> : IDisposable public abstract class BrowseSession<TPage, TSort> : IDisposable
@@ -130,7 +130,7 @@ public abstract class BrowseSession<TPage, TSort> : IDisposable
private async Task<bool> RunAsync() private async Task<bool> RunAsync()
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
// Supersede before cancelling: a request that completes synchronously when cancelled // Supersede before canceling: a request that completes synchronously when canceled
// must already see itself as superseded. Each source is released by its own request. // must already see itself as superseded. Each source is released by its own request.
var previous = _pending; var previous = _pending;
using var cancellation = new CancellationTokenSource(); using var cancellation = new CancellationTokenSource();
+13 -2
View File
@@ -19,6 +19,7 @@ public sealed class ClampRailEditor
Width = initial.Width; Width = initial.Width;
Depth = initial.Depth; Depth = initial.Depth;
RemovalMinutes = initial.RemovalMinutes; RemovalMinutes = initial.RemovalMinutes;
Clearance = initial.Clearance;
_clamps.AddRange(initial.Clamps.Order()); _clamps.AddRange(initial.Clamps.Order());
} }
@@ -35,6 +36,9 @@ public sealed class ClampRailEditor
/// length, so it is never converted between units.</summary> /// length, so it is never converted between units.</summary>
public double RemovalMinutes { get; set; } public double RemovalMinutes { get; set; }
/// <summary>The gap parts keep from every clamp; null uses each sheet's bottom edge spacing.</summary>
public double? Clearance { get; set; }
/// <summary>Whether saved machine defaults give new nests these clamps.</summary> /// <summary>Whether saved machine defaults give new nests these clamps.</summary>
public bool ApplyToNewNests { get; set; } public bool ApplyToNewNests { get; set; }
@@ -91,6 +95,7 @@ public sealed class ClampRailEditor
Width = converted.Width; Width = converted.Width;
Depth = converted.Depth; Depth = converted.Depth;
RemovalMinutes = converted.RemovalMinutes; RemovalMinutes = converted.RemovalMinutes;
Clearance = converted.Clearance;
ApplyToNewNests = settings.Enabled; ApplyToNewNests = settings.Enabled;
_clamps.Clear(); _clamps.Clear();
_clamps.AddRange(converted.Clamps.Distinct().Order()); _clamps.AddRange(converted.Clamps.Distinct().Order());
@@ -99,10 +104,10 @@ public sealed class ClampRailEditor
/// <summary> /// <summary>
/// The edited rail, or null when no clamp is listed. Throws /// The edited rail, or null when no clamp is listed. Throws
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a /// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
/// negative/non-finite removal time. /// negative/non-finite removal time or clearance.
/// </summary> /// </summary>
public ClampRail? BuildRail() => public ClampRail? BuildRail() =>
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes); _clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes, Clearance);
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary> /// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
public void ApplyTo(Nest nest) public void ApplyTo(Nest nest)
@@ -128,6 +133,11 @@ public sealed class ClampRailEditor
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 clearance && (!double.IsFinite(clearance) || clearance < 0))
throw new ArgumentOutOfRangeException(
nameof(Clearance),
"Clamp clearance must be finite and zero or more."
);
var settings = new ClampSettings var settings = new ClampSettings
{ {
Enabled = ApplyToNewNests && _clamps.Count > 0, Enabled = ApplyToNewNests && _clamps.Count > 0,
@@ -135,6 +145,7 @@ public sealed class ClampRailEditor
Width = Width, Width = Width,
Depth = Depth, Depth = Depth,
RemovalMinutes = RemovalMinutes, RemovalMinutes = RemovalMinutes,
Clearance = Clearance,
}; };
settings.SetClamps(_clamps); settings.SetClamps(_clamps);
return settings; return settings;
+26 -54
View File
@@ -65,6 +65,12 @@ public sealed class ClampSettings
/// </summary> /// </summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes; public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>
/// The gap parts keep from every clamp, in <see cref="Units"/>; null uses each sheet's
/// bottom edge spacing (<see cref="ClampRail.Clearance"/>).
/// </summary>
public double? Clearance { get; set; }
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary> /// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>(); public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
@@ -133,6 +139,7 @@ public sealed class ClampSettings
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units), Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units), Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes, RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
Clearance = rail?.Clearance,
}; };
if (rail != null) if (rail != null)
settings.SetClamps(rail.Clamps); settings.SetClamps(rail.Clamps);
@@ -150,11 +157,12 @@ public sealed class ClampSettings
return null; return null;
var converted = ConvertedTo(units); var converted = ConvertedTo(units);
return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes); return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes,
converted.Clearance);
} }
/// <summary> /// <summary>
/// A copy with width, depth and centers expressed in <paramref name="units"/> /// A copy with width, depth, clearance and centers expressed in <paramref name="units"/>
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and /// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled. /// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
/// </summary> /// </summary>
@@ -168,6 +176,7 @@ public sealed class ClampSettings
Width = Width * scale, Width = Width * scale,
Depth = Depth * scale, Depth = Depth * scale,
RemovalMinutes = RemovalMinutes, RemovalMinutes = RemovalMinutes,
Clearance = Clearance * scale,
Centers = Centers.Select(c => c * scale).ToArray(), Centers = Centers.Select(c => c * scale).ToArray(),
FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(), FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(),
OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(), OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(),
@@ -207,7 +216,7 @@ public sealed class ClampSettings
{ {
json = File.ReadAllText(path); json = File.ReadAllText(path);
} }
catch (Exception ex) when (IsUnreadableFile(ex)) catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
{ {
status = ClampSettingsStatus.Invalid; status = ClampSettingsStatus.Invalid;
return Fallback; return Fallback;
@@ -238,7 +247,7 @@ public sealed class ClampSettings
foreach (var property in document.RootElement.EnumerateObject()) foreach (var property in document.RootElement.EnumerateObject())
values[property.Name] = property.Value.Clone(); values[property.Name] = property.Value.Clone();
} }
catch (Exception ex) when (IsUnreadableFile(ex)) catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
{ {
status = ClampSettingsStatus.Invalid; status = ClampSettingsStatus.Invalid;
return settings; return settings;
@@ -256,7 +265,7 @@ public sealed class ClampSettings
if (values.TryGetValue("units", out var units)) if (values.TryGetValue("units", out var units))
{ {
if (TryParseUnits(units, out var parsed)) if (SettingsFile.TryParseUnits(units, out var parsed))
settings.Units = parsed; settings.Units = parsed;
else else
ignored = true; ignored = true;
@@ -290,6 +299,15 @@ public sealed class ClampSettings
ignored = true; ignored = true;
} }
// Missing or null keeps the bottom edge spacing; anything else must be a length.
if (values.TryGetValue("clearance", out var clearance) && clearance.ValueKind != JsonValueKind.Null)
{
if (TryLength(clearance, out var value) && value >= 0)
settings.Clearance = value;
else
ignored = true;
}
if (values.TryGetValue("centers", out var centers)) if (values.TryGetValue("centers", out var centers))
settings.Centers = ReadCenters(centers, ref ignored); settings.Centers = ReadCenters(centers, ref ignored);
if (values.TryGetValue("farSideCenters", out var farSide)) if (values.TryGetValue("farSideCenters", out var farSide))
@@ -329,24 +347,7 @@ public sealed class ClampSettings
/// </summary> /// </summary>
public void Save(string path, int maxRetries = 3) public void Save(string path, int maxRetries = 3)
{ {
var json = ToJson(); SettingsFile.Write(path, ToJson(), maxRetries);
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
} }
/// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary> /// <summary>The settings as <see cref="Save"/> writes them, for the shared copy.</summary>
@@ -362,6 +363,8 @@ public sealed class ClampSettings
["removalMinutes"] = RemovalMinutes, ["removalMinutes"] = RemovalMinutes,
["centers"] = Centers, ["centers"] = Centers,
}; };
if (Clearance is double clearance)
dto["clearance"] = clearance;
// Other edges are written only when used, so origin-side-only files stay unchanged. // Other edges are written only when used, so origin-side-only files stay unchanged.
if (FarSideCenters.Count > 0) if (FarSideCenters.Count > 0)
dto["farSideCenters"] = FarSideCenters; dto["farSideCenters"] = FarSideCenters;
@@ -392,35 +395,4 @@ public sealed class ClampSettings
&& double.IsFinite(value) && double.IsFinite(value)
&& double.IsFinite(value * MillimetersPerInch); && double.IsFinite(value * MillimetersPerInch);
} }
/// <summary>
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
/// numeric strings and comma-joined names.
/// </summary>
private static bool TryParseUnits(JsonElement element, out Units units)
{
units = default;
if (element.ValueKind != JsonValueKind.String)
return false;
var text = element.GetString();
foreach (var candidate in Enum.GetValues<Units>())
{
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
{
units = candidate;
return true;
}
}
return false;
}
private static bool IsUnreadableFile(Exception ex) =>
ex
is JsonException
or IOException
or UnauthorizedAccessException
or NotSupportedException
or System.Security.SecurityException;
} }
Loaded 100 of 825 files, more files were not shown because too many files have changed in this diff. Show more