Author SHA1 Message Date
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
116 changed files with 5872 additions and 201 deletions

No files matched your search

+3 -1
View File
@@ -19,9 +19,11 @@ jobs:
matrix:
project:
- OpenNest.Tests
- OpenNest.Desktop.Tests
- OpenNest.Engine.Tests
- OpenNest.IO.Tests
- OpenNest.Server.Tests
- OpenNest.FrontEnd.Tests
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
@@ -70,7 +72,7 @@ jobs:
windows-desktop:
# WinForms tests need a Windows desktop runtime; the Linux job can only
# cross-compile them. FrontEnd.Tests run here too, alongside the desktop.
# cross-compile them. FrontEnd.Tests also run here, beside the desktop build.
runs-on: windows-2022
timeout-minutes: 20
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" ]
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
# mini dump in TestResults, instead of holding the runner until the
# job timeout.
run: |
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.Server.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
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
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
}
+6 -3
View File
@@ -22,7 +22,9 @@ dotnet build OpenNest.sln
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj -c Release
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj -c Release
dotnet test OpenNest.Desktop.Tests/OpenNest.Desktop.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
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
@@ -38,8 +40,9 @@ Releases: follow [the release procedure](docs/releasing.md) and `scripts/Publish
- `OpenNest.Engine`: whole-job API in `Jobs/`, interactive proposals via `PlateFillService`, fill strategies, best-fit pairs, packing, sequencing and rapid planning. `INestingEngine.Solve(NestJob)` returns stock IDs/poses; boundary adapters map drawings and materialize results. `NestJobRunner` validates its candidates before committing demand/stock accounting. Do not assume arbitrary plug-in output or interactive paths received that validation. Job identity is reference-based, not drawing-name-based.
- Built-in whole-job engines live in `OpenNest.Engine/NestingEngines/<Name>/`, named for the jobs they suit; see [nesting engines](docs/nesting-engines.md). A change must beat that engine's current benchmark result with every layout valid. External plug-ins implement `INestingEngine` with a public parameterless constructor and load from `Engines/` beside the host; keep their projects out of this solution.
- `OpenNest.IO`: ACadSharp import/export and ZIP-based `.nest` persistence; depends on Core only (an IO.Tests guard enforces it). Best fits persist only when callers pass `BestFitCacheStore.Shared` as `NestReader`/`NestWriter.BestFitStore`. All DXF-to-Drawing conversion goes through `CadImporter`: `Import` + `BuildDrawing` for editable/reporting flows, `ImportDrawing` for headless callers. Preserve source offsets, entity IDs, suppressed entities and bends. Bend repair is opt-in, requires explicit source units and may not alter cut geometry or unrelated marks.
- `OpenNest`: WinForms UI (`Forms/`, `Controls/PlateView`, `Actions/`). `OpenNest.Data` holds cross-platform persistence; new-nest defaults live in `%APPDATA%\OpenNest\defaults.json` (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.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`: 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. 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.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.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
@@ -70,6 +73,6 @@ Read the relevant contract before changing its behavior:
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, opt-in rolling `dev-build` pre-release from green master pushes, the nest server's `/updates` mirror first in Database mode (GitHub fallback), `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
@@ -11,6 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
@@ -70,9 +70,11 @@ internal enum SupportEntries
/// <summary>
/// One automatic start candidate: the owned contour choice plus its preference kind and a
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
/// owner; nothing here exposes or mutates the underlying shape.
/// 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>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
{
/// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
if (target != null)
{
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
// sides within tolerance); count how many of them are facing sides.
// 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 right = maxX - p.X;
var bottom = p.Y - minY;
var top = maxY - p.Y;
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
if (facingRight && right <= near) facing++;
if (!facingRight && left <= near) facing++;
if (facingTop && top <= near) facing++;
if (!facingTop && bottom <= near) facing++;
}
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
@@ -602,7 +602,7 @@ public sealed class PreparedContours
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
=> CataloguePoints(shape, token)
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
.ToList();
/// <summary>
@@ -763,12 +763,14 @@ public sealed class PreparedContours
/// <summary>
/// The preferred catalogue points for one contour: for each entity, its convex corner
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
/// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
/// 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.
/// </summary>
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CataloguePoints(
Shape shape, CancellationToken token)
{
var found = new List<(int, Vector, AutomaticEntryKind)>();
var found = new List<(int, Vector, AutomaticEntryKind, double)>();
var count = shape.Entities.Count;
if (count == 1 && shape.Entities[0] is Circle)
{
@@ -792,29 +794,65 @@ public sealed class PreparedContours
switch (corner.Kind)
{
case ContourCuttingStrategy.CornerKind.Convex:
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
break;
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Line && Next(i) is Arc:
// A line leaving into an arc: the tangent joint. The reverse travel
// order classifies the same joint from the arc, matched below.
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
found.Add(Joint(i, vertex, (i + 1) % count));
break;
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
// of a plain straight edge are not preferred automatic starts here.
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Arc && Next(i) is Line:
found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
found.Add(Joint(i, vertex, i));
break;
}
}
if (entity is Line line)
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
}
return found;
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>
@@ -867,10 +867,18 @@ namespace OpenNest.CNC.CuttingStrategy
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner.
/// </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 dot = corner.TangentIn.DotProduct(corner.TangentOut);
var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
var dot = tangentIn.DotProduct(tangentOut);
var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
+8
View File
@@ -79,6 +79,14 @@ public interface INestRepository
Task<int> AllocateNestNumberAsync(int year, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not allocate nest names.");
/// <summary>
/// The ledger item codes on the plates of every open (Draft, Quote or To Be Cut) nest,
/// except <paramref name="excludeNestId"/> when it is not empty.
/// </summary>
Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("This repository does not list stock item codes.");
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
+15 -6
View File
@@ -44,7 +44,8 @@ public enum LibraryInsertKind
/// Decides how one library drawing revision enters a nest, before anything changes:
/// a new exact copy carrying its library link, more quantity on the copy already there,
/// or a refusal. Material, grade or thickness that differ from the nest still allow the
/// add, but the drawing's cut pins are dropped (D3). Different units, a different revision
/// add, but the drawing's cut pins are dropped (D3); a blank material or grade, or a zero
/// thickness, on the record suits any nest and is never a difference. Different units, a different revision
/// of the same drawing, or another drawing with the same name refuse it.
/// </summary>
public sealed class LibraryDrawingInsert
@@ -75,7 +76,7 @@ public sealed class LibraryDrawingInsert
/// <summary>
/// For a new copy, true when the library drawing's cut pins come with it: the library
/// material, grade and thickness match the nest's. Otherwise the copy has no pins. An
/// material, grade and thickness match the nest's or are unset. Otherwise the copy has no pins. An
/// added quantity leaves the existing copy as it is.
/// </summary>
public bool KeepPins { get; }
@@ -140,7 +141,12 @@ public sealed class LibraryDrawingInsert
var drawing = package.Drawing;
drawing.Name = record.Name;
drawing.Customer = record.Customer;
drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
// A field the record leaves unset means any material, so the copy takes the nest's.
var nestMaterial = nest.Material ?? new Material();
drawing.Material = new Material(
IsSet(record.MaterialName) ? record.MaterialName : nestMaterial.Name,
IsSet(record.MaterialGrade) ? record.MaterialGrade : nestMaterial.Grade,
drawing.Material?.Density ?? 0);
(record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
drawing.Quantity.Required = quantity;
drawing.Library = link;
@@ -177,21 +183,24 @@ public sealed class LibraryDrawingInsert
private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty<string>(), reason);
// A blank material or grade, or a zero thickness, on the record suits any nest.
private static List<string> Differences(Nest nest, DrawingLibraryRecord record)
{
var differences = new List<string>();
var material = nest.Material ?? new Material();
if (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
if (IsSet(record.MaterialName) && SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})");
if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
if (IsSet(record.MaterialGrade) && SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})");
var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
if (thickness != nestThickness)
if (thickness != 0 && thickness != nestThickness)
differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
return differences;
}
private static bool IsSet(string? value) => SharedListNames.Key(value).Length > 0;
private static string Describe(DrawingLibraryRecord record) =>
string.IsNullOrWhiteSpace(record.Customer)
? $"library drawing '{record.Name}' revision {record.Revision}"
@@ -0,0 +1,95 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>A plate's item code that is no longer the nest's to use, and why.</summary>
/// <param name="PlateNumber">The plate's 1-based position in the nest.</param>
public sealed record LedgerCodeConflict(int PlateNumber, string ItemCode, string Reason);
/// <summary>
/// The save and post re-check of a nest's <see cref="Plate.StockItemCodes"/>: finds codes the
/// ledger no longer has free for this nest or that other open nests are coded with, and takes
/// them off the plates when the programmer chooses to. Pure; the caller fetches the inputs.
/// </summary>
public static class LedgerCodeCheck
{
/// <summary>True when any plate has a coded sheet.</summary>
public static bool HasCodes(IEnumerable<Plate> plates) =>
(plates ?? throw new ArgumentNullException(nameof(plates))).Any(plate => plate.StockItemCodes.Count > 0);
/// <param name="plates">The nest's plates in order.</param>
/// <param name="freeItems">The ledger's free stock for the nest's material, after every hold.</param>
/// <param name="ownHolds">
/// The ledger's holds for this nest: an open one keeps its sheet for this nest, so the
/// sheet counts as available here although the ledger no longer lists it as free.
/// </param>
/// <param name="inUse">Other open nests' codes; an unknown answer checks the ledger alone.</param>
/// <returns>One conflict per plate and code, in plate order then the plate's code order.</returns>
public static IReadOnlyList<LedgerCodeConflict> Find(IList<Plate> plates, IEnumerable<LedgerStockItem> freeItems,
IEnumerable<LedgerHold>? ownHolds, LedgerCodesInUse inUse)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(freeItems);
ArgumentNullException.ThrowIfNull(inUse);
var available = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var item in freeItems.Where(item => item != null && item.Quantity > 0))
available[item.ItemCode] = available.GetValueOrDefault(item.ItemCode) + item.Quantity;
foreach (var hold in (ownHolds ?? Array.Empty<LedgerHold>()).Where(hold => hold != null && LedgerHoldStatuses.IsOpen(hold.Status)))
available[hold.ItemCode] = available.GetValueOrDefault(hold.ItemCode) + 1;
// A bundle's code repeats once per sheet across every plate it is used on.
var needed = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var code in plates.SelectMany(plate => plate.StockItemCodes))
needed[code] = needed.GetValueOrDefault(code) + 1;
var reasons = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (code, count) in needed)
{
var parts = new List<string>();
var free = available.GetValueOrDefault(code);
if (free == 0)
parts.Add("not free in MaterialLedger");
else if (free < count)
parts.Add($"only {free} of the {count} sheets needed free in MaterialLedger");
var nests = inUse.NestsUsing(code);
if (nests.Count > 0)
parts.Add((nests.Count == 1 ? "on nest " : "on nests ") + string.Join(", ", nests));
if (parts.Count > 0)
reasons[code] = Capitalize(string.Join("; ", parts));
}
var conflicts = new List<LedgerCodeConflict>();
for (var i = 0; i < plates.Count; i++)
{
foreach (var code in plates[i].StockItemCodes.Distinct(StringComparer.Ordinal))
{
if (reasons.TryGetValue(code, out var reason))
conflicts.Add(new LedgerCodeConflict(i + 1, code, reason));
}
}
return conflicts;
}
/// <summary>
/// Takes every copy of <paramref name="codes"/> off the plates, keeping the layout and the
/// other codes in order; the sheets left uncoded are matched by size in ML. Returns how
/// many sheets lost their code.
/// </summary>
public static int Remove(IEnumerable<Plate> plates, IEnumerable<string> codes)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(codes);
var remove = codes.ToHashSet(StringComparer.Ordinal);
var removed = 0;
foreach (var plate in plates)
{
var kept = plate.StockItemCodes.Where(code => !remove.Contains(code)).ToList();
if (kept.Count == plate.StockItemCodes.Count)
continue;
removed += plate.StockItemCodes.Count - kept.Count;
plate.StockItemCodes = kept;
}
return removed;
}
private static string Capitalize(string text) => text.Length == 0 ? text : char.ToUpperInvariant(text[0]) + text[1..];
}
@@ -0,0 +1,70 @@
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// Takes item codes already on other open nests out of ledger stock rows, so two nests are
/// not planned on the same sheet before ML holds exist for either. Pure: rows are rebuilt,
/// never changed.
/// </summary>
public static class LedgerCodeFilter
{
/// <summary>
/// <paramref name="rows"/> without <paramref name="codesInUse"/>: every copy of a code
/// leaves the row's <see cref="LedgerStockRow.ItemCodes"/> and
/// <see cref="LedgerStockRow.ItemLocations"/>, its quantity drops by the copies removed, and
/// a row left with none is dropped. Row ids and order are kept. <paramref name="removed"/>
/// counts the sheets and drops taken out.
/// </summary>
public static IReadOnlyList<LedgerStockRow> Exclude(IEnumerable<LedgerStockRow> rows,
IEnumerable<string> codesInUse, out int removed)
{
ArgumentNullException.ThrowIfNull(rows);
ArgumentNullException.ThrowIfNull(codesInUse);
var exclude = codesInUse as IReadOnlySet<string> ?? codesInUse.ToHashSet(StringComparer.Ordinal);
removed = 0;
var kept = new List<LedgerStockRow>();
foreach (var row in rows)
{
if (row == null)
continue;
var codes = row.ItemCodes.Where(code => !exclude.Contains(code)).ToList();
var dropped = row.ItemCodes.Count - codes.Count;
if (dropped == 0)
{
kept.Add(row);
continue;
}
removed += dropped;
if (codes.Count == 0)
continue;
var itemLocations = row.ItemLocations
.Where(pair => !exclude.Contains(pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
// A location stays listed while an item left in the row is there, or when the row
// never said which item it held.
var locations = row.Locations
.Where(location => itemLocations.ContainsValue(location)
|| !row.ItemLocations.Values.Contains(location, StringComparer.Ordinal))
.ToList();
kept.Add(row with
{
Quantity = codes.Count,
ItemCodes = codes,
Locations = locations,
ItemLocations = itemLocations,
});
}
return kept;
}
/// <summary>
/// <paramref name="mapping"/> with <paramref name="codesInUse"/> taken out of its rows and
/// the sheets removed counted in <see cref="LedgerStockMapping.OnOtherNests"/>.
/// </summary>
public static LedgerStockMapping Exclude(LedgerStockMapping mapping, IEnumerable<string> codesInUse)
{
ArgumentNullException.ThrowIfNull(mapping);
var rows = Exclude(mapping.Rows, codesInUse, out var removed);
return mapping with { Rows = rows, OnOtherNests = mapping.OnOtherNests + removed };
}
}
@@ -0,0 +1,78 @@
using System.Net.Http;
namespace OpenNest.Data.MaterialLedger;
/// <summary>
/// The ledger item codes other open server nests are coded with, or why they could not be
/// read. ML's free counts drop only once a hold exists, so these codes are what another
/// Auto Nest run must leave out until then. Reading them never blocks: a failure is a result.
/// </summary>
public sealed record LedgerCodesInUse
{
/// <summary>The nest server gets this long to answer, so a stock load or save never waits on it.</summary>
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
private LedgerCodesInUse(IReadOnlyList<NestStockCodeUse> uses, string? unavailable)
{
Uses = uses;
Unavailable = unavailable;
Codes = uses.Select(use => use.ItemCode).ToHashSet(StringComparer.Ordinal);
}
/// <summary>One entry per code and nest; empty when <see cref="Unavailable"/>.</summary>
public IReadOnlyList<NestStockCodeUse> Uses { get; }
/// <summary>The distinct codes of <see cref="Uses"/>.</summary>
public IReadOnlySet<string> Codes { get; }
/// <summary>Why the codes could not be read; null when they were.</summary>
public string? Unavailable { get; }
public bool IsKnown => Unavailable == null;
public static LedgerCodesInUse Known(IEnumerable<NestStockCodeUse> uses) =>
new((uses ?? throw new ArgumentNullException(nameof(uses))).Where(use => use != null).ToList(), null);
public static LedgerCodesInUse Unknown(string reason) =>
new(Array.Empty<NestStockCodeUse>(), string.IsNullOrWhiteSpace(reason) ? "not available" : reason);
/// <summary>The names of the other nests using <paramref name="code"/>, in name order.</summary>
public IReadOnlyList<string> NestsUsing(string code) =>
Uses.Where(use => string.Equals(use.ItemCode, code, StringComparison.Ordinal))
.Select(use => use.NestName)
.Distinct(StringComparer.Ordinal)
.OrderBy(name => name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
/// <summary>
/// Reads the codes from <paramref name="repository"/>, leaving out the nest
/// <paramref name="excludeNestId"/> (the one being edited). A null repository is File
/// mode. Only the caller's own cancellation throws.
/// </summary>
public static async Task<LedgerCodesInUse> ReadAsync(INestRepository? repository, Guid excludeNestId,
CancellationToken cancellationToken, TimeSpan? timeout = null)
{
if (repository == null)
return Unknown("nests are saved as files");
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout ?? RequestTimeout);
try
{
return Known(await repository.GetStockCodesInUseAsync(excludeNestId, timeoutSource.Token));
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Unknown("the nest server did not answer");
}
catch (NotSupportedException)
{
return Unknown("the nest server needs an update");
}
catch (Exception ex) when (ex is HttpRequestException or IOException or System.Text.Json.JsonException
or ArgumentException or InvalidOperationException)
{
return Unknown("the nest server could not be reached");
}
}
}
@@ -51,7 +51,12 @@ public sealed record LedgerStockMapping(
public int Drops => Rows.Where(r => r.IsDrop).Sum(r => r.Quantity);
public int SubstituteRows => Rows.Count(r => r.IsSubstitute);
/// <summary>E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (2 skipped: 2 unknown shape)".</summary>
/// <summary>Sheets and drops left out because other open nests are coded with them (<see cref="LedgerCodeFilter"/>).</summary>
public int OnOtherNests { get; init; }
/// <summary>
/// E.g. "Ledger: 3 sheet sizes, 7 drops, 2 substitute rows (3 on other nests) (2 skipped: 2 unknown shape)".
/// </summary>
public string Summary
{
get
@@ -64,6 +69,8 @@ public sealed record LedgerStockMapping(
var text = $"Ledger: {Plural(SheetRows, "sheet size")}, {Plural(Drops, "drop")}";
if (SubstituteRows > 0)
text += $", {Plural(SubstituteRows, "substitute row")}";
if (OnOtherNests > 0)
text += $" ({OnOtherNests} on other nests)";
return skipped.Count == 0
? text
: $"{text} ({SkippedUnknownShape + SkippedTooSmall} skipped: {string.Join(", ", skipped)})";
+18
View File
@@ -0,0 +1,18 @@
namespace OpenNest.Data;
/// <summary>
/// A ledger item code an open server nest's plates are coded with, returned by
/// <c>GET /api/nests/stock-item-codes</c>. One entry per code and nest; a bundle code used
/// for several sheets appears once.
/// </summary>
public sealed class NestStockCodeUse
{
public string ItemCode { get; init; } = "";
public Guid NestId { get; init; }
public string NestName { get; init; } = "";
/// <summary>Draft, Quote or To Be Cut: the statuses whose sheets are still to be cut.</summary>
public NestStatus Status { get; init; }
}
+13
View File
@@ -151,6 +151,19 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
return allocation.Number;
}
public async Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId,
CancellationToken cancellationToken = default)
{
var query = "api/nests/stock-item-codes" + (excludeNestId == Guid.Empty ? "" : "?exclude=" + excludeNestId.ToString("D"));
using var response = await _httpClient.GetAsync(Url(query), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new NotSupportedException("This nest server does not list stock item codes (HTTP 404). Update the nest server.");
await NestServerHttp.EnsureSuccessAsync(response, "list stock item codes", cancellationToken);
var uses = await response.Content.ReadFromJsonAsync<List<NestStockCodeUse>>(NestServerHttp.JsonOptions, cancellationToken);
return uses?.Where(use => use != null && !string.IsNullOrWhiteSpace(use.ItemCode)).ToList()
?? new List<NestStockCodeUse>();
}
public async Task<NestRecord> UpdateFileAsync(
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
{
@@ -0,0 +1,81 @@
using System.Reflection;
using System.Text.Json;
using System.Xml.Linq;
namespace OpenNest.Desktop.Tests;
/// <summary>
/// OpenNest.Desktop is shared by the WinForms and Avalonia front ends, so it must stay free of
/// any UI toolkit: no WinForms/WindowsDesktop framework, System.Drawing.Common or Avalonia.
/// </summary>
public class DesktopDependencyTests
{
private static readonly string[] ForbiddenPrefixes =
[
"System.Windows.Forms",
"Microsoft.WindowsDesktop",
"WindowsBase",
"PresentationCore",
"PresentationFramework",
"System.Drawing.Common",
"Avalonia",
];
[Fact]
public void DesktopAssembly_ReferencesNoUiToolkit()
{
var assembly = Assembly.Load(new AssemblyName("OpenNest.Desktop"));
var references = assembly.GetReferencedAssemblies().Select(a => a.Name!);
Assert.Empty(references.Where(IsForbidden));
}
[Fact]
public void DesktopProject_TargetsPlainNet8WithoutUiFrameworks()
{
var project = XDocument.Load(Path.Combine(DesktopProjectDirectory(), "OpenNest.Desktop.csproj"));
var elements = project.Descendants().ToList();
var targets = elements
.Where(e => e.Name.LocalName is "TargetFramework" or "TargetFrameworks")
.SelectMany(e => e.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToList();
Assert.Equal(["net8.0"], targets);
Assert.DoesNotContain(elements, e =>
e.Name.LocalName is "UseWindowsForms" or "UseWPF" or "UseWindowsDesktop"
&& e.Value.Trim().Equals("true", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(elements, e => e.Name.LocalName == "FrameworkReference");
var packages = elements
.Where(e => e.Name.LocalName == "PackageReference")
.Select(e => (string?)e.Attribute("Include") ?? string.Empty);
Assert.Empty(packages.Where(IsForbidden));
}
[Fact]
public void DesktopProject_RestoresNoUiToolkitPackagesTransitively()
{
var assetsPath = Path.Combine(DesktopProjectDirectory(), "obj", "project.assets.json");
Assert.True(File.Exists(assetsPath), $"Restore output not found: {assetsPath}");
using var assets = JsonDocument.Parse(File.ReadAllText(assetsPath));
var libraries = assets.RootElement.GetProperty("libraries").EnumerateObject()
.Select(library => library.Name.Split('/')[0]);
Assert.Empty(libraries.Where(IsForbidden));
}
private static bool IsForbidden(string name) =>
ForbiddenPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
private static string DesktopProjectDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "OpenNest.sln")))
directory = directory.Parent;
Assert.NotNull(directory);
return Path.Combine(directory.FullName, "OpenNest.Desktop");
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.Desktop/OpenNest.Desktop.csproj" />
</ItemGroup>
</Project>
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Toolkit-neutral desktop logic shared by the WinForms and Avalonia front ends.
No UI toolkit: OpenNest.Desktop.Tests guards against WinForms, WindowsDesktop,
System.Drawing.Common and Avalonia references. -->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Desktop</RootNamespace>
<AssemblyName>OpenNest.Desktop</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
</ItemGroup>
</Project>
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Gpu</RootNamespace>
<AssemblyName>OpenNest.Gpu</AssemblyName>
</PropertyGroup>
@@ -22,7 +22,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
Execute(factory.DatabasePath, "PRAGMA user_version=14");
Execute(factory.DatabasePath, "PRAGMA user_version=15");
Assert.Throws<InvalidOperationException>(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
@@ -70,6 +70,36 @@ public sealed class DrawingLibraryQueryTests
Assert.Equal(page.Items.Count, page.Total);
}
[Theory]
[InlineData("materialName=Carbon%20Steel", "Any|Any thickness|Steel")]
[InlineData("materialName=Carbon%20Steel&materialGrade=A36&thickness=0.25", "Any|Any thickness|Steel")]
[InlineData("materialName=Stainless&thickness=0.25", "Any")]
[InlineData("materialName=Carbon%20Steel&thickness=0.1875", "Any|Any thickness")]
[InlineData("thickness=0", "Any|Any thickness")]
public async Task UnsetMaterialGradeOrThickness_MatchesAnyFilter(string query, string expected)
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
foreach (var (name, material, grade, thickness) in new[]
{
("Any", "", "", 0.0),
("Any thickness", "Carbon Steel", "", 0.0),
("Steel", "Carbon Steel", "A36", 0.25),
})
{
var record = Record(name);
record.MaterialName = material;
record.MaterialGrade = grade;
record.Thickness = thickness;
await Upload(client, record, Package(name));
}
var page = await DrawingLibraryApiTests.Query(client, query + "&sort=name&order=asc");
Assert.Equal(expected, string.Join("|", page.Items.Select(r => r.Name)));
Assert.Equal(page.Items.Count, page.Total);
}
[Theory]
[InlineData("name", false)]
[InlineData("name", true)]
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
Assert.Equal(before, Digest(factory.DatabasePath));
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
Assert.Equal(before, Digest(factory.DatabasePath));
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class MaterialListTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListMaterials());
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
@@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests
Assert.Equal("Stainless", record.Material);
Assert.Equal("", record.MaterialGrade);
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
@@ -0,0 +1,200 @@
using System.Net;
using Microsoft.Data.Sqlite;
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Server.Tests;
/// <summary>
/// <c>GET /api/nests/stock-item-codes</c> lists the ledger item codes of open nests from codes
/// stored at each archive write, so Auto Nest can leave them out before ML holds exist.
/// </summary>
public sealed class NestStockCodesTests
{
[Fact]
public async Task OpenNests_ListTheirCodesOncePerNest()
{
using var session = new Session();
var first = await session.SaveAsync("Nest A", NestStatus.Quote, ["SHEET-1", "SHEET-1"], ["DROP-7"]);
var second = await session.SaveAsync("Nest B", NestStatus.ToBeCut, ["SHEET-1"]);
await session.SaveAsync("Uncoded", NestStatus.Quote);
var uses = await session.Repository.GetStockCodesInUseAsync(Guid.Empty);
Assert.Equal(
[("DROP-7", first.Id, "Nest A"), ("SHEET-1", first.Id, "Nest A"), ("SHEET-1", second.Id, "Nest B")],
uses.Select(u => (u.ItemCode, u.NestId, u.NestName)).ToArray());
Assert.Equal(NestStatus.ToBeCut, uses[2].Status);
}
[Fact]
public async Task Exclude_LeavesOutTheNestBeingEdited()
{
using var session = new Session();
var editing = await session.SaveAsync("Editing", NestStatus.Quote, ["SHEET-1"]);
await session.SaveAsync("Other", NestStatus.Quote, ["SHEET-2"]);
var uses = await session.Repository.GetStockCodesInUseAsync(editing.Id);
Assert.Equal(["SHEET-2"], uses.Select(u => u.ItemCode));
}
[Fact]
public async Task CutAndDeletedNests_AreNotOpen()
{
using var session = new Session();
var draft = await session.SaveAsync("Draft", NestStatus.Draft, ["D-1"]);
var cut = await session.SaveAsync("Cut", NestStatus.Quote, ["C-1"]);
var deleted = await session.SaveAsync("Deleted", NestStatus.Quote, ["X-1"]);
cut.Status = NestStatus.HasBeenCut;
await session.Repository.UpdateMetadataAsync(cut.Id, cut, cut.Revision);
await session.Repository.DeleteAsync(deleted.Id, deleted.Revision);
Assert.Equal(["D-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
Assert.Equal(draft.Id, Assert.Single(await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).NestId);
// Recovering a nest brings its codes back without rereading the archive.
var gone = (await session.Repository.GetDeletedMetadataAsync(deleted.Id))!;
await session.Repository.RecoverAsync(deleted.Id, gone.Revision);
Assert.Equal(["D-1", "X-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
}
[Fact]
public async Task SavingAndRestoring_ReplaceTheStoredCodes()
{
using var session = new Session();
var record = await session.SaveAsync("Nest", NestStatus.Quote, ["OLD-1"]);
var nest = Nest("Nest", NestStatus.Quote, ["NEW-1"]);
var updated = await session.Repository.UpdateFileAsync(record.Id, NestApiTests.Archive(nest),
Record(record.Id, nest), record.Revision);
Assert.Equal(["NEW-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
await session.Repository.RestoreAsync(record.Id, 1, updated.Revision);
Assert.Equal(["OLD-1"], (await session.Repository.GetStockCodesInUseAsync(Guid.Empty)).Select(u => u.ItemCode));
}
[Fact]
public async Task InvalidExclude_IsABadRequest()
{
using var factory = new ServerFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/nests/stock-item-codes?exclude=nope");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task OlderServer_IsNotSupported()
{
using var http = new HttpClient(new StatusHandler(HttpStatusCode.NotFound)) { BaseAddress = new Uri("http://old/") };
using var repository = new RemoteNestRepository(http, "http://old/");
await Assert.ThrowsAsync<NotSupportedException>(() => repository.GetStockCodesInUseAsync(Guid.Empty));
}
[Fact]
public void V13Upgrade_ReadsTheCodesOfEveryStoredNest()
{
using var factory = new ServerFactory();
var open = Guid.NewGuid();
var deleted = Guid.NewGuid();
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
var nest = Nest("Open", NestStatus.Quote, ["SHEET-1"]);
database.Insert(open, Record(open, nest), NestApiTests.Archive(nest));
var gone = Nest("Gone", NestStatus.Quote, ["SHEET-2"]);
var record = database.Insert(deleted, Record(deleted, gone), NestApiTests.Archive(gone));
database.SetDeleted(deleted, true, record.Revision);
}
Execute(factory.DatabasePath, "DROP TABLE nest_stock_codes; PRAGMA user_version=13;");
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
Assert.Equal(["SHEET-1"], database.StockCodesInUse(null).Select(u => u.ItemCode));
var record = database.Get(deleted, deleted: true)!;
database.SetDeleted(deleted, false, record.Revision);
Assert.Equal(["SHEET-1", "SHEET-2"], database.StockCodesInUse(null).Select(u => u.ItemCode));
}
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static Nest Nest(string name, NestStatus status, params string[][] plateCodes)
{
var nest = NestApiTests.SyntheticNest();
nest.Name = name;
nest.Status = status;
var drawing = nest.Drawings.Single();
nest.Plates.Clear();
foreach (var codes in plateCodes.DefaultIfEmpty(Array.Empty<string>()))
{
var plate = new Plate(new Size(48, 96)) { Quantity = System.Math.Max(codes.Length, 1) };
plate.Parts.Add(new Part(drawing, new Vector(2, 3)));
plate.StockItemCodes = codes;
nest.Plates.Add(plate);
}
return nest;
}
private static NestRecord Record(Guid id, Nest nest) => new()
{
Id = id,
Name = nest.Name,
Status = nest.Status,
DateCreated = nest.DateCreated,
DateModified = nest.DateCreated,
};
private static void Execute(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
private static object? Scalar(string path, string sql)
{
using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
return command.ExecuteScalar();
}
private sealed class Session : IDisposable
{
private readonly ServerFactory factory = new();
private readonly HttpClient client;
public Session()
{
client = factory.CreateClient();
Repository = new RemoteNestRepository(client, client.BaseAddress!.ToString());
}
public RemoteNestRepository Repository { get; }
public Task<NestRecord> SaveAsync(string name, NestStatus status, params string[][] plateCodes)
{
var nest = Nest(name, status, plateCodes);
return Repository.UploadAsync(NestApiTests.Archive(nest), Record(Guid.NewGuid(), nest));
}
public void Dispose()
{
Repository.Dispose();
client.Dispose();
factory.Dispose();
}
}
private sealed class StatusHandler(HttpStatusCode status) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(status));
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ public sealed class NestUnitsTests
Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units));
Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units);
Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, Units units) => new()
+19 -1
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
namespace OpenNest.Server.Tests;
@@ -10,15 +11,19 @@ namespace OpenNest.Server.Tests;
/// Hosts the production routes in memory against a private, disposable SQLite file.
/// The production <see cref="NestDatabase"/> registration is replaced before anything
/// resolves it, so tests never open the default data/nests.db or an OPENNEST_DB path.
/// The update mirror never refreshes on its own and reaches GitHub only through
/// <c>updateHandler</c> (by default a handler that fails every request); tests refresh it explicitly.
/// Disposing the factory disposes the host (and its database) before deleting the directory.
/// </summary>
public sealed class ServerFactory : WebApplicationFactory<global::Program>
{
private bool _cleanedUp;
private NestDatabase? _database;
private readonly HttpMessageHandler? _updateHandler;
public ServerFactory(string? databasePath = null)
public ServerFactory(string? databasePath = null, HttpMessageHandler? updateHandler = null)
{
_updateHandler = updateHandler;
DirectoryPath = Path.Combine(Path.GetTempPath(), "opennest-server-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DirectoryPath);
DatabasePath = databasePath ?? Path.Combine(DirectoryPath, "nests.db");
@@ -36,6 +41,13 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
services.RemoveAll<NestDatabase>();
// Register an externally owned instance; this factory closes it before deleting its file.
services.AddSingleton(_database = new NestDatabase(DatabasePath, pooling: false));
services.RemoveAll<UpdateMirror>();
var hosted = services.Where(d => d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(UpdateMirrorService)).ToList();
foreach (var descriptor in hosted)
services.Remove(descriptor);
services.AddSingleton(_ => new UpdateMirror(
Path.Combine(DirectoryPath, "updates"), new HttpClient(_updateHandler ?? new OfflineHandler())));
});
}
@@ -50,4 +62,10 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
if (Directory.Exists(DirectoryPath))
Directory.Delete(DirectoryPath, recursive: true);
}
private sealed class OfflineHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
throw new HttpRequestException("Tests never reach GitHub.");
}
}
@@ -151,7 +151,7 @@ public sealed class SetReleaseDrawingTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
var kept = upgraded.GetRelease(releaseId)!;
Assert.Empty(kept.Drawings);
Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity));
+1 -1
View File
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'"));
var release = upgraded.GetRelease(releaseId)!;
Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone));
+1 -1
View File
@@ -67,7 +67,7 @@ public sealed class SharedSettingsTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(13L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(14L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListSharedSettings());
}
+305
View File
@@ -0,0 +1,305 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
namespace OpenNest.Server.Tests;
/// <summary>
/// The desktop update feed the server mirrors from GitHub (<c>/updates/{channel}/</c>): which
/// release each channel serves, verified downloads, cleanup, restarts and the HTTP contract.
/// GitHub is a synthetic handler; nothing here reaches the network.
/// </summary>
public sealed class UpdateMirrorTests
{
private const string ReleasesUrl = "https://api.github.com/repos/ajisaacs/OpenNest/releases?per_page=10";
[Fact]
public async Task Refresh_MirrorsNothingUntilAClientAsksForAChannel()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
await mirror.RefreshAsync();
Assert.Empty(github.Requests);
Assert.NotNull(mirror.GetStatus().LastChecked);
}
[Fact]
public async Task Api_ServesTheStableFeedAndPackageAfterTheFirstRequestWakesARefresh()
{
var github = new FakeGitHub()
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true)
.WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var factory = new ServerFactory(updateHandler: github);
using var client = factory.CreateClient();
var mirror = factory.Services.GetRequiredService<UpdateMirror>();
var woken = 0;
mirror.RefreshRequested += () => woken++;
// Velopack appends its own query; the route ignores it.
var before = await client.GetAsync("/updates/stable/releases.win.json?arch=x64&localVersion=0.6.0");
Assert.Equal(HttpStatusCode.ServiceUnavailable, before.StatusCode);
Assert.Equal(1, woken);
await mirror.RefreshAsync();
var feed = await client.GetFromJsonAsync<JsonElement>("/updates/stable/releases.win.json");
var asset = Assert.Single(feed.GetProperty("Assets").EnumerateArray().ToList());
Assert.Equal("0.6.1", asset.GetProperty("Version").GetString());
Assert.Equal("OpenNest-0.6.1-full.nupkg", asset.GetProperty("FileName").GetString());
Assert.Equal(FakeGitHub.Hash("0.6.1"), asset.GetProperty("SHA256").GetString());
Assert.Equal(FakeGitHub.Package("0.6.1"), await client.GetByteArrayAsync("/updates/stable/OpenNest-0.6.1-full.nupkg"));
// The development build is never fetched for a stable-only shop.
Assert.DoesNotContain(github.Requests, r => r.Contains("dev-build"));
Assert.Equal(1, github.Requests.Count(r => r.StartsWith("https://api.github.com/", StringComparison.Ordinal)));
var status = await client.GetFromJsonAsync<JsonElement>("/api/updates");
Assert.Equal("0.6.1", status.GetProperty("stable").GetString());
Assert.Equal(JsonValueKind.Null, status.GetProperty("dev").ValueKind);
Assert.Equal(JsonValueKind.Null, status.GetProperty("lastError").ValueKind);
}
[Fact]
public async Task Api_RefusesUnknownChannelsAndFilesTheFeedDoesNotName()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var factory = new ServerFactory(updateHandler: github);
using var client = factory.CreateClient();
var mirror = factory.Services.GetRequiredService<UpdateMirror>();
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/beta/releases.win.json")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/dev/OpenNest-0.6.1-full.nupkg")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/stable/OpenNest-0.6.0-full.nupkg")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/updates/stable/..%2F..%2Fnests.db")).StatusCode);
}
[Fact]
public async Task Dev_ServesTheHigherOfTheDevelopmentBuildAndTheNewestRelease()
{
var github = new FakeGitHub()
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true)
.WithRelease("v0.6.1", "0.6.1", prerelease: false)
.WithRelease("v0.5.1", null, prerelease: true); // Older pre-release with no feed.
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Dev);
await mirror.RefreshAsync();
Assert.Equal("0.7.0-dev.2020", mirror.GetStatus().Dev);
// A full release published before the next development build outranks it.
github.Clear()
.WithRelease("v0.7.0", "0.7.0", prerelease: false)
.WithRelease("dev-build", "0.7.0-dev.2020", prerelease: true);
await mirror.RefreshAsync();
Assert.Equal("0.7.0", mirror.GetStatus().Dev);
Assert.Null(mirror.GetStatus().Stable);
Assert.Equal(new[] { "OpenNest-0.7.0-full.nupkg" }, PackageFiles(directory.Path));
}
[Fact]
public async Task Refresh_DownloadsEachPackageOnceAndDeletesSupersededOnes()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
await mirror.RefreshAsync();
Assert.Equal(1, github.Requests.Count(r => r.EndsWith(".nupkg", StringComparison.Ordinal)));
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false).WithRelease("v0.6.1", "0.6.1", prerelease: false);
await mirror.RefreshAsync();
Assert.Equal("0.6.2", mirror.GetStatus().Stable);
Assert.Equal(new[] { "OpenNest-0.6.2-full.nupkg" }, PackageFiles(directory.Path));
Assert.Null(mirror.GetPackagePath(UpdateMirror.Stable, "OpenNest-0.6.1-full.nupkg"));
}
[Fact]
public async Task Refresh_RejectsAPackageThatDoesNotMatchItsFeedAndKeepsTheCurrentOne()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false, corrupt: true);
await mirror.RefreshAsync();
var status = mirror.GetStatus();
Assert.Equal("0.6.1", status.Stable);
Assert.Contains("SHA-256", status.LastError);
Assert.Equal(new[] { "OpenNest-0.6.1-full.nupkg" }, PackageFiles(directory.Path));
}
[Fact]
public async Task Refresh_KeepsServingWhenGitHubIsUnreachable()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var mirror = new UpdateMirror(directory.Path, new HttpClient(github));
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
github.Clear();
await mirror.RefreshAsync();
Assert.NotNull(mirror.GetStatus().LastError);
Assert.NotNull(mirror.GetFeed(UpdateMirror.Stable));
}
[Fact]
public async Task Restart_ServesTheSavedFeedAndKeepsRefreshingIt()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var first = new UpdateMirror(directory.Path, new HttpClient(github));
first.GetFeed(UpdateMirror.Stable);
await first.RefreshAsync();
github.Clear().WithRelease("v0.6.2", "0.6.2", prerelease: false);
var restarted = new UpdateMirror(directory.Path, new HttpClient(github));
Assert.Equal("0.6.1", restarted.GetStatus().Stable);
Assert.NotNull(restarted.GetPackagePath(UpdateMirror.Stable, "OpenNest-0.6.1-full.nupkg"));
await restarted.RefreshAsync();
Assert.Equal("0.6.2", restarted.GetStatus().Stable);
}
[Fact]
public async Task Refresh_StopsMirroringAChannelNobodyHasAskedForInAWeek()
{
var github = new FakeGitHub().WithRelease("v0.6.1", "0.6.1", prerelease: false);
using var directory = new TempDirectory();
var now = DateTimeOffset.Parse("2026-10-28T12:00:00Z");
var mirror = new UpdateMirror(directory.Path, new HttpClient(github), clock: () => now);
mirror.GetFeed(UpdateMirror.Stable);
await mirror.RefreshAsync();
var requests = github.Requests.Count;
now += UpdateMirror.WantedFor + TimeSpan.FromMinutes(1);
await mirror.RefreshAsync();
Assert.Equal(requests, github.Requests.Count);
}
[Theory]
[InlineData("0.7.0", "0.6.1", 1)]
[InlineData("0.7.0-dev.2020", "0.6.1", 1)]
[InlineData("0.7.0", "0.7.0-dev.2020", 1)]
[InlineData("0.7.0-dev.10", "0.7.0-dev.9", 1)]
[InlineData("0.7.0-dev.9", "0.7.0-dev", 1)]
[InlineData("0.7.0-beta", "0.7.0-alpha", 1)]
[InlineData("0.7.0-alpha", "0.7.0-1", 1)]
[InlineData("0.7.0+abc", "0.7.0", 0)]
[InlineData("0.10.0", "0.9.0", 1)]
[InlineData("0.6.1", "not-a-version", 1)]
public void SemanticVersion_FollowsSemVerPrecedence(string higher, string lower, int expected)
{
Assert.Equal(expected, SemanticVersion.Compare(higher, lower));
Assert.Equal(-expected, SemanticVersion.Compare(lower, higher));
}
[Theory]
[InlineData(null, true)]
[InlineData("", true)]
[InlineData("on", true)]
[InlineData("off", false)]
[InlineData(" OFF ", false)]
public void Options_UpdatesAreOnUnlessTurnedOff(string? value, bool expected) =>
Assert.Equal(expected, ServerOptions.UpdatesEnabled(value));
private static string[] PackageFiles(string directory) =>
Directory.GetFiles(Path.Combine(directory, "packages")).Select(Path.GetFileName).OfType<string>().ToArray();
private sealed class TempDirectory : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "opennest-updates-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(Path))
Directory.Delete(Path, recursive: true);
}
}
/// <summary>The GitHub release list, feeds and packages for synthetic versions, newest release first.</summary>
private sealed class FakeGitHub : HttpMessageHandler
{
private readonly List<object> _releases = new();
private readonly Dictionary<string, byte[]> _files = new(StringComparer.Ordinal);
public List<string> Requests { get; } = new();
public static byte[] Package(string version) => Encoding.UTF8.GetBytes($"package {version}");
public static string Hash(string version) => Convert.ToHexString(SHA256.HashData(Package(version)));
public FakeGitHub Clear()
{
_releases.Clear();
_files.Clear();
return this;
}
/// <param name="version">The feed's version, or null for a release without a Velopack feed.</param>
public FakeGitHub WithRelease(string tag, string? version, bool prerelease, bool corrupt = false)
{
var assets = new List<object>();
if (version is not null)
{
var package = $"OpenNest-{version}-full.nupkg";
var feed = JsonSerializer.Serialize(new
{
Assets = new[]
{
new { PackageId = "OpenNest", Version = version, Type = "Full", FileName = package, SHA1 = "00", SHA256 = Hash(version), Size = (long)Package(version).Length },
},
});
assets.Add(Asset(tag, "releases.win.json", Encoding.UTF8.GetBytes(feed)));
var bytes = Package(version);
assets.Add(Asset(tag, package, corrupt ? bytes.Select(b => (byte)(b ^ 1)).ToArray() : bytes));
}
assets.Add(Asset(tag, $"OpenNest.{tag}.win-x64.zip", new byte[] { 1 }));
_releases.Add(new { tag_name = tag, draft = false, prerelease, assets });
return this;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
if (url == ReleasesUrl)
{
Assert.Contains("OpenNest-Server", request.Headers.UserAgent.ToString());
if (_releases.Count == 0)
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden));
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(_releases) });
}
return Task.FromResult(_files.TryGetValue(url, out var bytes)
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }
: new HttpResponseMessage(HttpStatusCode.NotFound));
}
private object Asset(string tag, string name, byte[] contents)
{
var url = $"https://github.com/ajisaacs/OpenNest/releases/download/{tag}/{name}";
_files[url] = contents;
return new { name, browser_download_url = url };
}
}
}
+22 -5
View File
@@ -87,7 +87,6 @@ internal static class NestArchive
.OrderBy(p => p.Name, StringComparer.Ordinal)
.ToList();
// NestReader: codes end at the first blank entry and at the plate's quantity.
var platesOut = plates
.Select((p, index) =>
{
@@ -98,10 +97,7 @@ internal static class NestArchive
Width = System.Math.Min(size.Width, size.Length),
Length = System.Math.Max(size.Width, size.Length),
Quantity = p.Quantity,
StockItemCodes = (p.StockItemCodes ?? new())
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
.Take(System.Math.Max(p.Quantity, 0))
.ToList(),
StockItemCodes = PlateCodes(p).ToList(),
ExpectedRemnant = ReadRemnant(p.ExpectedRemnant),
};
})
@@ -121,6 +117,27 @@ internal static class NestArchive
return null;
}
/// <summary>
/// The distinct ledger item codes the archive's plates are coded with, in plate order, by
/// the same rules as <see cref="ReadSheets"/>; empty when the bytes are unreadable.
/// </summary>
public static IReadOnlyList<string> ReadStockItemCodes(byte[] file)
{
if (Read(file, out var nest) is not null)
return Array.Empty<string>();
var drawingIds = (nest.Drawings ?? new()).Select(d => d.Id).ToHashSet();
return NestPlates(nest, drawingIds)
.SelectMany(p => PlateCodes(p))
.Distinct(StringComparer.Ordinal)
.ToList();
}
// NestReader: codes end at the first blank entry and at the plate's quantity.
private static IEnumerable<string> PlateCodes(PlateJson plate) =>
(plate.StockItemCodes ?? new())
.TakeWhile(code => !string.IsNullOrWhiteSpace(code))
.Take(System.Math.Max(plate.Quantity, 0));
/// <summary>The archive's units, for backfilling records stored before they were kept; inches when unreadable.</summary>
public static Units ReadUnits(byte[] file) => Read(file, out var nest) is null ? UnitsOf(nest) : Units.Inches;
+4 -3
View File
@@ -138,9 +138,10 @@ public sealed partial class NestDatabase
if (!query.IncludeArchived) conditions.Add("archived=0");
if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")");
if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer");
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("materialKey=$material");
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade");
if (query.Thickness is not null) conditions.Add("thickness=$thickness");
// A drawing saved without a material, grade or thickness (0) suits any, so it matches every filter value.
if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("(materialKey=$material OR materialKey='')");
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("(gradeKey=$grade OR gradeKey='')");
if (query.Thickness is not null) conditions.Add("(thickness=$thickness OR thickness=0)");
if (query.CutType is not null) conditions.Add("cutType=$cutType");
if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst");
var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
@@ -0,0 +1,94 @@
using Microsoft.Data.Sqlite;
using OpenNest.Data;
namespace OpenNest.Server;
// The ledger item codes each nest's current archive is coded with, kept beside the record at
// every archive write so listing the codes in use never reopens an archive.
public sealed partial class NestDatabase
{
// v14: one row per distinct code of a nest's current archive.
private const string StockCodeSchema = """
CREATE TABLE IF NOT EXISTS nest_stock_codes (
id TEXT NOT NULL,
code TEXT NOT NULL,
PRIMARY KEY (id, code)
);
CREATE INDEX IF NOT EXISTS ix_nest_stock_codes_code ON nest_stock_codes(code);
""";
/// <summary>
/// The item codes on the current archives of every non-deleted Draft, Quote or To Be Cut
/// nest except <paramref name="excludeId"/>, ordered by code then nest name.
/// </summary>
public IReadOnlyList<NestStockCodeUse> StockCodesInUse(Guid? excludeId)
{
lock (_sync)
{
using var command = _connection.CreateCommand();
command.CommandText = """
SELECT c.code, n.id, n.name, n.status FROM nest_stock_codes c
JOIN nests n ON n.id = c.id
WHERE n.deleted = 0 AND n.status IN ('Draft', 'Quote', 'ToBeCut') AND n.id <> $exclude
ORDER BY c.code, n.name COLLATE NOCASE, n.id
""";
command.Parameters.AddWithValue("$exclude", (excludeId ?? Guid.Empty).ToString());
var uses = new List<NestStockCodeUse>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
uses.Add(new NestStockCodeUse
{
ItemCode = reader.GetString(0),
NestId = Guid.Parse(reader.GetString(1)),
NestName = reader.GetString(2),
Status = Enum.TryParse<NestStatus>(reader.GetString(3), true, out var status) ? status : NestStatus.Quote,
});
}
return uses;
}
}
// Called only under _sync with the caller's transaction, whenever a nest's current archive changes.
private void WriteStockCodes(Guid id, byte[] file, SqliteTransaction transaction)
{
using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "DELETE FROM nest_stock_codes WHERE id = $id";
command.Parameters.AddWithValue("$id", id.ToString());
command.ExecuteNonQuery();
InsertStockCodes(command, id, NestArchive.ReadStockItemCodes(file));
}
private static void InsertStockCodes(SqliteCommand command, Guid id, IEnumerable<string> codes)
{
command.CommandText = "INSERT OR IGNORE INTO nest_stock_codes (id, code) VALUES ($id, $code)";
command.Parameters.Clear();
command.Parameters.AddWithValue("$id", id.ToString());
var code = command.Parameters.Add("$code", SqliteType.Text);
foreach (var value in codes)
{
code.Value = value;
command.ExecuteNonQuery();
}
command.Parameters.Clear();
}
// v14: reads each current archive once, deleted ones too so a recovered nest keeps its codes.
private static void AddStockCodes(SqliteCommand command)
{
command.CommandText = StockCodeSchema;
command.ExecuteNonQuery();
var codes = new List<(Guid Id, IReadOnlyList<string> Codes)>();
command.CommandText = "SELECT id, file FROM nests";
using (var reader = command.ExecuteReader())
{
while (reader.Read())
codes.Add((Guid.Parse(reader.GetString(0)), NestArchive.ReadStockItemCodes((byte[])reader.GetValue(1))));
}
foreach (var (id, nestCodes) in codes.Where(c => c.Codes.Count > 0))
InsertStockCodes(command, id, nestCodes);
command.CommandText = "PRAGMA user_version=14";
command.ExecuteNonQuery();
}
}
+5 -1
View File
@@ -162,6 +162,7 @@ public sealed partial class NestDatabase : IDisposable
""";
AddRecordParameters(command, id, record, file, updateFile: true);
command.ExecuteNonQuery();
WriteStockCodes(id, file, transaction);
Snapshot(id, transaction);
transaction.Commit();
return Get(id) ?? throw new InvalidOperationException("Insert did not persist.");
@@ -220,6 +221,7 @@ public sealed partial class NestDatabase : IDisposable
command.Parameters.AddWithValue("$revision", checked(currentRevision + 1));
command.Parameters.AddWithValue("$source", (object?)sourceRevision ?? DBNull.Value);
command.ExecuteNonQuery();
WriteStockCodes(id, file, transaction);
Snapshot(id, transaction);
}
@@ -342,7 +344,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
if (version > 13) throw new InvalidOperationException("Database schema is newer than this server.");
if (version > 14) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -435,6 +437,8 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = SharedSettingSchema;
command.ExecuteNonQuery();
}
if (version < 14)
AddStockCodes(command);
transaction.Commit();
}
}
+15
View File
@@ -24,6 +24,14 @@ builder.Services.ConfigureHttpJsonOptions(options =>
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
// Desktop update feed mirrored from GitHub beside the database; OPENNEST_UPDATES=off turns it off.
var updatesEnabled = ServerOptions.UpdatesEnabled(Environment.GetEnvironmentVariable(ServerOptions.UpdatesVariable));
if (updatesEnabled)
{
builder.Services.AddSingleton(_ => new UpdateMirror(ServerOptions.UpdatesPath(databasePath), new HttpClient()));
builder.Services.AddHostedService<UpdateMirrorService>();
}
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
var app = builder.Build();
@@ -59,6 +67,11 @@ app.MapGet("/api/nests/{id:guid}/sheets", (Guid id, NestDatabase db) =>
: Results.Ok(sheets);
});
// Ledger item codes on open nests' plates, so Auto Nest leaves them out until ML holds them.
// The nest being edited passes its own id as exclude.
app.MapGet("/api/nests/stock-item-codes", (Guid? exclude, NestDatabase db) =>
Results.Ok(db.StockCodesInUse(exclude)));
// Next generated nest name for a year; one counter per year shared by every client.
app.MapPost("/api/nest-names/next", (int year, NestDatabase db) =>
{
@@ -154,6 +167,8 @@ app.MapDrawingSets();
app.MapReleases();
app.MapMaterials();
app.MapSettings();
if (updatesEnabled)
app.MapUpdates();
app.Run();
+12
View File
@@ -4,6 +4,18 @@ namespace OpenNest.Server;
public static class ServerOptions
{
public const string DatabaseVariable = "OPENNEST_DB";
public const string UpdatesVariable = "OPENNEST_UPDATES";
/// <summary>
/// True unless <c>OPENNEST_UPDATES</c> is <c>off</c>: the server mirrors the desktop
/// app's update feed from GitHub (see <see cref="UpdateMirror"/>).
/// </summary>
public static bool UpdatesEnabled(string? value) =>
!string.Equals(value?.Trim(), "off", StringComparison.OrdinalIgnoreCase);
/// <summary>The mirrored update feed lives in an <c>updates</c> directory beside the database.</summary>
public static string UpdatesPath(string databasePath) =>
Path.Combine(Path.GetDirectoryName(Path.GetFullPath(databasePath))!, "updates");
/// <summary>
/// The database path: <c>--database=&lt;path&gt;</c> or <c>--database &lt;path&gt;</c>, then
+421
View File
@@ -0,0 +1,421 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Server;
/// <summary>
/// A caching mirror of the desktop app's Velopack feed, so the workstations of one shop check
/// for updates against their nest server instead of each calling GitHub. One refresh makes a
/// single GitHub API request (the release list), reads the feeds attached to the chosen releases
/// and downloads each channel's newest full package once, verified against the feed's size and
/// SHA-256, into <see cref="DirectoryPath"/>.
/// </summary>
/// <remarks>
/// Channels: <c>stable</c> serves the newest full release; <c>dev</c> serves whichever is higher
/// of that and the newest pre-release with a feed (the rolling development build). A channel is
/// mirrored only while clients ask for it (within <see cref="WantedFor"/>), so a server whose
/// workstations never take development builds never downloads one. Feeds are served with the
/// package file names Velopack wrote, unchanged: clients download <c>{feed URL}/{FileName}</c>.
/// </remarks>
public sealed class UpdateMirror
{
public const string Stable = "stable";
public const string Dev = "dev";
public const string FeedFileName = "releases.win.json";
public const string DefaultRepository = "ajisaacs/OpenNest";
/// <summary>How long a channel stays mirrored after the last client asked for it.</summary>
public static readonly TimeSpan WantedFor = TimeSpan.FromDays(7);
private static readonly string[] Channels = { Stable, Dev };
private static readonly JsonSerializerOptions FeedOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
private readonly string _repository;
private readonly Func<DateTimeOffset> _clock;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private readonly object _stateLock = new();
private readonly Dictionary<string, ChannelFeed> _feeds = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _wanted = new(StringComparer.Ordinal);
private DateTimeOffset? _lastChecked;
private string? _lastError;
public UpdateMirror(string directoryPath, HttpClient http, string repository = DefaultRepository, Func<DateTimeOffset>? clock = null)
{
DirectoryPath = directoryPath;
_http = http;
_repository = repository;
_clock = clock ?? (() => DateTimeOffset.UtcNow);
Directory.CreateDirectory(PackagesPath);
LoadSavedFeeds();
}
/// <summary>Raised when a client asks for a channel that holds no feed yet, so the next refresh need not wait.</summary>
public event Action? RefreshRequested;
public string DirectoryPath { get; }
private string PackagesPath => Path.Combine(DirectoryPath, "packages");
public static bool IsChannel(string channel) => Array.IndexOf(Channels, channel) >= 0;
/// <summary>
/// The channel's feed for a client, or null while nothing is mirrored for it (the route answers
/// 503 and the client falls back to GitHub). Every call marks the channel as wanted.
/// </summary>
public byte[]? GetFeed(string channel)
{
bool missing;
byte[]? feed;
lock (_stateLock)
{
_wanted[channel] = _clock();
feed = _feeds.TryGetValue(channel, out var current) ? current.Json : null;
missing = feed is null;
}
if (missing)
RefreshRequested?.Invoke();
return feed;
}
/// <summary>The path of a package a channel's feed currently names, or null for any other file name.</summary>
public string? GetPackagePath(string channel, string fileName)
{
lock (_stateLock)
{
if (!_feeds.TryGetValue(channel, out var feed) || feed.FileName != fileName)
return null;
}
var path = Path.Combine(PackagesPath, fileName);
return File.Exists(path) ? path : null;
}
public UpdateMirrorStatus GetStatus()
{
lock (_stateLock)
{
return new UpdateMirrorStatus
{
Stable = _feeds.TryGetValue(Stable, out var stable) ? stable.Version : null,
Dev = _feeds.TryGetValue(Dev, out var dev) ? dev.Version : null,
LastChecked = _lastChecked,
LastError = _lastError,
};
}
}
/// <summary>
/// Brings every wanted channel up to date with GitHub. Failures are recorded in
/// <see cref="GetStatus"/> and leave the feeds already mirrored in place.
/// </summary>
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
await _refreshLock.WaitAsync(cancellationToken);
try
{
var wanted = WantedChannels();
if (wanted.Count > 0)
await RefreshChannelsAsync(wanted, cancellationToken);
lock (_stateLock)
{
_lastChecked = _clock();
_lastError = null;
}
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
lock (_stateLock)
{
_lastChecked = _clock();
_lastError = ex.Message;
}
}
finally
{
_refreshLock.Release();
}
}
private List<string> WantedChannels()
{
var now = _clock();
lock (_stateLock)
return Channels.Where(c => _wanted.TryGetValue(c, out var at) && now - at <= WantedFor).ToList();
}
private async Task RefreshChannelsAsync(List<string> channels, CancellationToken cancellationToken)
{
var releases = await GetJsonAsync<List<GitHubRelease>>(
$"https://api.github.com/repos/{_repository}/releases?per_page=10", cancellationToken) ?? new();
var published = releases.Where(r => !r.Draft && FeedAsset(r) is not null).ToList();
// The API lists the newest releases first.
var stable = published.FirstOrDefault(r => !r.Prerelease);
var preRelease = published.FirstOrDefault(r => r.Prerelease);
var feeds = new Dictionary<GitHubRelease, Candidate?>();
foreach (var channel in channels)
{
var sources = channel == Stable ? new[] { stable } : new[] { stable, preRelease };
Candidate? best = null;
foreach (var release in sources.OfType<GitHubRelease>())
{
if (!feeds.TryGetValue(release, out var candidate))
feeds[release] = candidate = await ReadCandidateAsync(release, cancellationToken);
if (candidate is not null && (best is null || SemanticVersion.Compare(candidate.Asset.Version, best.Asset.Version) > 0))
best = candidate;
}
if (best is not null)
await PublishAsync(channel, best, cancellationToken);
}
DeleteUnreferencedPackages();
}
private async Task<Candidate?> ReadCandidateAsync(GitHubRelease release, CancellationToken cancellationToken)
{
var feed = await GetJsonAsync<VelopackFeed>(FeedAsset(release)!.DownloadUrl, cancellationToken);
var full = feed?.Assets?
.Where(a => a.Type == "Full" && IsPlainFileName(a.FileName) && SemanticVersion.IsValid(a.Version))
.OrderByDescending(a => a.Version, SemanticVersion.Comparer)
.FirstOrDefault();
var package = full is null ? null : release.Assets.FirstOrDefault(a => a.Name == full.FileName);
return package is null ? null : new Candidate(full!, package.DownloadUrl);
}
private async Task PublishAsync(string channel, Candidate candidate, CancellationToken cancellationToken)
{
var asset = candidate.Asset;
var packagePath = Path.Combine(PackagesPath, asset.FileName);
if (!File.Exists(packagePath))
await DownloadPackageAsync(candidate, packagePath, cancellationToken);
// Serve only the chosen package, as the feed Velopack wrote describes it.
var json = JsonSerializer.SerializeToUtf8Bytes(new { Assets = new[] { asset } });
var feedPath = Path.Combine(DirectoryPath, channel, FeedFileName);
Directory.CreateDirectory(Path.GetDirectoryName(feedPath)!);
WriteAtomically(feedPath, json);
lock (_stateLock)
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
}
private async Task DownloadPackageAsync(Candidate candidate, string packagePath, CancellationToken cancellationToken)
{
var partial = packagePath + ".partial";
try
{
using (var response = await _http.GetAsync(candidate.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var target = File.Create(partial);
await source.CopyToAsync(target, cancellationToken);
}
var asset = candidate.Asset;
var length = new FileInfo(partial).Length;
if (asset.Size > 0 && length != asset.Size)
throw new InvalidDataException($"{asset.FileName} is {length} bytes; its feed says {asset.Size}.");
if (!string.IsNullOrEmpty(asset.SHA256))
{
await using var stream = File.OpenRead(partial);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken));
if (!hash.Equals(asset.SHA256, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"{asset.FileName} does not match its feed's SHA-256.");
}
File.Move(partial, packagePath, overwrite: true);
}
finally
{
if (File.Exists(partial))
File.Delete(partial);
}
}
private void DeleteUnreferencedPackages()
{
HashSet<string> keep;
lock (_stateLock)
keep = _feeds.Values.Select(f => f.FileName).ToHashSet(StringComparer.Ordinal);
foreach (var path in Directory.EnumerateFiles(PackagesPath))
{
if (keep.Contains(Path.GetFileName(path)))
continue;
try { File.Delete(path); }
catch (IOException) { } // Still being served; the next refresh retries.
catch (UnauthorizedAccessException) { }
}
}
private void LoadSavedFeeds()
{
foreach (var channel in Channels)
{
var path = Path.Combine(DirectoryPath, channel, FeedFileName);
if (!File.Exists(path))
continue;
try
{
var json = File.ReadAllBytes(path);
var asset = JsonSerializer.Deserialize<VelopackFeed>(json, FeedOptions)?.Assets?.SingleOrDefault();
if (asset is null || !IsPlainFileName(asset.FileName) || !File.Exists(Path.Combine(PackagesPath, asset.FileName)))
continue;
_feeds[channel] = new ChannelFeed(json, asset.FileName, asset.Version);
// A restart keeps serving what clients were already taking.
_wanted[channel] = _clock();
}
catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException)
{
// An unreadable feed is rebuilt by the next refresh.
}
}
}
private async Task<T?> GetJsonAsync<T>(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("OpenNest-Server");
if (url.StartsWith("https://api.github.com/", StringComparison.Ordinal))
request.Headers.Accept.ParseAdd("application/vnd.github+json");
using var response = await _http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
return await JsonSerializer.DeserializeAsync<T>(stream, FeedOptions, cancellationToken);
}
private static GitHubAsset? FeedAsset(GitHubRelease release) =>
release.Assets.FirstOrDefault(a => a.Name == FeedFileName);
// Feed file names become paths under the packages directory and URL segments.
private static bool IsPlainFileName(string? name) =>
!string.IsNullOrEmpty(name) && name == Path.GetFileName(name) && name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
&& name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase);
private static void WriteAtomically(string path, byte[] contents)
{
var temporary = path + ".tmp";
File.WriteAllBytes(temporary, contents);
File.Move(temporary, path, overwrite: true);
}
private sealed record ChannelFeed(byte[] Json, string FileName, string Version);
private sealed record Candidate(VelopackFeedAsset Asset, string DownloadUrl);
private sealed class GitHubRelease
{
[JsonPropertyName("draft")] public bool Draft { get; set; }
[JsonPropertyName("prerelease")] public bool Prerelease { get; set; }
[JsonPropertyName("assets")] public List<GitHubAsset> Assets { get; set; } = new();
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("browser_download_url")] public string DownloadUrl { get; set; } = "";
}
private sealed class VelopackFeed
{
public List<VelopackFeedAsset>? Assets { get; set; }
}
}
/// <summary>One entry of a Velopack <c>releases.{channel}.json</c>; properties keep Velopack's names.</summary>
public sealed class VelopackFeedAsset
{
public string PackageId { get; set; } = "";
public string Version { get; set; } = "";
public string Type { get; set; } = "";
public string FileName { get; set; } = "";
public string? SHA1 { get; set; }
public string? SHA256 { get; set; }
public long Size { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesMarkdown { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? NotesHTML { get; set; }
}
/// <summary><c>GET /api/updates</c>: the versions each channel serves and the last refresh's outcome.</summary>
public sealed class UpdateMirrorStatus
{
public string? Stable { get; init; }
public string? Dev { get; init; }
public DateTimeOffset? LastChecked { get; init; }
public string? LastError { get; init; }
}
/// <summary>SemVer 2.0 precedence for feed versions such as <c>0.7.0-dev.2020</c>; build metadata is ignored.</summary>
public static class SemanticVersion
{
public static readonly IComparer<string> Comparer = Comparer<string>.Create(Compare);
public static bool IsValid(string? version) => version is not null && TryParse(version, out _, out _);
public static int Compare(string? left, string? right)
{
if (!TryParse(left, out var leftCore, out var leftPre))
return TryParse(right, out _, out _) ? -1 : 0;
if (!TryParse(right, out var rightCore, out var rightPre))
return 1;
for (var i = 0; i < 3; i++)
{
var core = leftCore[i].CompareTo(rightCore[i]);
if (core != 0)
return core;
}
// A release outranks its pre-releases.
if (leftPre.Length == 0 || rightPre.Length == 0)
return (leftPre.Length == 0).CompareTo(rightPre.Length == 0);
for (var i = 0; i < System.Math.Min(leftPre.Length, rightPre.Length); i++)
{
var leftNumeric = long.TryParse(leftPre[i], out var leftNumber);
var rightNumeric = long.TryParse(rightPre[i], out var rightNumber);
var part = (leftNumeric, rightNumeric) switch
{
(true, true) => leftNumber.CompareTo(rightNumber),
(true, false) => -1,
(false, true) => 1,
_ => string.CompareOrdinal(leftPre[i], rightPre[i]),
};
if (part != 0)
return part;
}
return leftPre.Length.CompareTo(rightPre.Length);
}
private static bool TryParse(string? version, out long[] core, out string[] preRelease)
{
core = Array.Empty<long>();
preRelease = Array.Empty<string>();
if (string.IsNullOrEmpty(version))
return false;
var withoutMetadata = version.Split('+')[0];
var dash = withoutMetadata.IndexOf('-');
var numbers = (dash < 0 ? withoutMetadata : withoutMetadata[..dash]).Split('.');
if (numbers.Length != 3)
return false;
core = new long[3];
for (var i = 0; i < 3; i++)
{
if (!long.TryParse(numbers[i], out core[i]) || core[i] < 0)
return false;
}
preRelease = dash < 0 ? Array.Empty<string>() : withoutMetadata[(dash + 1)..].Split('.');
return preRelease.All(p => p.Length > 0);
}
}
+41
View File
@@ -0,0 +1,41 @@
namespace OpenNest.Server;
/// <summary>
/// Refreshes the <see cref="UpdateMirror"/> every <see cref="Interval"/>, and at once when a
/// client asks for a channel the mirror does not hold yet.
/// </summary>
public sealed class UpdateMirrorService : BackgroundService
{
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
private readonly UpdateMirror _mirror;
private readonly SemaphoreSlim _wake = new(0, 1);
public UpdateMirrorService(UpdateMirror mirror)
{
_mirror = mirror;
_mirror.RefreshRequested += Wake;
}
public override void Dispose()
{
_mirror.RefreshRequested -= Wake;
base.Dispose();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _mirror.RefreshAsync(stoppingToken);
try { await _wake.WaitAsync(Interval, stoppingToken); }
catch (OperationCanceledException) { return; }
}
}
private void Wake()
{
try { _wake.Release(); }
catch (SemaphoreFullException) { } // A wake is already pending.
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace OpenNest.Server;
/// <summary>
/// <c>/updates/{channel}/</c>: the desktop app's Velopack feed, mirrored from GitHub by
/// <see cref="UpdateMirror"/>. Velopack's <c>SimpleWebSource</c> reads
/// <c>releases.win.json</c> (ignoring the query it appends) and then the package it names. A
/// channel with nothing mirrored yet answers 503 so clients fall back to GitHub for that check.
/// </summary>
internal static class UpdateRoutes
{
public static void MapUpdates(this IEndpointRouteBuilder app)
{
app.MapGet("/api/updates", (UpdateMirror mirror) => Results.Ok(mirror.GetStatus()));
app.MapGet("/updates/{channel}/{file}", (string channel, string file, UpdateMirror mirror) =>
{
if (!UpdateMirror.IsChannel(channel))
return Results.NotFound();
if (file == UpdateMirror.FeedFileName)
{
return mirror.GetFeed(channel) is { } feed
? Results.Bytes(feed, "application/json")
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
return mirror.GetPackagePath(channel, file) is { } path
? Results.File(path, "application/octet-stream", enableRangeProcessing: true)
: Results.NotFound();
});
}
}
+4
View File
@@ -11,3 +11,7 @@ OPENNEST_HOST_PORT=8090
# Docker volume holding nests.db; change only to switch to a verified restore.
OPENNEST_DATA_VOLUME=opennest-data
# Desktop update mirror: the server checks GitHub Releases (outbound HTTPS to api.github.com
# and github.com) and serves updates to Database-mode workstations. "off" turns it off.
OPENNEST_UPDATES=on
@@ -67,6 +67,97 @@ public class ContourEntryCandidateTests
Assert.Equal(8, forward.Count);
}
/// <summary>Square 0..10 whose corners are 0.5-radius fillets, counterclockwise or clockwise.</summary>
internal static Program RoundedSquare(bool clockwise)
{
var p = new Program();
p.MoveTo(0.5, 0);
if (clockwise)
{
p.ArcTo(new Vector(0, 0.5), new Vector(0.5, 0.5), RotationType.CW);
p.LineTo(0, 9.5);
p.ArcTo(new Vector(0.5, 10), new Vector(0.5, 9.5), RotationType.CW);
p.LineTo(9.5, 10);
p.ArcTo(new Vector(10, 9.5), new Vector(9.5, 9.5), RotationType.CW);
p.LineTo(10, 0.5);
p.ArcTo(new Vector(9.5, 0), new Vector(9.5, 0.5), RotationType.CW);
p.LineTo(0.5, 0);
return p;
}
p.LineTo(9.5, 0);
p.ArcTo(new Vector(10, 0.5), new Vector(9.5, 0.5), RotationType.CCW);
p.LineTo(10, 9.5);
p.ArcTo(new Vector(9.5, 10), new Vector(9.5, 9.5), RotationType.CCW);
p.LineTo(0.5, 10);
p.ArcTo(new Vector(0, 9.5), new Vector(0.5, 9.5), RotationType.CCW);
p.LineTo(0, 0.5);
p.ArcTo(new Vector(0.5, 0), new Vector(0.5, 0.5), RotationType.CCW);
return p;
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CornerFilletJoints_AreConvexCorners(bool clockwise)
{
var prepared = Capture(RoundedSquare(clockwise));
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
// Both joints of each fillet start the cut at its corner, one radius from the sharp corner.
var corners = candidates.Where(c => c.Kind == AutomaticEntryKind.ConvexCorner).ToList();
Assert.Equal(8, corners.Count);
Assert.All(corners, c => Assert.Equal(0.5, c.CornerReach, 9));
Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(10, 9.5)) < 1e-6);
Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(9.5, 10)) < 1e-6);
Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TangentJoint);
Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.StraightMidpoint));
Assert.All(candidates.Where(c => c.Kind == AutomaticEntryKind.StraightMidpoint),
c => Assert.Equal(0, c.CornerReach));
}
[Fact]
public void FilletLongerThanItsEdge_StaysATangentJoint()
{
// A 5-radius quarter arc after a 1-long bottom edge is a curve, not a rounded corner.
var p = new Program();
p.MoveTo(0, 0);
p.LineTo(1, 0);
p.ArcTo(new Vector(6, 5), new Vector(1, 5), RotationType.CCW);
p.LineTo(6, 6);
p.LineTo(0, 6);
p.LineTo(0, 0);
var prepared = Capture(p);
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
Assert.Equal(2, candidates.Count(c => c.Kind == AutomaticEntryKind.TangentJoint));
Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.ConvexCorner
&& (Distance(c.Choice.Point, new Vector(1, 0)) < 1e-6 || Distance(c.Choice.Point, new Vector(6, 5)) < 1e-6));
}
[Fact]
public void InsideCornerFillet_StaysATangentJoint()
{
// An L whose inside corner is rounded: that fillet turns the reflex way.
var p = new Program();
p.MoveTo(0, 0);
p.LineTo(10, 0);
p.LineTo(10, 4);
p.LineTo(5, 4);
p.ArcTo(new Vector(4, 5), new Vector(5, 5), RotationType.CW);
p.LineTo(4, 10);
p.LineTo(0, 10);
p.LineTo(0, 0);
var prepared = Capture(p);
var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
var joints = candidates.Where(c => c.Kind == AutomaticEntryKind.TangentJoint).ToList();
Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(5, 4)) < 1e-6);
Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(4, 5)) < 1e-6);
}
[Fact]
public void TangentLineArcJoint_IsTangentJoint_NotACorner()
{
@@ -93,6 +93,22 @@ public class ContourEntryRankingTests
Assert.True(rightMid < bottomMid);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void FilletedCorner_BeatsAFacingMidpointNearerTheRoute(bool clockwise)
{
// Travel favours the right edge's midpoint, but a long edge's middle is a last resort:
// the joints of the facing rounded corner count on both of its sides and lead.
var ranked = Catalogue(ContourEntryCandidateTests.RoundedSquare(clockwise))
.RankTowardNextCut(new Vector(14, 6), new Vector(12, 5)).ToList();
Assert.Equal(AutomaticEntryKind.ConvexCorner, ranked[0].Kind);
Assert.True(At(ranked[0], 10, 9.5) || At(ranked[0], 9.5, 10));
var rightMid = ranked.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
Assert.True(ranked.FindIndex(c => At(c, 10, 0.5)) < rightMid);
}
[Fact]
public void TangentAndMidpoint_ShareARankTier()
{
+105
View File
@@ -0,0 +1,105 @@
using OpenNest.Data.MaterialLedger;
using OpenNest.Geometry;
namespace OpenNest.Tests.Data;
/// <summary>
/// Before save and post, a nest's codes are checked against the ledger's free stock (plus the
/// nest's own open holds) and other open nests; conflicting codes can be taken off the plates.
/// </summary>
public class LedgerCodeCheckTests
{
[Fact]
public void FreeCodesOnNoOtherNest_AreClear()
{
var plates = Plates(["S-1"], ["B-1", "B-1"]);
var conflicts = LedgerCodeCheck.Find(plates, [Item("S-1", 1), Item("B-1", 2)], null,
LedgerCodesInUse.Known([]));
Assert.Empty(conflicts);
}
[Fact]
public void TakenCodes_AreReportedPerPlateWithTheirReason()
{
var plates = Plates(["GONE"], ["B-1", "B-1"], ["B-1", "OTHER"]);
var inUse = LedgerCodesInUse.Known([LedgerCodeFilterTests.Use("OTHER", "2026-0042")]);
var conflicts = LedgerCodeCheck.Find(plates, [Item("B-1", 2), Item("OTHER", 1)], null, inUse);
Assert.Equal(
[
(1, "GONE", "Not free in MaterialLedger"),
(2, "B-1", "Only 2 of the 3 sheets needed free in MaterialLedger"),
(3, "B-1", "Only 2 of the 3 sheets needed free in MaterialLedger"),
(3, "OTHER", "On nest 2026-0042"),
], conflicts.Select(c => (c.PlateNumber, c.ItemCode, c.Reason)));
}
[Fact]
public void BothReasons_AreJoined()
{
var inUse = LedgerCodesInUse.Known([LedgerCodeFilterTests.Use("S-1", "B"), LedgerCodeFilterTests.Use("S-1", "A")]);
var conflict = Assert.Single(LedgerCodeCheck.Find(Plates(["S-1"]), [], null, inUse));
Assert.Equal("Not free in MaterialLedger; on nests A, B", conflict.Reason);
}
[Fact]
public void TheNestsOwnOpenHolds_KeepItsSheets()
{
var plates = Plates(["S-1"], ["B-1", "B-1"]);
LedgerHold[] holds =
[
Hold("S-1", LedgerHoldStatuses.Approved),
Hold("B-1", LedgerHoldStatuses.Proposed),
Hold("B-1", LedgerHoldStatuses.Cancelled),
];
var conflicts = LedgerCodeCheck.Find(plates, [Item("B-1", 1)], holds, LedgerCodesInUse.Known([]));
Assert.Empty(conflicts);
}
[Fact]
public void UnknownCodesInUse_CheckTheLedgerAlone()
{
var conflicts = LedgerCodeCheck.Find(Plates(["S-1"]), [Item("S-1", 1)], null,
LedgerCodesInUse.Unknown("nests are saved as files"));
Assert.Empty(conflicts);
}
[Fact]
public void Remove_TakesEveryCopyOffAndKeepsTheRestInOrder()
{
var plates = Plates(["B-1", "S-1", "B-1"], ["S-2"]);
var removed = LedgerCodeCheck.Remove(plates, ["B-1"]);
Assert.Equal(2, removed);
Assert.Equal(["S-1"], plates[0].StockItemCodes);
Assert.Equal(3, plates[0].Quantity);
Assert.Equal(["S-2"], plates[1].StockItemCodes);
Assert.True(LedgerCodeCheck.HasCodes(plates));
LedgerCodeCheck.Remove(plates, ["S-1", "S-2"]);
Assert.False(LedgerCodeCheck.HasCodes(plates));
}
private static List<Plate> Plates(params string[][] codes) =>
codes.Select(c =>
{
var plate = new Plate(new Size(48, 96)) { Quantity = c.Length };
plate.StockItemCodes = c;
return plate;
}).ToList();
private static LedgerStockItem Item(string code, int quantity) =>
new(code, 7, LedgerItemKinds.FullSheet, 48, 96, quantity, LedgerItemShapes.Rectangular, "R1", "Rack 1",
null, null, DateTime.UtcNow);
private static LedgerHold Hold(string code, string status) =>
new(1, code, 48, 96, status, "R1", DateTime.UtcNow);
}
@@ -0,0 +1,175 @@
using OpenNest.Data;
using OpenNest.Data.MaterialLedger;
using OpenNest.Geometry;
namespace OpenNest.Tests.Data;
/// <summary>
/// Codes other open nests are coded with leave Auto Nest's ledger rows: every copy of a code,
/// with its quantity and location, and rows left empty disappear.
/// </summary>
public class LedgerCodeFilterTests
{
[Fact]
public void ABundleLosesEveryCopyOfItsCode_AndAnEmptiedRowIsDropped()
{
var bundle = Row("ledger:1", ["B-1", "B-1", "B-1"]);
var sheet = Row("ledger:2", ["S-1"]);
var free = Row("ledger:3", ["S-2"]);
var rows = LedgerCodeFilter.Exclude([bundle, sheet, free], ["B-1", "S-1"], out var removed);
Assert.Equal(["ledger:3"], rows.Select(r => r.Id));
Assert.Same(free, rows[0]);
Assert.Equal(4, removed);
}
[Fact]
public void DropsKeepTheirOtherCodesLocationsAndQuantity()
{
var drops = Row("ledger:1", ["D-1", "D-2", "D-3"], ("D-1", "R1"), ("D-2", "R2"), ("D-3", "R1")) with
{
Rank = 2,
Label = "A36",
};
var row = Assert.Single(LedgerCodeFilter.Exclude([drops], ["D-2", "UNRELATED"], out var removed));
Assert.Equal(1, removed);
Assert.Equal(2, row.Quantity);
Assert.Equal(["D-1", "D-3"], row.ItemCodes);
Assert.Equal(["R1"], row.Locations);
Assert.Equal(new[] { "D-1", "D-3" }, row.ItemLocations.Keys.Order());
Assert.Equal(("ledger:1", 2, "A36"), (row.Id, row.Rank, row.Label));
// The original row is left alone.
Assert.Equal(3, drops.Quantity);
}
[Fact]
public void NoCodesInUse_KeepsEveryRowAsItWas()
{
var rows = new[] { Row("ledger:1", ["S-1"]) };
var kept = LedgerCodeFilter.Exclude(rows, Array.Empty<string>(), out var removed);
Assert.Same(rows[0], Assert.Single(kept));
Assert.Equal(0, removed);
}
[Fact]
public void CodesMatchExactly()
{
var kept = LedgerCodeFilter.Exclude([Row("ledger:1", ["s-1"])], ["S-1"], out var removed);
Assert.Single(kept);
Assert.Equal(0, removed);
}
[Fact]
public void MappingSummary_CountsTheSheetsOnOtherNests()
{
var mapping = LedgerStockMapper.Map(
[
Item("B-1", LedgerItemKinds.FullSheetBundle, 3),
Item("D-1", LedgerItemKinds.Drop, 1),
Item("D-2", LedgerItemKinds.Drop, 1),
], Units.Inches);
var filtered = LedgerCodeFilter.Exclude(mapping, ["B-1", "D-1"]);
Assert.Equal(4, filtered.OnOtherNests);
Assert.Equal("Ledger: 0 sheet sizes, 1 drop (4 on other nests)", filtered.Summary);
Assert.Equal("Ledger: 1 sheet size, 2 drops", mapping.Summary);
}
[Fact]
public void CodesInUse_NameEachNestOnceAndReportWhyTheyAreUnknown()
{
var known = LedgerCodesInUse.Known(
[
Use("S-1", "Nest B"),
Use("S-1", "Nest A"),
Use("S-2", "Nest A"),
]);
Assert.True(known.IsKnown);
Assert.Equal(["S-1", "S-2"], known.Codes.Order());
Assert.Equal(["Nest A", "Nest B"], known.NestsUsing("S-1"));
var unknown = LedgerCodesInUse.Unknown("nests are saved as files");
Assert.False(unknown.IsKnown);
Assert.Empty(unknown.Codes);
}
[Fact]
public async Task ReadingCodes_NeverThrowsForAnExpectedFailure()
{
Assert.Equal("nests are saved as files", (await LedgerCodesInUse.ReadAsync(null, Guid.Empty, default)).Unavailable);
Assert.Equal("the nest server needs an update",
(await LedgerCodesInUse.ReadAsync(new Repository(_ => throw new NotSupportedException()), Guid.Empty, default)).Unavailable);
Assert.Equal("the nest server could not be reached",
(await LedgerCodesInUse.ReadAsync(new Repository(_ => throw new IOException("down")), Guid.Empty, default)).Unavailable);
var slow = new Repository(async token =>
{
await Task.Delay(Timeout.Infinite, token);
return [];
});
Assert.Equal("the nest server did not answer",
(await LedgerCodesInUse.ReadAsync(slow, Guid.Empty, default, TimeSpan.FromMilliseconds(20))).Unavailable);
using var cancel = new CancellationTokenSource();
cancel.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => LedgerCodesInUse.ReadAsync(slow, Guid.Empty, cancel.Token));
}
[Fact]
public async Task ReadingCodes_ExcludesTheNestBeingEdited()
{
var editing = Guid.NewGuid();
Guid asked = default;
var repository = new Repository(_ => Task.FromResult<IReadOnlyList<NestStockCodeUse>>([Use("S-1", "Other")]))
{
OnAsk = id => asked = id,
};
var codes = await LedgerCodesInUse.ReadAsync(repository, editing, default);
Assert.Equal(editing, asked);
Assert.Equal(["S-1"], codes.Codes);
}
internal static NestStockCodeUse Use(string code, string nest) =>
new() { ItemCode = code, NestId = Guid.NewGuid(), NestName = nest, Status = NestStatus.Quote };
private static LedgerStockRow Row(string id, string[] codes, params (string Code, string Location)[] locations) =>
new(id, new Size(48, 96), codes.Length, IsDrop: codes.Distinct().Count() > 1, IsIrregular: false, codes,
locations.Select(l => l.Location).Distinct().ToList())
{
ItemLocations = locations.ToDictionary(l => l.Code, l => l.Location, StringComparer.Ordinal),
};
private static LedgerStockItem Item(string code, string kind, int quantity) =>
new(code, 7, kind, kind == LedgerItemKinds.Drop ? 24 : 48, kind == LedgerItemKinds.Drop ? 36 : 96, quantity,
LedgerItemShapes.Rectangular, "R1", "Rack 1", null, null, DateTime.UtcNow);
private sealed class Repository(Func<CancellationToken, Task<IReadOnlyList<NestStockCodeUse>>> read) : INestRepository
{
public Action<Guid>? OnAsk { get; init; }
public Task<IReadOnlyList<NestStockCodeUse>> GetStockCodesInUseAsync(Guid excludeNestId, CancellationToken cancellationToken)
{
OnAsk?.Invoke(excludeNestId);
return read(cancellationToken);
}
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<NestRecord> UploadAsync(byte[] nestFile, NestRecord record, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken cancellationToken) => throw new NotImplementedException();
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => throw new NotImplementedException();
}
}
@@ -150,6 +150,41 @@ public class LibraryDrawingInsertTests
Assert.NotNull(drawing.Library);
}
[Fact]
public void RecordWithoutMaterialOrThickness_SuitsAnyNestAndTakesItsMaterial()
{
var nest = new Nest("Other job") { Units = Units.Inches, Thickness = 0.1875, Material = new Material("Other Steel", "Grade B") };
var record = Record();
record.MaterialName = "";
record.MaterialGrade = " ";
record.Thickness = 0;
var insert = LibraryDrawingInsert.Plan(nest, Server, record, Package(), 1);
Assert.True(insert.KeepPins);
Assert.Empty(insert.Warnings);
insert.Apply();
var drawing = Assert.Single(nest.Drawings);
Assert.Single(drawing.CutPins);
Assert.Equal(("Other Steel", "Grade B"), (drawing.Material.Name, drawing.Material.Grade));
Assert.Equal((0.0, ""), (drawing.Library!.Thickness, drawing.Library.MaterialName));
}
[Fact]
public void RecordWithOnlyAMaterial_StillNamesAMaterialDifference()
{
var nest = new Nest("Other job") { Units = Units.Inches, Thickness = 0.1875, Material = new Material("Other Steel", "Grade B") };
var record = Record();
record.MaterialGrade = "";
record.Thickness = 0;
var warning = Assert.Single(LibraryDrawingInsert.Plan(nest, Server, record, Package(), 1).Warnings);
Assert.Contains("material Synthetic Steel (nest Other Steel)", warning);
Assert.DoesNotContain("grade", warning);
Assert.DoesNotContain("thickness", warning);
}
[Fact]
public void EveryDifference_IsNamed()
{
@@ -0,0 +1 @@
__pycache__/
+102
View File
@@ -0,0 +1,102 @@
# Sketch search (prototype)
Finds drawings that look like a hand sketch, a phone photo of one, or an MS Paint
outline. A DINOv2 image model compares the sketch with a rendering of every
drawing. The model is pretrained, optionally fine-tuned on synthetic sketches of
the library itself (see [Fine-tune](#fine-tune)).
## Setup
```sh
pip install torch --index-url https://download.pytorch.org/whl/cu124
pip install -r requirements.txt
```
A CPU-only torch works, but embedding is much slower than on a GPU.
## Build the index
```sh
python build_index.py --index C:\SketchIndex --server http://barge.lan:8091
python build_index.py --index C:\SketchIndex --dxf X:\Archive
```
`--server` reads the server's drawing library with GET requests only. `--dxf`
walks a folder of DXF files and skips text, dimensions, hatches and
`ETCH`/`SCRIBE` layers. Both options can be repeated and combined. Programs are
cached under `<index>/cache`. Re-running the build only processes drawings whose
revision or file changed.
## Search
```sh
python search.py --index C:\SketchIndex sketch.png --open
python search.py --index C:\SketchIndex photo.jpg --photo --size 10x4.5
```
- **MS Paint:** draw the outline in a dark color and save the file, or crop a
screenshot to the canvas. Window chrome in the image counts as part of the
sketch. Either outlines or a filled silhouette work.
- **Photos and scans:** pass `--photo` to use local thresholding, which handles
uneven lighting. Crop to the sketch and take the photo square-on.
- **`--size WxL`:** keeps only drawings of that overall size (either
orientation, inches or millimetres, default ±15%). The size is a hard filter,
so use it only when the sketch's dimensions are reliable.
- **Leave out text and small details:** draw the outline and the holes.
Rotation and mirroring do not matter.
Results go to `<index>/last-search.html`. The page shows the sketch as the model
sees it, followed by the closest drawings with name, customer, material and
size.
## Measure accuracy
```sh
python evaluate.py --index C:\SketchIndex --synthetic 500
python evaluate.py --index C:\SketchIndex --real C:\Sketches
```
`--synthetic` turns random held-out drawings into fake sketches and reports how
often the source drawing ranks in the top N. Fake sketches have wobbly lines,
±15% proportions, dropped small holes, random rotation, mirroring, line weight
and fill. "Same shape" also counts a drawing that renders identically to the
answer, such as duplicates and unchanged revisions.
`--real` takes a folder of real sketches, each named after its drawing
(`BRKT-12.png`, or `BRKT-12__2.jpg` for extra sketches of the same part). Real
sketches are the result that decides whether the approach works.
## How it works
1. Each drawing's cut geometry is rasterized: G-code from the server, or DXF
entities. Scribe, etch and break motions are skipped.
2. Drawings and sketches share one `normalize()` step. It reduces the ink to
1px centre lines (a filled silhouette becomes its boundary), crops and scales
the result into a 224×224 canvas, and redraws it at a fixed stroke width.
3. DINOv2 embeds the image: the CLS token joined with the mean patch token.
4. The query is embedded in all 8 rotations and mirrors, and each drawing scores
its best match. A small penalty applies when the drawing's proportions
differ from the sketch's (`ASPECT_WEIGHT` in `search.py`).
## Fine-tune
The pretrained model alone is weak on a 40k-drawing library. Training it on
synthetic sketches of the library itself needs no labelling:
```sh
python train.py --index C:\SketchIndex --out C:\SketchIndex\model
python build_index.py --index C:\SketchIndex --server http://barge.lan:8091 --model C:\SketchIndex\model
python evaluate.py --index C:\SketchIndex --synthetic 400
```
Each epoch pairs every training drawing's rendering with a freshly generated
fake sketch (`synthetic.py`, kept in the drawing's orientation because search
tries all 8). The model learns to pick each sketch's own drawing out of the
batch. Two groups of drawings are left out of training:
- Near-duplicate renderings, which would count as wrong answers within a batch.
- About 10% of drawings chosen by key (`is_holdout`). `evaluate.py` samples only
these, so its scores are not inflated by training.
Rebuilding the index with a new `--model` reuses the existing renders and only
re-embeds them.
@@ -0,0 +1,191 @@
"""Builds or refreshes a sketch-search index from an OpenNest server and/or DXF folders.
python build_index.py --index C:\\SketchIndex --server http://barge.lan:8091
python build_index.py --index C:\\SketchIndex --dxf X:\\Archive
Re-running only downloads, renders and embeds drawings whose revision changed.
"""
import argparse
import concurrent.futures as cf
import io
import json
import os
import sys
import time
import urllib.request
import zipfile
from pathlib import Path
import numpy as np
from PIL import Image
import sketch_common as sc
def list_server(server, limit=None):
items, offset = [], 0
while True:
page = json.load(urllib.request.urlopen(f"{server}/api/drawings/query?limit=500&offset={offset}", timeout=60))
for r in page["items"]:
items.append({
"key": f"server:{r['id']}", "source": "server", "id": r["id"], "revision": r["revision"],
"sha": r["packageSha256"], "name": r["name"], "customer": r.get("customer", ""),
"material": " ".join(x for x in (r.get("materialName"), r.get("materialGrade")) if x),
"thickness": r.get("thickness"), "units": r.get("units", ""),
})
offset += len(page["items"])
if not page["items"] or offset >= page["total"] or (limit and offset >= limit):
return items[:limit] if limit else items
def list_dxf(folder, limit=None):
items = []
for root, _, files in os.walk(folder):
for f in files:
if f.lower().endswith(".dxf"):
p = Path(root, f)
st = p.stat()
items.append({"key": f"dxf:{p}", "source": "dxf", "path": str(p), "name": p.stem,
"sha": f"{st.st_size}-{int(st.st_mtime)}", "units": ""})
if limit and len(items) >= limit:
return items
return items
def program_path(index, item):
return Path(index, "cache", f"{item['id']}-r{item['revision']}.gcode")
def download(server, index, item):
path = program_path(index, item)
if path.exists():
return None
for attempt in range(3):
try:
data = urllib.request.urlopen(f"{server}/api/drawings/{item['id']}/file", timeout=60).read()
z = zipfile.ZipFile(io.BytesIO(data))
programs = sorted(n for n in z.namelist() if n.startswith("programs/program-") and not n.endswith("-subs"))
path.write_bytes(z.read(programs[0]) if programs else b"")
return None
except Exception as e: # noqa: BLE001 - reported per drawing
err = e
time.sleep(1 + attempt)
return f"{item['name']}: {err}"
def load_polys(index, item):
if item["source"] == "server":
return sc.parse_program(program_path(index, item).read_text(errors="replace"))
return sc.read_dxf(item["path"])
def render_one(args):
index, item = args
try:
polys = load_polys(index, item)
if not polys:
return item["key"], None, "no cut geometry"
lo, hi = sc.bounds(polys)
sc.normalize(sc.render_mask(polys)).convert("L").save(Path(index, "thumbs", thumb_name(item)))
return item["key"], (float(hi[0] - lo[0]), float(hi[1] - lo[1])), None
except Exception as e: # noqa: BLE001 - reported per drawing
return item["key"], None, str(e)
def thumb_name(item):
if item["source"] == "server":
return f"{item['id']}.png"
import hashlib
return "dxf-" + hashlib.sha1(item["path"].encode()).hexdigest()[:16] + ".png"
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--index", required=True, help="index folder (created if missing)")
ap.add_argument("--server", action="append", default=[], help="OpenNest server URL")
ap.add_argument("--dxf", action="append", default=[], help="folder of DXF files (searched recursively)")
ap.add_argument("--model", default="facebook/dinov2-small", help="Hugging Face model or fine-tuned model folder")
ap.add_argument("--limit", type=int, help="index at most this many drawings per source (testing)")
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
args = ap.parse_args()
if not args.server and not args.dxf:
ap.error("give at least one --server or --dxf source")
index = Path(args.index)
for sub in ("cache", "thumbs"):
Path(index, sub).mkdir(parents=True, exist_ok=True)
items = []
for server in args.server:
server = server.rstrip("/")
found = list_server(server, args.limit)
print(f"{server}: {len(found)} drawings")
with cf.ThreadPoolExecutor(12) as pool:
errors = [e for e in pool.map(lambda it: download(server, index, it), found) if e]
for e in errors[:10]:
print(" download failed:", e)
items += found
for folder in args.dxf:
found = list_dxf(folder, args.limit)
print(f"{folder}: {len(found)} DXF files")
items += found
config = {"model": args.model}
old_items, old_emb = {}, None
if Path(index, "items.jsonl").exists() and Path(index, "config.json").exists():
old_list = [json.loads(l) for l in Path(index, "items.jsonl").read_text(encoding="utf-8").splitlines()]
old_items = {it["key"]: (it, i) for i, it in enumerate(old_list)}
if json.loads(Path(index, "config.json").read_text()) == config:
old_emb = np.load(Path(index, "embeddings.npy"))
# Renders survive a model change; embeddings only survive with the same model.
embedded, unembedded, todo = [], [], []
for it in items:
prev = old_items.get(it["key"])
if prev and prev[0]["sha"] == it["sha"] and Path(index, "thumbs", thumb_name(it)).exists():
it.update(width=prev[0]["width"], height=prev[0]["height"])
if old_emb is not None:
embedded.append((it, old_emb[prev[1]]))
else:
unembedded.append(it)
else:
todo.append(it)
print(f"reusing {len(embedded) + len(unembedded)} renders, rendering {len(todo)}")
by_key = {it["key"]: it for it in todo}
failures = []
started = time.time()
with cf.ProcessPoolExecutor(args.workers) as pool:
for n, (key, size, err) in enumerate(pool.map(render_one, [(str(index), it) for it in todo], chunksize=16), 1):
if err:
failures.append((key, err))
else:
by_key[key].update(width=size[0], height=size[1])
unembedded.append(by_key[key])
if n % 2000 == 0:
print(f" rendered {n}/{len(todo)} ({time.time() - started:.0f}s)")
print(f"rendered {len(todo) - len(failures)} in {time.time() - started:.0f}s, {len(failures)} skipped")
for key, err in failures[:15]:
print(" skipped", key, "-", err)
rows = [e for _, e in embedded]
if unembedded:
embedder = sc.Embedder(args.model)
started = time.time()
load = lambda it: Image.open(Path(index, "thumbs", thumb_name(it))).convert("RGB")
with cf.ThreadPoolExecutor(8) as pool:
for i in range(0, len(unembedded), 2048):
rows += list(embedder.embed(list(pool.map(load, unembedded[i:i + 2048]))))
print(f"embedded {len(unembedded)} in {time.time() - started:.0f}s")
all_items = [it for it, _ in embedded] + unembedded
for it in all_items:
it["thumb"] = thumb_name(it)
np.save(Path(index, "embeddings.npy"), np.stack(rows).astype(np.float16))
Path(index, "items.jsonl").write_text("\n".join(json.dumps(it) for it in all_items), encoding="utf-8")
Path(index, "config.json").write_text(json.dumps(config))
print(f"index: {len(all_items)} drawings in {index}")
if __name__ == "__main__":
sys.exit(main())
+117
View File
@@ -0,0 +1,117 @@
"""Measures how often search finds the right drawing.
Synthetic: turns random held-out drawings (never trained on) into fake hand
sketches and records where the source drawing ranks among the whole index.
python evaluate.py --index C:\\SketchIndex --synthetic 300
Real: a folder of sketch images, each named after the drawing it should find
(the drawing name, or name + anything after "__", e.g. "BRKT-12__photo.jpg").
python evaluate.py --index C:\\SketchIndex --real C:\\Sketches [--photo]
A "same shape" hit also counts a drawing whose rendering is effectively identical
to the answer (duplicates and unchanged revisions are common in a part library).
"""
import argparse
import json
import random
import sys
from pathlib import Path
import numpy as np
from PIL import Image
import build_index
from synthetic import sketchify
import sketch_common as sc
from search import ASPECT_WEIGHT, SketchIndex
SAME_SHAPE = 0.985
CUTOFFS = (1, 5, 10, 20, 50)
def rank_of(index, results_keys, truth_idx):
"""(strict rank, same-shape rank), 1-based; None when beyond the list."""
truth_emb = index.embeddings[truth_idx]
strict = same = None
for r, i in enumerate(results_keys, 1):
if strict is None and i == truth_idx:
strict = r
if same is None and (i == truth_idx or float(index.embeddings[i] @ truth_emb) >= SAME_SHAPE):
same = r
return strict, same
def full_ranking(index, mask, k, aspect_weight):
scores = index.scores(sc.normalize(mask), sc.aspect(mask), aspect_weight)
return list(np.argsort(-scores)[:k])
def report(name, ranks, k):
print(f"\n{name}: {len(ranks)} queries")
for label, col in (("exact drawing", 0), ("same shape", 1)):
found = [r[col] for r in ranks]
cells = " ".join(f"top{c}: {100 * sum(1 for f in found if f and f <= c) / len(found):5.1f}%" for c in CUTOFFS)
print(f" {label:13} {cells} (median rank {np.median([f or k + 1 for f in found]):.0f})")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--index", required=True)
ap.add_argument("--synthetic", type=int, help="number of synthetic sketches")
ap.add_argument("--real", help="folder of real sketches named after their drawing")
ap.add_argument("--photo", action="store_true")
ap.add_argument("--model", help="evaluate this model instead of the index's (re-embeds renders in memory)")
ap.add_argument("--seed", type=int, default=1)
ap.add_argument("--save", help="folder to save synthetic sketches and their normalized form")
ap.add_argument("--aspect-weight", type=float, nargs="+", default=[ASPECT_WEIGHT],
help="one or more proportion-penalty weights to compare")
args = ap.parse_args()
k = 200
index = SketchIndex(args.index, args.model)
if args.synthetic:
rng = random.Random(args.seed)
holdout = [i for i, it in enumerate(index.items) if sc.is_holdout(it["key"])]
picks = rng.sample(holdout, min(args.synthetic, len(holdout)))
ranks = {w: [] for w in args.aspect_weight}
if args.save:
Path(args.save).mkdir(parents=True, exist_ok=True)
for n, i in enumerate(picks):
it = index.items[i]
sketch = sketchify(build_index.load_polys(args.index, it), rng)
mask = sc.sketch_mask(sketch)
for w in args.aspect_weight:
ranks[w].append(rank_of(index, full_ranking(index, mask, k, w), i))
if args.save and n < 40:
sketch.save(Path(args.save, f"{n:03}-sketch.png"))
sc.normalize(mask).save(Path(args.save, f"{n:03}-query.png"))
Path(args.save, f"{n:03}-answer.txt").write_text(
json.dumps({"name": it["name"], "thumb": it["thumb"], "rank": ranks[args.aspect_weight[0]][-1]}))
for w in args.aspect_weight:
report(f"synthetic sketches vs {len(index.items)} drawings, aspect weight {w:g}", ranks[w], k)
if args.real:
by_name = {}
for i, it in enumerate(index.items):
by_name.setdefault(it["name"].lower(), []).append(i)
ranks = []
for f in sorted(Path(args.real).iterdir()):
if f.suffix.lower() not in (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"):
continue
name = f.stem.split("__")[0].lower()
if name not in by_name:
print(f" {f.name}: no indexed drawing named '{name}'")
continue
ranking = full_ranking(index, sc.sketch_mask(Image.open(f), photo=args.photo), k, args.aspect_weight[0])
best = min((rank_of(index, ranking, i) for i in by_name[name]),
key=lambda r: (r[0] or k + 1, r[1] or k + 1))
print(f" {f.name}: rank {best[0] or f'>{k}'} (same shape {best[1] or f'>{k}'})")
ranks.append(best)
if ranks:
report("real sketches", ranks, k)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,8 @@
# PyTorch: install the CUDA build first for GPU embedding, e.g.
# pip install torch --index-url https://download.pytorch.org/whl/cu124
torch>=2.4
transformers>=4.45
numpy>=1.26
pillow>=10.0
scikit-image>=0.22
ezdxf>=1.3
+139
View File
@@ -0,0 +1,139 @@
"""Finds the drawings that look most like a sketch.
python search.py --index C:\\SketchIndex sketch.png
python search.py --index C:\\SketchIndex sketch.jpg --photo --size 10x4.5 --open
Writes an HTML page of the best matches beside the index (or --out) and prints them.
"""
import argparse
import html
import json
import sys
import webbrowser
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import numpy as np
from PIL import Image
import sketch_common as sc
MM_PER_INCH = 25.4
ASPECT_WEIGHT = 0.1
class SketchIndex:
def __init__(self, folder, model=None):
"""model overrides the index's model: its renders are re-embedded in memory."""
self.folder = Path(folder)
config = json.loads(Path(folder, "config.json").read_text())
self.items = [json.loads(l) for l in Path(folder, "items.jsonl").read_text(encoding="utf-8").splitlines()]
self.embedder = sc.Embedder(model or config["model"])
if model and model != config["model"]:
load = lambda it: Image.open(Path(folder, "thumbs", it["thumb"])).convert("RGB")
with ThreadPoolExecutor(8) as pool:
self.embeddings = np.concatenate([self.embedder.embed(list(pool.map(load, self.items[i:i + 2048])))
for i in range(0, len(self.items), 2048)])
else:
self.embeddings = np.load(Path(folder, "embeddings.npy")).astype(np.float32)
self._aspects = None
def size_mask(self, size, tolerance):
"""Drawings whose extents match size (either orientation, inches or millimetres)."""
want = np.sort(np.array(size, dtype=np.float64))
have = np.sort(np.array([[it["width"], it["height"]] for it in self.items]), axis=1)
ok = np.zeros(len(self.items), bool)
for factor in (1.0, MM_PER_INCH, 1 / MM_PER_INCH):
ok |= np.all(np.abs(have * factor - want) <= tolerance * want, axis=1)
return ok
def scores(self, query, aspect=None, aspect_weight=ASPECT_WEIGHT):
"""Similarity of every drawing to a normalized query image; aspect is the
sketch's short/long side ratio, which penalizes drawings of other proportions."""
variants = self.embedder.embed(sc.dihedral(query))
scores = (self.embeddings @ variants.T).max(axis=1)
if aspect and aspect_weight:
if self._aspects is None:
wh = np.array([[it["width"], it["height"]] for it in self.items])
self._aspects = np.maximum(wh.min(axis=1), 1e-9) / np.maximum(wh.max(axis=1), 1e-9)
scores = scores - aspect_weight * np.abs(np.log(self._aspects / aspect))
return scores
def search(self, query, k=30, size=None, tolerance=0.15, aspect=None, aspect_weight=ASPECT_WEIGHT):
"""query is a normalized image; returns [(score, item)] best first."""
scores = self.scores(query, aspect, aspect_weight)
if size:
scores = np.where(self.size_mask(size, tolerance), scores, -np.inf)
order = np.argsort(-scores)[:k]
return [(float(scores[i]), self.items[i]) for i in order if np.isfinite(scores[i])]
def parse_size(text):
parts = text.lower().replace(" ", "").split("x")
if len(parts) != 2:
raise argparse.ArgumentTypeError("size looks like 10x4.5")
return float(parts[0]), float(parts[1])
def write_html(path, index_folder, query_image, results, title):
path = Path(path)
query_file = path.with_suffix(".query.png")
query_image.save(query_file)
thumbs = Path(index_folder, "thumbs").resolve().as_uri()
cards = []
for rank, (score, it) in enumerate(results, 1):
details = " · ".join(str(x) for x in (
it.get("customer"), it.get("material"),
f"{it['thickness']:g} thk" if it.get("thickness") else None,
f"{it['width']:.3g} × {it['height']:.3g} {it.get('units', '')}".strip()) if x)
where = it.get("path") or f"server drawing {it.get('id')}"
cards.append(f"""<figure><img src="{thumbs}/{it['thumb']}"><figcaption>
<b>{rank}. {html.escape(it['name'])}</b><span>{html.escape(details)}</span>
<small title="{html.escape(where)}">score {score:.3f} · {html.escape(where)}</small></figcaption></figure>""")
path.write_text(f"""<!doctype html><meta charset="utf-8"><title>{html.escape(title)}</title>
<style>
:root {{ color-scheme: light dark; --bg:#fafafa; --card:#fff; --fg:#222; --muted:#666; --line:#ddd; }}
@media (prefers-color-scheme: dark) {{ :root {{ --bg:#1b1b1b; --card:#262626; --fg:#eee; --muted:#aaa; --line:#3a3a3a; }} }}
body {{ font: 14px system-ui, sans-serif; background: var(--bg); color: var(--fg); margin: 16px; }}
header {{ display: flex; gap: 16px; align-items: center; margin-bottom: 16px; }}
header img {{ width: 160px; border: 1px solid var(--line); background: #fff; }}
main {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }}
figure {{ margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 8px; }}
figure img {{ width: 100%; background: #fff; border-radius: 4px; }}
figcaption {{ display: flex; flex-direction: column; gap: 2px; overflow-wrap: anywhere; }}
span, small {{ color: var(--muted); }}
</style>
<header><img src="{query_file.name}"><div><h2>{html.escape(title)}</h2>
<p>The sketch as the model sees it (left), then the {len(results)} closest drawings.</p></div></header>
<main>{''.join(cards)}</main>""", encoding="utf-8")
return path
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("sketch", help="image of the sketch (PNG/JPG; crop off any window chrome)")
ap.add_argument("--index", required=True)
ap.add_argument("-k", type=int, default=30, help="number of matches")
ap.add_argument("--photo", action="store_true", help="camera photo or scan: adaptive thresholding")
ap.add_argument("--size", type=parse_size, help="overall size, e.g. 10x4.5 (either orientation, in or mm)")
ap.add_argument("--tolerance", type=float, default=0.15, help="relative size tolerance (default 0.15)")
ap.add_argument("--out", help="HTML results file (default: <index>/last-search.html)")
ap.add_argument("--open", action="store_true", help="open the results in a browser")
args = ap.parse_args()
mask = sc.sketch_mask(Image.open(args.sketch), photo=args.photo)
index = SketchIndex(args.index)
query = sc.normalize(mask)
results = index.search(query, args.k, args.size, args.tolerance, sc.aspect(mask))
for rank, (score, it) in enumerate(results, 1):
print(f"{rank:3}. {score:.3f} {it['name']} [{it.get('customer', '')}] "
f"{it['width']:.3g}x{it['height']:.3g}")
out = write_html(args.out or Path(args.index, "last-search.html"), args.index, query, results,
f"Matches for {Path(args.sketch).name}")
print(f"results: {out}")
if args.open:
webbrowser.open(out.resolve().as_uri())
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,264 @@
"""Shared geometry reading, image normalization and embedding for sketch search.
Drawings and sketches go through the same normalize() so the model compares like
with like: ink is reduced to 1px centre lines, cropped, scaled to fit a square
canvas and re-stroked at a fixed width.
"""
import math
import re
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
from skimage.filters import threshold_local
from skimage.morphology import binary_erosion, remove_small_objects, skeletonize
CANVAS = 224
MARGIN = 14
STROKE = 3
RENDER = 1024
FILLED_RATIO = 0.35
# Motions with these tags are marks or open break cuts, not part outline.
SKIPPED_TAGS = {"SCRIBE", "ETCH", "BREAK", "LEADIN", "LEADOUT"}
_WORD = re.compile(r"([A-Z])\s*(-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)")
def is_holdout(key):
"""About 10% of drawings, chosen by key, are never trained on and are what evaluation samples."""
import hashlib
return hashlib.md5(key.encode()).digest()[0] < 26
# ---------------------------------------------------------------- geometry
def _arc_points(sx, sy, ex, ey, cx, cy, clockwise):
r = math.hypot(sx - cx, sy - cy)
a0 = math.atan2(sy - cy, sx - cx)
a1 = math.atan2(ey - cy, ex - cx)
sweep = a1 - a0
if clockwise:
if sweep >= -1e-9:
sweep -= 2 * math.pi
elif sweep <= 1e-9:
sweep += 2 * math.pi
n = max(4, math.ceil(abs(sweep) / (math.pi / 36)))
return [(cx + r * math.cos(a0 + sweep * i / n), cy + r * math.sin(a0 + sweep * i / n))
for i in range(1, n + 1)]
def parse_program(text):
"""Returns the cut polylines of an OpenNest drawing program (G90/G91, G00-G03)."""
absolute = True
motion = 0
x = y = 0.0
polys = []
current = None
for raw in text.splitlines():
code, _, tag = raw.partition(":")
words = _WORD.findall(code.upper())
if not words:
continue
values = {}
for letter, number in words:
if letter == "G":
g = int(float(number))
if g == 90:
absolute = True
elif g == 91:
absolute = False
elif g in (0, 1, 2, 3):
motion = g
else:
values = None # G65 calls and other codes are not geometry
break
else:
values[letter] = float(number)
if not values or ("X" not in values and "Y" not in values):
continue
if absolute:
nx, ny = values.get("X", x), values.get("Y", y)
else:
nx, ny = x + values.get("X", 0.0), y + values.get("Y", 0.0)
skipped = tag.strip().upper().split(" ")[0] in SKIPPED_TAGS
if motion == 0 or skipped:
current = None
else:
if current is None:
current = [(x, y)]
polys.append(current)
if motion == 1:
current.append((nx, ny))
else:
cx, cy = x + values.get("I", 0.0), y + values.get("J", 0.0)
current.extend(_arc_points(x, y, nx, ny, cx, cy, motion == 2))
x, y = nx, ny
return [p for p in polys if len(p) > 1]
_DXF_SKIP_TYPES = {"TEXT", "MTEXT", "DIMENSION", "HATCH", "POINT", "ATTDEF", "ATTRIB",
"LEADER", "MULTILEADER", "IMAGE", "WIPEOUT", "VIEWPORT", "SOLID", "3DFACE"}
def read_dxf(path):
"""Returns the polylines of a DXF's model space, skipping text, dimensions and marks."""
import ezdxf
from ezdxf import path as ezpath
from ezdxf import recover
doc, _ = recover.readfile(str(path))
polys = []
def visit(entities, depth=0):
for e in entities:
kind = e.dxftype()
layer = (e.dxf.get("layer", "") or "").upper()
if kind in _DXF_SKIP_TYPES or "ETCH" in layer or "SCRIBE" in layer:
continue
if kind == "INSERT":
if depth < 8:
try:
visit(e.virtual_entities(), depth + 1)
except Exception:
pass
continue
try:
p = ezpath.make_path(e)
except (TypeError, ValueError, ezdxf.DXFError):
continue
pts = [(v.x, v.y) for v in p.flattening(0.002, segments=8)]
if len(pts) > 1:
polys.append(pts)
visit(doc.modelspace())
return polys
def bounds(polys):
pts = np.array([pt for p in polys for pt in p])
lo, hi = pts.min(axis=0), pts.max(axis=0)
return lo, hi
# ---------------------------------------------------------------- images
def render_mask(polys, size=RENDER, width=2):
"""Rasterizes polylines to a boolean ink mask, aspect preserved."""
lo, hi = bounds(polys)
span = max(hi[0] - lo[0], hi[1] - lo[1], 1e-9)
scale = (size - 8) / span
w = max(8, int((hi[0] - lo[0]) * scale) + 8)
h = max(8, int((hi[1] - lo[1]) * scale) + 8)
img = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(img)
for p in polys:
draw.line([((px - lo[0]) * scale + 4, h - 1 - ((py - lo[1]) * scale + 4)) for px, py in p],
fill=255, width=width)
return np.asarray(img) > 0
def sketch_mask(image, photo=False):
"""Converts a sketch image (scan, photo or MS Paint drawing) to a boolean ink mask."""
if image.mode in ("RGBA", "LA") or "transparency" in image.info:
bg = Image.new("RGBA", image.size, (255, 255, 255, 255))
image = Image.alpha_composite(bg, image.convert("RGBA"))
gray = np.asarray(image.convert("L"), dtype=np.float32)
if gray.max() - gray.min() < 10:
raise ValueError("The sketch image is blank.")
if photo:
gray = np.asarray(Image.fromarray(gray.astype(np.uint8)).filter(ImageFilter.GaussianBlur(1.5)),
dtype=np.float32)
block = max(15, (min(gray.shape) // 20) | 1)
ink = gray < threshold_local(gray, block, offset=10)
else:
# Anything clearly darker than the paper is ink. Otsu would split faint
# strokes from dark ones; the border is paper even when a fill dominates.
border = np.concatenate([gray[0], gray[-1], gray[:, 0], gray[:, -1]])
paper = float(np.median(border))
if paper < 128: # light lines on a dark background
gray, paper = 255 - gray, 255 - paper
darkest = float(np.percentile(gray, 0.1))
ink = gray < paper - 0.35 * (paper - darkest)
return remove_small_objects(ink, max(16, ink.size // 200000))
def aspect(mask):
"""Short/long side ratio of the ink's bounding box."""
ys, xs = np.nonzero(mask)
if len(xs) == 0:
raise ValueError("No ink found.")
h, w = ys.max() - ys.min() + 1, xs.max() - xs.min() + 1
return min(h, w) / max(h, w)
def normalize(mask):
"""Crops, centre-lines, scales and re-strokes an ink mask; returns a CANVAS x CANVAS RGB image."""
ys, xs = np.nonzero(mask)
if len(xs) == 0:
raise ValueError("No ink found.")
mask = mask[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
mask = np.pad(mask, 2)
if mask.mean() > FILLED_RATIO: # a filled silhouette: keep its boundary
mask = mask & ~binary_erosion(mask)
mask = skeletonize(mask)
h, w = mask.shape
s = (CANVAS - 2 * MARGIN) / max(h, w)
tw, th = max(1, round(w * s)), max(1, round(h * s))
small = Image.fromarray(mask.astype(np.uint8) * 255).resize((tw, th), Image.BOX)
small = small.point(lambda v: 255 if v > 0 else 0).filter(ImageFilter.MaxFilter(STROKE))
canvas = Image.new("L", (CANVAS, CANVAS), 0)
canvas.paste(small, ((CANVAS - tw) // 2, (CANVAS - th) // 2))
return Image.eval(canvas, lambda v: 255 - v).convert("RGB")
def dihedral(image):
"""The 8 rotations/mirrors of a square image."""
out = []
for flip in (False, True):
base = image.transpose(Image.FLIP_LEFT_RIGHT) if flip else image
for k in range(4):
out.append(base.rotate(90 * k))
return out
# ---------------------------------------------------------------- embedding
class Embedder:
def __init__(self, model_name="facebook/dinov2-small", device=None):
import torch
from transformers import AutoModel
self.torch = torch
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.dtype = torch.float16 if self.device == "cuda" else torch.float32
self.model = AutoModel.from_pretrained(model_name).to(self.device, self.dtype).eval()
self.model_name = model_name
def embed(self, images, batch=64):
"""L2-normalized embeddings (CLS token joined with mean patch token) as float32."""
torch = self.torch
out = []
with torch.inference_mode():
for i in range(0, len(images), batch):
x = torch.from_numpy(np.stack([pixels(im) for im in images[i:i + batch]]))
out.append(features(self.model, x.to(self.device, self.dtype)).cpu().numpy())
return np.concatenate(out) if out else np.zeros((0, 0), np.float32)
_MEAN = np.array([0.485, 0.456, 0.406], np.float32).reshape(3, 1, 1)
_STD = np.array([0.229, 0.224, 0.225], np.float32).reshape(3, 1, 1)
def pixels(image):
"""A normalized RGB image as a CHW float32 array in the model's input scale."""
return (np.asarray(image, dtype=np.float32).transpose(2, 0, 1) / 255 - _MEAN) / _STD
def features(model, x):
"""Embeddings of a batch of pixels() tensors; differentiable for training."""
import torch.nn.functional as F
import torch
hidden = model(pixel_values=x).last_hidden_state
cls = F.normalize(hidden[:, 0].float(), dim=1)
patch = F.normalize(hidden[:, 1:].float().mean(dim=1), dim=1)
return F.normalize(torch.cat([cls, patch], dim=1), dim=1)
@@ -0,0 +1,69 @@
"""Fake hand sketches generated from drawing geometry, for evaluation and training.
Sketches get wobbly lines, up to 15% distorted proportions, about half their small
holes left out, a few degrees of tilt, random line weight and pen darkness, and
are sometimes filled in as a silhouette.
"""
import numpy as np
from PIL import Image, ImageDraw
import sketch_common as sc
def sketchify(polys, rng, reorient=True):
"""A fake hand sketch of a drawing as a grayscale image. With reorient=False the
sketch keeps the drawing's orientation apart from a few degrees of tilt."""
lo, hi = sc.bounds(polys)
span = max(hi - lo)
kept = []
for p in polys:
a = np.array(p)
closed = np.hypot(*(a[0] - a[-1])) < 1e-3 * span
if closed and max(a.max(0) - a.min(0)) < 0.08 * span and rng.random() < 0.5:
continue
kept.append(a)
if not kept:
kept = [np.array(p) for p in polys]
angle = (rng.choice([0, 90, 180, 270]) if reorient else 0) + rng.gauss(0, 3)
t = np.radians(angle)
rot = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]])
stretch = np.diag([rng.uniform(0.85, 1.15), 1.0])
mirror = np.diag([-1.0, 1.0]) if reorient and rng.random() < 0.25 else np.eye(2)
m = rot @ stretch @ mirror
nprng = np.random.default_rng(rng.randrange(1 << 30))
amp = span * rng.uniform(0.001, 0.005)
shaky = []
for a in kept:
seg = np.hypot(*np.diff(a, axis=0).T)
length = np.concatenate([[0], np.cumsum(seg)])
n = max(8, int(length[-1] / (span * 0.004)))
s = np.linspace(0, length[-1], n)
pts = np.stack([np.interp(s, length, a[:, 0]), np.interp(s, length, a[:, 1])], axis=1)
noise = nprng.normal(size=(n + 30, 2))
kernel = np.hanning(31) / np.hanning(31).sum()
smooth = np.stack([np.convolve(noise[:, i], kernel, "valid") for i in range(2)], axis=1)[:n]
smooth *= amp / (smooth.std() + 1e-12)
shaky.append((pts - lo) @ m.T + smooth)
allpts = np.concatenate(shaky)
lo2, hi2 = allpts.min(0), allpts.max(0)
scale = 700 / max(hi2 - lo2)
w, h = int((hi2[0] - lo2[0]) * scale) + 100, int((hi2[1] - lo2[1]) * scale) + 100
to_px = lambda a: [((x - lo2[0]) * scale + 50, h - ((y - lo2[1]) * scale + 50)) for x, y in a]
if rng.random() < 0.2: # filled silhouette, holes by even-odd
ink = np.zeros((h, w), bool)
for a in shaky:
layer = Image.new("1", (w, h), 0)
ImageDraw.Draw(layer).polygon(to_px(a), fill=1)
ink ^= np.asarray(layer)
img = Image.fromarray(np.where(ink, 0, 255).astype(np.uint8))
else:
img = Image.new("L", (w, h), 255)
draw = ImageDraw.Draw(img)
width, pen = rng.randint(2, 9), rng.randint(0, 120)
for a in shaky:
draw.line(to_px(a), fill=pen, width=width, joint="curve")
return img
+122
View File
@@ -0,0 +1,122 @@
"""Fine-tunes the sketch-search embedding on fake sketches of indexed drawings.
Each step pairs fresh synthetic sketches with their drawings' renderings and trains
the model to pick each sketch's own drawing out of the batch (symmetric InfoNCE).
Held-out drawings (sketch_common.is_holdout) and near-duplicate renderings, which
would be false negatives within a batch, are left out. No hand labelling is needed.
python train.py --index C:\\SketchIndex --out C:\\SketchIndex\\model
python build_index.py --index C:\\SketchIndex --server http://barge.lan:8091 --model C:\\SketchIndex\\model
"""
import argparse
import json
import math
import random
import sys
import time
from pathlib import Path
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
import build_index
import sketch_common as sc
from synthetic import sketchify
DUPLICATE = 0.985
class SketchPairs(torch.utils.data.Dataset):
def __init__(self, index, items):
self.index, self.items = str(index), items
def __len__(self):
return len(self.items)
def __getitem__(self, i):
it = self.items[i]
clean = Image.open(Path(self.index, "thumbs", it["thumb"])).convert("RGB")
try:
polys = build_index.load_polys(self.index, it)
sketch = sc.normalize(sc.sketch_mask(sketchify(polys, random.Random(), reorient=False)))
except Exception: # noqa: BLE001 - a degenerate sketch falls back to the clean image
sketch = clean
return torch.from_numpy(sc.pixels(sketch)), torch.from_numpy(sc.pixels(clean))
def distinct(items, embeddings, device):
"""Indices of items whose rendering is not a near-duplicate of an earlier item's."""
e = torch.from_numpy(embeddings).to(device, torch.float16)
keep = []
for start in range(0, len(items), 4096):
block = e[start:start + 4096] @ e[:start + 4096].T
rows = torch.arange(block.shape[0], device=device) + start
earlier = torch.arange(block.shape[1], device=device)[None, :] < rows[:, None]
best = block.masked_fill(~earlier, -1).max(dim=1).values
keep += [start + i for i, dup in enumerate((best >= DUPLICATE).cpu().numpy()) if not dup]
return keep
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--index", required=True, help="index built with the base model")
ap.add_argument("--out", required=True, help="folder for the fine-tuned model")
ap.add_argument("--base", help="model to start from (default: the index's model)")
ap.add_argument("--epochs", type=int, default=5)
ap.add_argument("--batch", type=int, default=96)
ap.add_argument("--lr", type=float, default=2e-5)
ap.add_argument("--temperature", type=float, default=0.05)
ap.add_argument("--workers", type=int, default=12)
args = ap.parse_args()
from transformers import AutoModel
device = "cuda" if torch.cuda.is_available() else "cpu"
config = json.loads(Path(args.index, "config.json").read_text())
items = [json.loads(l) for l in Path(args.index, "items.jsonl").read_text(encoding="utf-8").splitlines()]
embeddings = np.load(Path(args.index, "embeddings.npy"))
keep = distinct(items, embeddings, device)
train = [items[i] for i in keep if not sc.is_holdout(items[i]["key"])]
print(f"{len(items)} drawings, {len(keep)} distinct, training on {len(train)}")
loader = torch.utils.data.DataLoader(
SketchPairs(args.index, train), batch_size=args.batch, shuffle=True, drop_last=True,
num_workers=args.workers, persistent_workers=args.workers > 0, pin_memory=True)
model = AutoModel.from_pretrained(args.base or config["model"]).to(device).train()
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.05)
total = args.epochs * len(loader)
warmup = max(1, total // 20)
sched = torch.optim.lr_scheduler.LambdaLR(
opt, lambda s: min(1, (s + 1) / warmup) * 0.5 * (1 + math.cos(math.pi * min(1, s / total))))
step, started = 0, time.time()
for epoch in range(args.epochs):
losses, hits = [], []
for sketches, cleans in loader:
x = torch.cat([sketches, cleans]).to(device, non_blocking=True)
with torch.autocast(device, dtype=torch.bfloat16):
f = sc.features(model, x)
fs, fc = f[:len(sketches)], f[len(sketches):]
logits = fs @ fc.T / args.temperature
target = torch.arange(len(fs), device=device)
loss = (F.cross_entropy(logits, target) + F.cross_entropy(logits.T, target)) / 2
opt.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
sched.step()
step += 1
losses.append(loss.item())
hits.append((logits.argmax(dim=1) == target).float().mean().item())
if step % 50 == 0:
print(f" step {step}/{total} loss {np.mean(losses[-50:]):.3f} "
f"in-batch top1 {100 * np.mean(hits[-50:]):.1f}% ({time.time() - started:.0f}s)", flush=True)
print(f"epoch {epoch + 1}: loss {np.mean(losses):.3f}, in-batch top1 {100 * np.mean(hits):.1f}%", flush=True)
model.save_pretrained(args.out)
print(f"saved {args.out}")
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,160 @@
using System.Collections.Specialized;
using System.Configuration;
using System.Drawing;
using System.Reflection;
using OpenNest.Properties;
namespace OpenNest.WinForms.Tests;
public sealed class AppDataSettingsProviderTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "OpenNestSettings-" + Guid.NewGuid().ToString("N"));
private string SettingsPath => Path.Combine(_directory, "user-settings.json");
public void Dispose()
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
[Fact]
public void SettingsUseTheAppDataProvider()
{
var attribute = typeof(Settings).GetCustomAttribute<SettingsProviderAttribute>();
Assert.NotNull(attribute);
Assert.Equal(typeof(AppDataSettingsProvider).AssemblyQualifiedName, attribute.ProviderTypeName);
}
[Fact]
public void SavedValuesReadBackInANewProvider()
{
var properties = Properties();
var values = new AppDataSettingsProvider(SettingsPath, null).GetPropertyValues(new SettingsContext(), properties);
values["IncludeDevBuilds"].PropertyValue = true;
values["WindowLocation"].PropertyValue = new Point(40, 60);
new AppDataSettingsProvider(SettingsPath, null).SetPropertyValues(new SettingsContext(), values);
var reloaded = new AppDataSettingsProvider(SettingsPath, null).GetPropertyValues(new SettingsContext(), Properties());
Assert.Equal(true, reloaded["IncludeDevBuilds"].PropertyValue);
Assert.Equal(new Point(40, 60), reloaded["WindowLocation"].PropertyValue);
Assert.Equal("", reloaded["FeedUrl"].PropertyValue);
}
[Fact]
public void UntouchedDefaultsAreNotWritten()
{
var provider = new AppDataSettingsProvider(SettingsPath, null);
var values = provider.GetPropertyValues(new SettingsContext(), Properties());
_ = values["IncludeDevBuilds"].PropertyValue;
values["FeedUrl"].PropertyValue = "http://feed";
provider.SetPropertyValues(new SettingsContext(), values);
var json = File.ReadAllText(SettingsPath);
Assert.Contains("FeedUrl", json);
Assert.DoesNotContain("IncludeDevBuilds", json);
Assert.DoesNotContain("WindowLocation", json);
}
[Fact]
public void EntriesThisBuildDoesNotKnowAreKept()
{
Directory.CreateDirectory(_directory);
File.WriteAllText(SettingsPath, """{ "FromNewerBuild": "x" }""");
var provider = new AppDataSettingsProvider(SettingsPath, null);
var values = provider.GetPropertyValues(new SettingsContext(), Properties());
values["FeedUrl"].PropertyValue = "http://feed";
provider.SetPropertyValues(new SettingsContext(), values);
Assert.Contains("FromNewerBuild", File.ReadAllText(SettingsPath));
}
[Fact]
public void LegacyValuesAreCarriedOverOnceWhenTheFileIsMissing()
{
var legacyCalls = 0;
SettingsProvider Legacy()
{
legacyCalls++;
return new FixedProvider(new() { ["IncludeDevBuilds"] = "True" });
}
var values = new AppDataSettingsProvider(SettingsPath, Legacy).GetPropertyValues(new SettingsContext(), Properties());
var reloaded = new AppDataSettingsProvider(SettingsPath, Legacy).GetPropertyValues(new SettingsContext(), Properties());
Assert.Equal(true, values["IncludeDevBuilds"].PropertyValue);
Assert.Equal(true, reloaded["IncludeDevBuilds"].PropertyValue);
Assert.Equal(1, legacyCalls);
}
[Fact]
public void TheDefaultUserConfigProviderCanBeReadForCarryOver()
{
var context = new SettingsContext { ["GroupName"] = typeof(AppDataSettingsProviderTests).FullName, ["SettingsKey"] = "" };
var values = new AppDataSettingsProvider(SettingsPath, () => new LocalFileSettingsProvider())
.GetPropertyValues(context, Properties());
Assert.Equal(false, values["IncludeDevBuilds"].PropertyValue);
Assert.False(File.Exists(SettingsPath));
}
[Fact]
public void ACorruptFileReadsAsDefaults()
{
Directory.CreateDirectory(_directory);
File.WriteAllText(SettingsPath, "not json");
var values = new AppDataSettingsProvider(SettingsPath, null).GetPropertyValues(new SettingsContext(), Properties());
Assert.Equal(false, values["IncludeDevBuilds"].PropertyValue);
}
private static SettingsPropertyCollection Properties()
{
return new SettingsPropertyCollection
{
UserSetting("IncludeDevBuilds", typeof(bool), "False"),
UserSetting("FeedUrl", typeof(string), ""),
UserSetting("WindowLocation", typeof(Point), "0, 0"),
};
}
private static SettingsProperty UserSetting(string name, Type type, string defaultValue)
{
var attributes = new SettingsAttributeDictionary { [typeof(UserScopedSettingAttribute)] = new UserScopedSettingAttribute() };
return new SettingsProperty(name, type, null, false, defaultValue, SettingsSerializeAs.String, attributes, false, false);
}
/// <summary>Stands in for user.config: returns the given serialized values.</summary>
private sealed class FixedProvider : SettingsProvider
{
private readonly Dictionary<string, string> _values;
public FixedProvider(Dictionary<string, string> values) => _values = values;
public override string ApplicationName { get; set; } = "Test";
public override void Initialize(string name, NameValueCollection config) => base.Initialize("Fixed", config);
public override SettingsPropertyValueCollection GetPropertyValues(SettingsContext context, SettingsPropertyCollection collection)
{
var values = new SettingsPropertyValueCollection();
foreach (SettingsProperty property in collection)
{
var value = new SettingsPropertyValue(property);
if (_values.TryGetValue(property.Name, out var serialized))
value.SerializedValue = serialized;
values.Add(value);
}
return values;
}
public override void SetPropertyValues(SettingsContext context, SettingsPropertyValueCollection collection) { }
}
}
@@ -54,7 +54,7 @@ public class LayoutPartLabelZoomTests
using var image = new Bitmap(120, 120);
using var g = Graphics.FromImage(image);
g.Clear(Color.White);
layout.DrawLabel(g, "12", muted: false, Brushes.Yellow);
layout.DrawLabel(g, "12", Brushes.Yellow, Brushes.Black);
Assert.Equal(shown, !IsBlank(image));
layout.ReleaseDisplayResources();
@@ -160,6 +160,54 @@ public class AutoNestFormLedgerStockTests
Assert.Equal(25.4 * 48, form.GetPlateOptions().First(o => o.Id == "ledger:1").Width, 9);
});
[Fact]
public void LoadLeavesOutCodesOnOtherOpenNests() => RunSta(() =>
{
var editing = Guid.NewGuid();
using var form = new LedgerTestForm
{
ServerNestId = editing,
InUse = LedgerCodesInUse.Known([Use("B-1", "2026-0042"), Use("D-1", "2026-0043")]),
};
GetGrid(form);
Assert.True(form.LoadLedgerStockAsync().IsCompleted);
Assert.Equal(editing, form.ExcludedNestId);
var row = Assert.Single(form.LedgerRows);
Assert.Equal(["D-2"], row.ItemCodes);
Assert.Equal(1, row.Quantity);
Assert.Equal("Ledger: 0 sheet sizes, 1 drop (4 on other nests)", Label(form).Text);
});
[Fact]
public void LoadWithoutOtherNests_OffersEverythingAndSaysSo() => RunSta(() =>
{
using var form = new LedgerTestForm { InUse = LedgerCodesInUse.Unknown("the nest server could not be reached") };
GetGrid(form);
Assert.True(form.LoadLedgerStockAsync().IsCompleted);
Assert.Equal(new[] { 3, 2 }, form.LedgerRows.Select(r => r.Quantity));
Assert.Equal("Ledger: 1 sheet size, 2 drops (other nests not checked: the nest server could not be reached)",
Label(form).Text);
});
[Fact]
public void EveryRowOnOtherNests_IsNotReportedAsNoStock() => RunSta(() =>
{
using var form = new LedgerTestForm
{
InUse = LedgerCodesInUse.Known([Use("B-1", "A"), Use("D-1", "A"), Use("D-2", "A")]),
};
GetGrid(form);
Assert.True(form.LoadLedgerStockAsync().IsCompleted);
Assert.Empty(form.LedgerRows);
Assert.Equal("Ledger: 0 sheet sizes, 0 drops (5 on other nests)", Label(form).Text);
});
[Fact]
public void ClosingTheFormCancelsAPendingLoad() => RunSta(() =>
{
@@ -277,6 +325,14 @@ public class AutoNestFormLedgerStockTests
public TaskCompletionSource<LedgerStockResult>? Pending { get; set; }
public List<(string, string, double)> Requests { get; } = new();
public CancellationToken LastToken { get; private set; }
public LedgerCodesInUse InUse { get; set; } = LedgerCodesInUse.Known([]);
public Guid? ExcludedNestId { get; private set; }
internal override Task<LedgerCodesInUse> FetchCodesInUseAsync(Guid excludeNestId, CancellationToken cancellationToken)
{
ExcludedNestId = excludeNestId;
return Task.FromResult(InUse);
}
internal override bool LedgerConfigured => Configured;
@@ -289,6 +345,9 @@ public class AutoNestFormLedgerStockTests
}
}
private static OpenNest.Data.NestStockCodeUse Use(string code, string nest) =>
new() { ItemCode = code, NestId = Guid.NewGuid(), NestName = nest, Status = NestStatus.Quote };
private static Nest LedgerNest() => new() { Material = new Material("Steel", "A36"), Thickness = 0.25 };
private static List<PlateOption> Manual(double firstCost, double secondCost) => new()
@@ -187,6 +187,88 @@ public class CuttingInspectionPanelTests
Assert.Same(original, editor.PlateView.Plate);
});
[Fact]
public void ScopeSelectorReplansForAllPlatesAndBackAndRemembersTheChoice() => RunSta(() =>
{
using var editor = TwoPlateEditor();
editor.Show();
editor.PlateManager.LoadAt(0);
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
var remembered = new List<bool>();
panel.RememberScope = remembered.Add;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
var scope = Field<ComboBox>(panel, "scopeSelector");
Assert.True(scope.Visible);
Assert.Equal(0, scope.SelectedIndex);
Assert.Equal("All plates (2)", scope.Items[1]);
Assert.Single(panel.Proposal.Plates);
scope.SelectedIndex = 1;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.True(panel.AllPlates);
Assert.Equal(2, panel.Proposal.Plates.Count);
Assert.True(Field<Button>(panel, "nextButton").Visible);
Assert.Contains("2 plates", Field<Label>(panel, "statusLabel").Text);
scope.SelectedIndex = 0;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.False(panel.AllPlates);
Assert.Single(panel.Proposal.Plates);
Assert.False(Field<Button>(panel, "nextButton").Visible);
Assert.Equal(new[] { true, false }, remembered);
panel.RequestClose();
});
[Fact]
public void ScopeChangedMidPlanReplansOnceTheRunningPlanCancels() => RunSta(() =>
{
using var editor = TwoPlateEditor();
editor.Show();
editor.PlateManager.LoadAt(0);
using var entered = new ManualResetEventSlim();
using var release = new ManualResetEventSlim();
var runs = 0;
editor.CuttingBeforePlan = token =>
{
if (Interlocked.Increment(ref runs) > 1) return;
entered.Set();
release.Wait(TimeSpan.FromSeconds(30));
Assert.True(token.IsCancellationRequested);
};
try
{
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
panel.RememberScope = _ => { };
Assert.True(entered.Wait(TimeSpan.FromSeconds(30)));
Field<ComboBox>(panel, "scopeSelector").SelectedIndex = 1;
Assert.False(panel.AllPlates); // The running plan still reads the old scope.
release.Set();
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.True(panel.AllPlates);
Assert.Equal(2, panel.Proposal.Plates.Count);
panel.RequestClose();
}
finally
{
release.Set();
}
});
[Fact]
public void ScopeSelectorIsHiddenWhenOnlyOnePlateHasParts() => RunSta(() =>
{
using var editor = Editor();
editor.Show();
editor.PlateManager.LoadAt(0);
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.False(Field<ComboBox>(panel, "scopeSelector").Visible);
panel.RequestClose();
});
[Fact]
public void BlockedOverlapRowShadesTheOverlapOnTheLivePlate() => RunSta(() =>
{
@@ -950,6 +1032,16 @@ public class CuttingInspectionPanelTests
return new EditNestForm(nest);
}
private static EditNestForm TwoPlateEditor()
{
var editor = Editor();
var second = editor.Nest.Plates[^1]; // Fill the managed empty sentinel; a new one is appended.
second.Size = new OpenNest.Geometry.Size(100, 100);
second.Parts.Add(Square("left", 1));
second.Parts.Add(Square("right", 20));
return editor;
}
private static Part Square(string name, double x)
{
var program = new OpenNest.CNC.Program();
@@ -0,0 +1,195 @@
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Data.MaterialLedger;
using OpenNest.Forms;
namespace OpenNest.WinForms.Tests.Forms;
/// <summary>
/// Save and post re-check a nest's ledger item codes: taken codes are listed and the
/// programmer goes on, removes them (one undoable edit) or cancels; a ledger that cannot be
/// read never blocks.
/// </summary>
public class LedgerCodeRecheckTests
{
[Fact]
public void Proceed_KeepsTheCodesAndRecordsNothing() => UndoTestEnv.Run(env =>
{
var fake = new Fakes { Answer = LedgerCodeRecheck.Choice.Proceed };
var before = UndoTestEnv.Describe(env.Nest);
var count = env.Editor.History.UndoCount;
Assert.True(Confirm(fake, env, "Post"));
Assert.Equal(before, UndoTestEnv.Describe(env.Nest));
Assert.Equal(count, env.Editor.History.UndoCount);
Assert.Equal("Post", fake.AskedAction);
Assert.Equal(
[(1, "B-1", "Only 1 of the 2 sheets needed free in MaterialLedger"), (2, "S-1", "On nest 2026-0042")],
fake.Asked!.Select(c => (c.PlateNumber, c.ItemCode, c.Reason)));
}, CodedNest);
[Fact]
public void RemoveCodes_IsOneUndoableEditThatKeepsTheLayout() => UndoTestEnv.Run(env =>
{
var fake = new Fakes { Answer = LedgerCodeRecheck.Choice.RemoveCodes };
var parts = env.Nest.Plates.Sum(p => p.Parts.Count);
env.AssertOneUndoableEdit(LedgerCodeRecheck.RemoveLabel, () => Assert.True(Confirm(fake, env, "Save")));
Assert.Equal(parts, env.Nest.Plates.Sum(p => p.Parts.Count));
Assert.Equal(2, env.Nest.Plates[0].Quantity);
Assert.Empty(env.Nest.Plates[0].StockItemCodes);
Assert.Equal(["KEEP-1"], env.Nest.Plates[3].StockItemCodes);
Assert.Equal("Removed 2 ledger item codes from 3 sheets", fake.Status);
}, CodedNest);
[Fact]
public void Cancel_StopsTheAttemptAndChangesNothing() => UndoTestEnv.Run(env =>
{
var fake = new Fakes { Answer = LedgerCodeRecheck.Choice.Cancel };
var before = UndoTestEnv.Describe(env.Nest);
Assert.False(Confirm(fake, env, "Save"));
Assert.Equal(before, UndoTestEnv.Describe(env.Nest));
Assert.Equal("Save cancelled", fake.Status);
}, CodedNest);
[Fact]
public void UnreachableLedger_SkipsTheCheckWithANote() => UndoTestEnv.Run(env =>
{
var fake = new Fakes { Stock = new LedgerStockResult.Unreachable("No answer within 5 s.") };
Assert.True(Confirm(fake, env, "Save"));
Assert.Null(fake.Asked);
Assert.Equal("Ledger codes not re-checked: MaterialLedger unreachable (No answer within 5 s.)", fake.Status);
var failing = new Fakes { StockError = new HttpRequestException("refused") };
Assert.True(Confirm(failing, env, "Post"));
Assert.Null(failing.Asked);
Assert.Equal("Ledger codes not re-checked: refused", failing.Status);
}, CodedNest);
[Fact]
public void NoLedgerOrNoCodes_ChecksNothing() => UndoTestEnv.Run(env =>
{
var unconfigured = new Fakes { Configured = false };
Assert.True(Confirm(unconfigured, env, "Save"));
Assert.Equal(0, unconfigured.Fetches);
Assert.Null(unconfigured.Status);
foreach (var plate in env.Nest.Plates)
plate.StockItemCodes = [];
var uncoded = new Fakes();
Assert.True(Confirm(uncoded, env, "Save"));
Assert.Equal(0, uncoded.Fetches);
}, CodedNest);
[Fact]
public void OtherNestsUnknown_ChecksTheLedgerAloneAndSaysSo() => UndoTestEnv.Run(env =>
{
var fake = new Fakes
{
Stock = Free(("B-1", 2), ("S-1", 1), ("KEEP-1", 1)),
InUse = LedgerCodesInUse.Unknown("nests are saved as files"),
};
Assert.True(Confirm(fake, env, "Save"));
Assert.Null(fake.Asked);
Assert.Equal("Ledger codes checked against MaterialLedger only; other nests not checked: nests are saved as files", fake.Status);
}, CodedNest);
[Fact]
public void Dialog_GoesOnOnlyAfterTheAcknowledgement() => StaTestThread.Run(() =>
{
LedgerCodeConflict[] conflicts = [new(1, "B-1", "Not free in MaterialLedger")];
using (var form = new LedgerCodeConflictForm("Post", conflicts))
{
form.Show();
var proceed = Find<Button>(form, "proceedButton");
Assert.Equal("Post Anyway", proceed.Text);
Assert.False(proceed.Enabled);
Assert.Single(Find<ListView>(form, "conflictList").Items);
Find<CheckBox>(form, "acknowledgeBox").Checked = true;
Assert.True(proceed.Enabled);
proceed.PerformClick();
Assert.Equal(LedgerCodeRecheck.Choice.Proceed, form.Choice);
}
using (var form = new LedgerCodeConflictForm("Save", conflicts))
{
form.Show();
Find<Button>(form, "removeButton").PerformClick();
Assert.Equal(LedgerCodeRecheck.Choice.RemoveCodes, form.Choice);
}
using (var form = new LedgerCodeConflictForm("Save", conflicts))
{
form.Show();
Find<Button>(form, "cancelButton").PerformClick();
Assert.Equal(LedgerCodeRecheck.Choice.Cancel, form.Choice);
}
}, TimeSpan.FromSeconds(15), "The STA test did not complete.");
private static bool Confirm(Fakes fake, UndoTestEnv env, string action) =>
fake.Create().ConfirmAsync(env.Host, env.Editor, action).GetAwaiter().GetResult();
/// <summary>
/// The undo nest with a material: plate 1 cuts two sheets of bundle B-1, plate 2 is on S-1,
/// plate 4 on KEEP-1 (free and on no other nest).
/// </summary>
private static Nest CodedNest()
{
var nest = UndoTestEnv.BuildNest();
nest.Material = new Material("Steel", "A36");
nest.Thickness = 0.25;
nest.Plates[0].Quantity = 2;
nest.Plates[0].StockItemCodes = ["B-1", "B-1"];
nest.Plates[1].StockItemCodes = ["S-1"];
nest.Plates[3].StockItemCodes = ["KEEP-1"];
return nest;
}
private static LedgerStockResult Free(params (string Code, int Quantity)[] items) =>
new LedgerStockResult.Ok(Array.Empty<LedgerStockSpec>(), items.Select(i =>
new LedgerStockItem(i.Code, 7, LedgerItemKinds.FullSheet, 48, 96, i.Quantity,
LedgerItemShapes.Rectangular, "R1", "Rack 1", null, null, DateTime.UtcNow)).ToList());
private static T Find<T>(Form form, string name) where T : Control =>
Assert.IsType<T>(form.Controls.Find(name, true).Single());
private sealed class Fakes
{
public bool Configured { get; init; } = true;
public LedgerStockResult Stock { get; init; } = Free(("B-1", 1), ("S-1", 1), ("KEEP-1", 1));
public Exception? StockError { get; init; }
public LedgerCodesInUse InUse { get; init; } = LedgerCodesInUse.Known(
[new NestStockCodeUse { ItemCode = "S-1", NestId = Guid.NewGuid(), NestName = "2026-0042" }]);
public LedgerCodeRecheck.Choice Answer { get; init; }
public int Fetches { get; private set; }
public string? AskedAction { get; private set; }
public IReadOnlyList<LedgerCodeConflict>? Asked { get; private set; }
public string? Status { get; private set; }
public LedgerCodeRecheck Create() => new(
() => Configured,
(_, _, _, _) =>
{
Fetches++;
return StockError != null ? Task.FromException<LedgerStockResult>(StockError) : Task.FromResult(Stock);
},
(_, _) => Task.FromResult<LedgerHoldsResult>(new LedgerHoldsResult.Ok(Array.Empty<LedgerHold>())),
(_, _) => Task.FromResult(InUse),
(_, action, conflicts) =>
{
AskedAction = action;
Asked = conflicts;
return Answer;
},
text => Status = text);
}
}
@@ -5,7 +5,8 @@ using OpenNest.Forms;
namespace OpenNest.WinForms.Tests.Forms;
/// <summary>
/// Machine clamps in the desktop: the Clamps tab of Machine Configuration edits a machine's rail,
/// Machine clamps and support beds in the desktop: the Clamps and Support Bed tabs of Machine
/// Configuration edit a machine's rail and bed,
/// and Nest &gt; Clamps saves its machine defaults to the nest's machine.
/// </summary>
public class MachineClampsFormsTests
@@ -51,6 +52,47 @@ public class MachineClampsFormsTests
});
}
[Fact]
public void SupportBedTab_EditsTheMachineBed()
{
RunSta(() =>
{
var directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(directory);
try
{
var provider = new LocalJsonProvider(directory);
var machine = new MachineConfig
{
Name = "Plasma",
SupportGrid = new SupportGridSettings { PitchX = 3, SlatPitch = 4 },
};
provider.SaveMachine(machine);
using var form = new MachineConfigForm(provider);
var node = Find<TreeView>(form, "_tree").Nodes.Cast<TreeNode>().Single(n => n.Tag is MachineConfig);
typeof(MachineConfigForm)
.GetMethod("Tree_AfterSelect", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.Invoke(form, new object[] { null, new TreeViewEventArgs(node) });
var shown = (MachineConfig)node.Tag;
var tab = All<TabControl>(form).Single().TabPages.Cast<TabPage>().Single(p => p.Text == "Support Bed");
All<CheckBox>(tab).Single(c => c.Text == "Give new nests this support bed").Checked = true;
All<ComboBox>(tab).Single(c => c.Items.Contains("Slats")).SelectedIndex = 1;
Assert.True(shown.SupportGrid!.Enabled);
Assert.True(shown.SupportGrid.UseSlats);
Assert.Equal(3, shown.SupportGrid.PitchX);
var slats = Assert.IsType<OpenNest.Support.SupportSlats>(shown.SupportGrid.BedFor(Units.Inches));
Assert.Equal(4, slats.Pitch);
}
finally
{
Directory.Delete(directory, recursive: true);
}
});
}
[Fact]
public void ClampsDialog_WithoutAMachine_CannotSaveMachineDefaults()
{
@@ -59,6 +59,56 @@ public class SavedNestsFormTests
"The saved-nest dialog test did not complete.");
}
[Fact]
public void SelectingAHistoricalVersion_ShowsItsPlatesInTheDetailsAndPreview()
{
StaTestThread.Run(
() =>
{
var job = Record("Versioned job");
var current = NestFile((Quantity: 1, Parts: 1));
var earlier = NestFile((Quantity: 1, Parts: 3), (Quantity: 1, Parts: 2));
job.Revision = 3;
job.FileSize = current.Length;
var repository = new FakeRepository(job);
repository.Files[job.Id] = current;
repository.RevisionFiles[(job.Id, 2)] = earlier;
repository.Revisions[job.Id] =
[
new NestRecord { Id = job.Id, Revision = 3, Name = job.Name, FileSize = current.Length },
new NestRecord { Id = job.Id, Revision = 2, Name = job.Name, FileSize = earlier.Length },
];
using var form = new SavedNestsForm(repository);
form.Show();
var plates = Field<DataGridView>(form, "platesGrid");
var preview = Field<OpenNest.Controls.PlateView>(form, "platePreview");
var status = Field<Label>(form, "detailsStatus");
PumpUntil(() => plates.Rows.Count == 1, "the current version's plates");
var currentPlate = preview.Plate;
Field<TabControl>(form, "detailsTabs").SelectedIndex = 2;
NestHistoryForm? history = null;
PumpUntil(
() => (history = Field<NestHistoryForm>(form, "historyView")) != null
&& Field<DataGridView>(history, "revisions").Rows.Count == 2,
"the version history");
var revisions = Field<DataGridView>(history!, "revisions");
revisions.CurrentCell = revisions.Rows[1].Cells[0];
PumpUntil(() => plates.Rows.Count == 2 && status.Text.StartsWith("Version 2"), "version 2's plates");
Assert.NotSame(currentPlate, preview.Plate);
Assert.Equal(3, preview.Plate.Parts.Count);
Assert.True(preview.Visible);
revisions.CurrentCell = revisions.Rows[0].Cells[0];
PumpUntil(() => plates.Rows.Count == 1 && !status.Text.StartsWith("Version"), "the current version again");
Assert.Single(preview.Plate.Parts);
},
TestTimeout,
"The version preview test did not complete.");
}
[Fact]
public void PlatePreview_StepsThroughThePlates_AndFollowsThePlatesTable()
{
@@ -350,12 +400,18 @@ public class SavedNestsFormTests
public int UpdateAttempts { get; private set; }
public int ExpectedRevision { get; private set; }
public Dictionary<Guid, byte[]> Files { get; } = new();
public Dictionary<(Guid Id, int Revision), byte[]> RevisionFiles { get; } = new();
public Dictionary<Guid, NestRecord[]> Revisions { get; } = new();
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber, CancellationToken cancellationToken = default) =>
GetFileAsync(id, cancellationToken);
RevisionFiles.TryGetValue((id, revisionNumber), out var file)
? Task.FromResult<byte[]?>(file)
: GetFileAsync(id, cancellationToken);
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100, CancellationToken cancellationToken = default) =>
Task.FromResult(new NestRevisionPage());
Task.FromResult(Revisions.TryGetValue(id, out var items)
? new NestRevisionPage { Items = items, Total = items.Length, Offset = offset, Limit = limit }
: new NestRevisionPage());
public async Task<NestRecord> UpdateFileAsync(Guid id, byte[] bytes, NestRecord metadata, int expectedRevision,
CancellationToken cancellationToken = default)
@@ -0,0 +1,107 @@
using Velopack;
using Velopack.Logging;
using Velopack.Sources;
namespace OpenNest.WinForms.Tests;
public class ServerFirstUpdateSourceTests
{
[Fact]
public void FeedUrlWinsOverTheNestServer()
{
var source = Assert.IsType<SimpleWebSource>(
UpdaterService.CreateSource("http://feed.example:8080", includeDevBuilds: true, serverUrl: "http://nests.example:8090"));
Assert.Equal("http://feed.example:8080/", source.BaseUri.ToString());
}
[Fact]
public void WithoutANestServerUpdatesComeFromGitHub()
{
Assert.IsType<GithubSource>(UpdaterService.CreateSource(null, includeDevBuilds: false, serverUrl: null));
Assert.IsType<GithubSource>(UpdaterService.CreateSource("", includeDevBuilds: false, serverUrl: " "));
}
[Theory]
[InlineData(false, "http://nests.example:8090/updates/stable")]
[InlineData(true, "http://nests.example:8090/updates/dev")]
public void DatabaseModeReadsTheServersChannelFirst(bool includeDevBuilds, string expected)
{
Assert.IsType<ServerFirstUpdateSource>(
UpdaterService.CreateSource(null, includeDevBuilds, "http://nests.example:8090/"));
Assert.Equal(expected, ServerChannelUrl(includeDevBuilds, "http://nests.example:8090/"));
}
[Fact]
public async Task AServerFeedIsUsedAndItsPackagesDownloadFromTheServer()
{
var server = new FakeSource("0.6.1");
var github = new FakeSource("0.6.1");
var source = new ServerFirstUpdateSource(server, github);
var feed = await source.GetReleaseFeed(NullVelopackLogger.Instance, "OpenNest", "win");
await source.DownloadReleaseEntry(NullVelopackLogger.Instance, feed.Assets[0], "package", _ => { });
Assert.Same(server, source.LastFeedSource);
Assert.Equal((1, 1), (server.FeedRequests, server.Downloads));
Assert.Equal((0, 0), (github.FeedRequests, github.Downloads));
}
[Fact]
public async Task WhenTheServerHasNoFeedGitHubAnswersAndServesItsOwnPackage()
{
var server = new FakeSource(null);
var github = new FakeSource("0.6.1");
var source = new ServerFirstUpdateSource(server, github);
var feed = await source.GetReleaseFeed(NullVelopackLogger.Instance, "OpenNest", "win");
await source.DownloadReleaseEntry(NullVelopackLogger.Instance, feed.Assets[0], "package", _ => { });
Assert.Same(github, source.LastFeedSource);
Assert.Equal((1, 0), (server.FeedRequests, server.Downloads));
Assert.Equal((1, 1), (github.FeedRequests, github.Downloads));
}
private static string ServerChannelUrl(bool includeDevBuilds, string serverUrl)
{
var source = (ServerFirstUpdateSource)UpdaterService.CreateSource(null, includeDevBuilds, serverUrl);
var field = typeof(ServerFirstUpdateSource).GetField("_server", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
return ((SimpleWebSource)field.GetValue(source)!).BaseUri.ToString().TrimEnd('/');
}
/// <summary>A feed with one full package, or (version null) a source that fails like an unreachable server.</summary>
private sealed class FakeSource : IUpdateSource
{
private readonly string? _version;
public FakeSource(string? version) => _version = version;
public int FeedRequests { get; private set; }
public int Downloads { get; private set; }
public Task<VelopackAssetFeed> GetReleaseFeed(IVelopackLogger logger, string appId, string channel,
Guid? stagingId = null, VelopackAsset? latestLocalRelease = null)
{
FeedRequests++;
if (_version is null)
throw new HttpRequestException("Response status code does not indicate success: 503 (Service Unavailable).");
return Task.FromResult(new VelopackAssetFeed
{
Assets = new[]
{
new VelopackAsset { PackageId = appId, Version = SemanticVersion.Parse(_version), Type = VelopackAssetType.Full, FileName = $"OpenNest-{_version}-full.nupkg" },
},
});
}
public Task DownloadReleaseEntry(IVelopackLogger logger, VelopackAsset releaseEntry, string localFile,
Action<int> progress, CancellationToken cancelToken = default)
{
Downloads++;
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,25 @@
namespace OpenNest.WinForms.Tests;
public class UpdaterServiceTests
{
[Fact]
public async Task ManualCheckOutsideAnInstallationReportsNotInstalled()
{
// Test hosts never start through VelopackApp, so there is no locator.
using var updater = new UpdaterService(feedUrl: null, includeDevBuilds: false);
Assert.False(updater.CanUpdate);
Assert.Equal(UpdaterService.CheckResult.NotInstalled, await updater.CheckNowAsync());
Assert.False(updater.HasPendingUpdate);
}
[Fact]
public void VersionOutsideAnInstallationIsTheAssemblyVersionWithoutBuildMetadata()
{
using var updater = new UpdaterService(feedUrl: null, includeDevBuilds: false);
Assert.Equal(UpdaterService.DisplayVersion(), updater.CurrentVersion);
Assert.DoesNotContain("+", updater.CurrentVersion);
Assert.StartsWith("0.", updater.CurrentVersion);
}
}
+42
View File
@@ -52,6 +52,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server", "OpenNest
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Server.Tests", "OpenNest.Server.Tests\OpenNest.Server.Tests.csproj", "{91DCA585-CEBF-4B80-B239-591CD0C919FA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Desktop", "OpenNest.Desktop\OpenNest.Desktop.csproj", "{A20A0A3F-06FA-4B94-91E2-302D9040B380}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Desktop.Tests", "OpenNest.Desktop.Tests\OpenNest.Desktop.Tests.csproj", "{1702C640-0699-4815-AB1F-686A2563F3A0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Avalonia", "OpenNest.Avalonia\OpenNest.Avalonia.csproj", "{BE640107-4749-4C43-AC3A-0F2349422E16}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -326,6 +332,42 @@ Global
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x64.Build.0 = Release|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.ActiveCfg = Release|Any CPU
{91DCA585-CEBF-4B80-B239-591CD0C919FA}.Release|x86.Build.0 = Release|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|x64.ActiveCfg = Debug|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|x64.Build.0 = Debug|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|x86.ActiveCfg = Debug|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Debug|x86.Build.0 = Debug|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|Any CPU.Build.0 = Release|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|x64.ActiveCfg = Release|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|x64.Build.0 = Release|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|x86.ActiveCfg = Release|Any CPU
{A20A0A3F-06FA-4B94-91E2-302D9040B380}.Release|x86.Build.0 = Release|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|x64.ActiveCfg = Debug|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|x64.Build.0 = Debug|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|x86.ActiveCfg = Debug|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Debug|x86.Build.0 = Debug|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|Any CPU.Build.0 = Release|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|x64.ActiveCfg = Release|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|x64.Build.0 = Release|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|x86.ActiveCfg = Release|Any CPU
{1702C640-0699-4815-AB1F-686A2563F3A0}.Release|x86.Build.0 = Release|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|x64.ActiveCfg = Debug|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|x64.Build.0 = Debug|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|x86.ActiveCfg = Debug|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Debug|x86.Build.0 = Debug|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|Any CPU.Build.0 = Release|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|x64.ActiveCfg = Release|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|x64.Build.0 = Release|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|x86.ActiveCfg = Release|Any CPU
{BE640107-4749-4C43-AC3A-0F2349422E16}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+28 -3
View File
@@ -23,6 +23,8 @@ namespace OpenNest.Controls
private const int LabelWidth = 150;
private const int FieldIndent = 16;
private const int InputWidth = 100;
// The lead preview's least height, below the type with the most fields.
private const int PreviewHeight = 110;
private static readonly Color HintColor = SystemColors.GrayText;
@@ -40,6 +42,9 @@ namespace OpenNest.Controls
private readonly TableLayoutPanel pnlInternalLeadIn,
pnlInternalLeadOut;
private readonly LeadPreview externalPreview = new LeadPreview { Emphasis = ContourType.External };
private readonly LeadPreview internalPreview = new LeadPreview { Emphasis = ContourType.Internal };
private readonly CheckBox chkTabsEnabled;
private readonly TableLayoutPanel tabOptions;
private readonly NumericUpDown nudTabWidth;
@@ -118,6 +123,7 @@ namespace OpenNest.Controls
var externalContent = SetupTab(
tabExternal,
externalPreview,
out cboExternalLeadIn,
out pnlExternalLeadIn,
out cboExternalLeadOut,
@@ -125,6 +131,7 @@ namespace OpenNest.Controls
);
SetupTab(
tabInternal,
internalPreview,
out cboInternalLeadIn,
out pnlInternalLeadIn,
out cboInternalLeadOut,
@@ -275,6 +282,7 @@ namespace OpenNest.Controls
PopulateDropdowns();
FitLeadTabs(externalContent);
WireChangeEvents();
UpdatePreviews();
}
public override Size GetPreferredSize(Size proposedSize) => layout.GetPreferredSize(proposedSize);
@@ -333,6 +341,14 @@ namespace OpenNest.Controls
nudLeadInAngleIncrement.Enabled = p.RoundLeadInAngles;
suppressEvents = false;
UpdatePreviews();
}
private void UpdatePreviews()
{
var parameters = BuildParameters();
externalPreview.Parameters = parameters;
internalPreview.Parameters = parameters;
}
private void UpdateSupportTabOptions()
@@ -347,8 +363,10 @@ namespace OpenNest.Controls
private void OnParametersChanged()
{
if (!suppressEvents)
ParametersChanged?.Invoke(this, EventArgs.Empty);
if (suppressEvents)
return;
UpdatePreviews();
ParametersChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
@@ -374,13 +392,15 @@ namespace OpenNest.Controls
BuildLeadInParamControls(pnlExternalLeadIn, 0);
BuildLeadOutParamControls(pnlExternalLeadOut, 0);
var contentHeight = content.GetPreferredSize(Size.Empty).Height + tallestLeadIn + tallestLeadOut;
var contentHeight = content.GetPreferredSize(Size.Empty).Height + tallestLeadIn + tallestLeadOut
+ PreviewHeight;
// Tab strip, page padding and borders.
tabControl.Height = contentHeight + Font.Height + 28;
}
private TableLayoutPanel SetupTab(
TabPage tab,
LeadPreview preview,
out ComboBox leadInCombo,
out TableLayoutPanel leadInPanel,
out ComboBox leadOutCombo,
@@ -412,7 +432,12 @@ namespace OpenNest.Controls
leadOutPanel = CreateGrid(FieldIndent);
content.Controls.Add(leadOutPanel);
// The preview fills whatever the fields leave, so it grows for types with fewer.
preview.Dock = DockStyle.Fill;
preview.Margin = new Padding(0, 6, 0, 0);
tab.Controls.Add(preview);
tab.Controls.Add(content);
preview.BringToFront();
return content;
}
+191
View File
@@ -0,0 +1,191 @@
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Controls
{
/// <summary>
/// Draws a sample part with one hole and the leads <see cref="CuttingParameters"/> would cut
/// them with. The part is sized from the leads so they stay readable whatever their size;
/// the leads of <see cref="Emphasis"/> are drawn strong, the other contour's faint.
/// </summary>
public class LeadPreview : Control
{
private const float Inset = 10;
// Perimeters run CW and holes CCW, as most parts are drawn.
private static readonly RotationType OutsideTurn =
ContourCuttingStrategy.LeadTurn(ContourType.External, RotationType.CW);
private static readonly RotationType HoleTurn =
ContourCuttingStrategy.LeadTurn(ContourType.Internal, RotationType.CCW);
private static readonly Color MaterialColor = Color.FromArgb(232, 236, 240);
private static readonly Color ContourColor = Color.FromArgb(70, 70, 70);
private static readonly Color LeadColor = Color.OrangeRed;
private static readonly Color FaintLeadColor = Color.FromArgb(110, Color.OrangeRed);
private CuttingParameters parameters;
public LeadPreview()
{
SetStyle(
ControlStyles.AllPaintingInWmPaint
| ControlStyles.OptimizedDoubleBuffer
| ControlStyles.ResizeRedraw
| ControlStyles.UserPaint,
true
);
BackColor = Color.White;
}
public ContourType Emphasis { get; set; } = ContourType.External;
public CuttingParameters Parameters
{
get => parameters;
set
{
parameters = value;
Invalidate();
}
}
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
if (parameters == null)
return;
var leadIns = new[] { parameters.ExternalLeadIn, parameters.InternalLeadIn };
var leadOuts = new[] { parameters.ExternalLeadOut, parameters.InternalLeadOut };
var outsideReach = Reach(leadIns[0], leadOuts[0], OutsideTurn);
var holeReach = Reach(leadIns[1], leadOuts[1], HoleTurn);
var unit = System.Math.Max(System.Math.Max(outsideReach, holeReach), 0.1);
// The hole holds its own leads with room to spare; the part holds the hole.
var holeRadius = System.Math.Max(holeReach * 1.4, unit * 0.6);
var height = 2 * holeRadius + 2 * unit;
var width = height * 1.6;
var holeCenter = new Vector(width * 0.4, height / 2);
var outsidePoint = new Vector(width, height / 2);
var holePoint = new Vector(holeCenter.X + holeRadius, holeCenter.Y);
using var material = new GraphicsPath(FillMode.Alternate);
material.AddRectangle(new RectangleF(0, 0, (float)width, (float)height));
material.AddEllipse(
(float)(holeCenter.X - holeRadius),
(float)(holeCenter.Y - holeRadius),
(float)(2 * holeRadius),
(float)(2 * holeRadius)
);
using var outsideLeads = LeadPath(leadIns[0], leadOuts[0], outsidePoint, 0, OutsideTurn);
using var holeLeads = LeadPath(leadIns[1], leadOuts[1], holePoint, System.Math.PI, HoleTurn);
var outsidePierce = Pierce(leadIns[0], outsidePoint, 0, OutsideTurn);
var holePierce = Pierce(leadIns[1], holePoint, System.Math.PI, HoleTurn);
var bounds = material.GetBounds();
bounds = Union(bounds, outsideLeads.GetBounds(), holeLeads.GetBounds());
bounds = Union(bounds, new RectangleF(outsidePierce, SizeF.Empty), new RectangleF(holePierce, SizeF.Empty));
var view = new RectangleF(Inset, Inset, Width - 2 * Inset, Height - 2 * Inset);
if (view.Width <= 0 || view.Height <= 0 || bounds.Width <= 0 || bounds.Height <= 0)
return;
// Fit and center, flipping Y so the preview reads like the plate view.
var scale = System.Math.Min(view.Width / bounds.Width, view.Height / bounds.Height);
using var transform = new Matrix();
transform.Translate(
view.Left + (view.Width - bounds.Width * scale) / 2,
view.Top + (view.Height + bounds.Height * scale) / 2
);
transform.Scale(scale, -scale);
transform.Translate(-bounds.Left, -bounds.Top);
material.Transform(transform);
outsideLeads.Transform(transform);
holeLeads.Transform(transform);
var pierces = new[] { outsidePierce, holePierce };
transform.TransformPoints(pierces);
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
using (var fill = new SolidBrush(MaterialColor))
g.FillPath(fill, material);
using (var outline = new Pen(ContourColor, 1.5f))
g.DrawPath(outline, material);
var outsideStrong = Emphasis == ContourType.External;
DrawLeads(g, outsideLeads, pierces[0], outsideStrong);
DrawLeads(g, holeLeads, pierces[1], !outsideStrong);
}
private static void DrawLeads(Graphics g, GraphicsPath leads, PointF pierce, bool strong)
{
var color = strong ? LeadColor : FaintLeadColor;
using var pen = new Pen(color, strong ? 2f : 1.25f);
g.DrawPath(pen, leads);
var size = strong ? 6f : 4f;
using var brush = new SolidBrush(color);
g.FillEllipse(brush, pierce.X - size / 2, pierce.Y - size / 2, size, size);
}
/// <summary>How far a contour's leads reach from their contour point.</summary>
private static double Reach(LeadIn leadIn, LeadOut leadOut, RotationType turn)
{
using var path = LeadPath(leadIn, leadOut, Vector.Zero, 0, turn);
var bounds = Union(path.GetBounds(), new RectangleF(Pierce(leadIn, Vector.Zero, 0, turn), SizeF.Empty));
return System.Math.Max(
System.Math.Max(System.Math.Abs(bounds.Left), System.Math.Abs(bounds.Right)),
System.Math.Max(System.Math.Abs(bounds.Top), System.Math.Abs(bounds.Bottom))
);
}
private static GraphicsPath LeadPath(LeadIn leadIn, LeadOut leadOut, Vector point, double normal,
RotationType turn)
{
var program = new Program(Mode.Absolute);
program.Codes.Add(new RapidMove(point));
if (leadIn != null)
program.Codes.AddRange(leadIn.Generate(point, normal, turn));
// The lead-out leaves from where the cut ends: back at the lead-in's contour point.
program.Codes.Add(new RapidMove(point));
if (leadOut != null)
program.Codes.AddRange(leadOut.Generate(point, normal, turn));
program.GetGraphicsPaths(Vector.Zero, out var cutPath, out var leadPath);
cutPath.Dispose();
return leadPath;
}
private static PointF Pierce(LeadIn leadIn, Vector point, double normal, RotationType turn)
{
var pierce = leadIn?.GetPiercePoint(point, normal, turn) ?? point;
return new PointF((float)pierce.X, (float)pierce.Y);
}
private static RectangleF Union(RectangleF bounds, params RectangleF[] others)
{
foreach (var other in others)
{
// An empty lead path reports an empty rectangle at the origin; skip it.
if (other.Width <= 0 && other.Height <= 0 && other.Location == PointF.Empty)
continue;
bounds = RectangleF.FromLTRB(
System.Math.Min(bounds.Left, other.Left),
System.Math.Min(bounds.Top, other.Top),
System.Math.Max(bounds.Right, other.Right),
System.Math.Max(bounds.Bottom, other.Bottom)
);
}
return bounds;
}
}
}
+4 -2
View File
@@ -281,9 +281,11 @@ namespace OpenNest.Controls
{
DrawCutOffLines(g);
// Over the lines, on a disc of the plate's color, so the cut does not strike them through.
// Over the lines, on a disc of the line's color so the cut does not strike them through.
// A disc of the plate's color read as a notch in any part edge the cut runs along.
using var disc = new SolidBrush(CutOffLineColor);
foreach (var (part, id, muted) in cutOffLabels)
part.DrawLabel(g, id, muted, view.ColorScheme.LayoutFillBrush);
part.DrawLabel(g, id, muted ? Brushes.Gray : disc, Brushes.White);
cutOffLabels.Clear();
}
+23 -3
View File
@@ -549,6 +549,16 @@ namespace OpenNest.Forms
double thicknessIn, CancellationToken cancellationToken) =>
MaterialLedgerStore.CreateClient().GetStockAsync(material, grade, thicknessIn, cancellationToken);
/// <summary>
/// The nest server id of the nest being nested, whose own codes stay offered; empty for
/// a nest not saved to the server.
/// </summary>
public Guid ServerNestId { get; set; }
/// <summary>Reads the codes on other open server nests; the form's tests replace it.</summary>
internal virtual Task<LedgerCodesInUse> FetchCodesInUseAsync(Guid excludeNestId, CancellationToken cancellationToken) =>
LedgerCodesOnOtherNests.ReadAsync(excludeNestId, cancellationToken);
private string MaterialName => nest?.Material?.Name?.Trim() ?? "";
internal void UpdateLedgerButton()
@@ -577,11 +587,14 @@ namespace OpenNest.Forms
ledgerSummaryLabel.Text = "Loading stock from MaterialLedger...";
try
{
// Codes on other open nests are not held in ML yet, so they are left out here.
var inUseTask = FetchCodesInUseAsync(ServerNestId, token);
var result = await FetchLedgerStockAsync(MaterialName, nest.Material?.Grade?.Trim() ?? "",
LedgerStockMapper.ThicknessInches(nest.Thickness, nest.Units), token);
var inUse = await inUseTask;
if (token.IsCancellationRequested || IsDisposed)
return;
ApplyLedgerStock(result);
ApplyLedgerStock(result, inUse);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
@@ -600,17 +613,24 @@ namespace OpenNest.Forms
/// <summary>
/// Replaces the ledger rows with <paramref name="result"/>'s stock (an unmapped material
/// clears them; a failed request keeps them) and reports the outcome under the grid.
/// Codes in <paramref name="inUse"/> are left out; when it is unknown the stock loads
/// whole and the summary says other nests were not checked.
/// </summary>
internal void ApplyLedgerStock(LedgerStockResult result)
internal void ApplyLedgerStock(LedgerStockResult result, LedgerCodesInUse inUse = null)
{
switch (result)
{
case LedgerStockResult.Ok ok:
var mapping = LedgerStockMapper.Map(ok.Items, nest.Units, PartEnvelopes(), ok.Specs);
if (inUse is { IsKnown: true })
mapping = LedgerCodeFilter.Exclude(mapping, inUse.Codes);
ReplaceLedgerRows(mapping.Rows);
ledgerSummary = mapping.Rows.Count == 0 && mapping.SkippedUnknownShape + mapping.SkippedTooSmall == 0
ledgerSummary = mapping.Rows.Count == 0
&& mapping.SkippedUnknownShape + mapping.SkippedTooSmall + mapping.OnOtherNests == 0
? $"Ledger: no free stock of {DescribeMaterial()}."
: mapping.Summary;
if (inUse is { IsKnown: false })
ledgerSummary += $" (other nests not checked: {inUse.Unavailable})";
break;
case LedgerStockResult.Unmapped:
ReplaceLedgerRows(Array.Empty<LedgerStockRow>());
+1 -1
View File
@@ -53,7 +53,7 @@ namespace OpenNest.Forms.Commands
/// </summary>
public static AutoNestCommand Prompt(IWin32Window owner, EditNestForm target)
{
using var form = new AutoNestForm(target.Nest);
using var form = new AutoNestForm(target.Nest) { ServerNestId = target.Document.RemoteId };
if (target.Nest.PlateOptions.Count > 0)
form.LoadPlateOptions(target.Nest.PlateOptions, target.Nest.SalvageRate);
if (target.Nest.MinimumRemnantDimension > 0)
@@ -21,11 +21,17 @@ namespace OpenNest.Forms.Commands
private readonly Form owner;
private readonly Action<string> showStatus;
private readonly LedgerCodeRecheck ledgerRecheck;
public NestStorageController(Form owner, Action<string> showStatus)
{
this.owner = owner;
this.showStatus = showStatus;
ledgerRecheck = new LedgerCodeRecheck(text =>
{
if (!owner.IsDisposed)
showStatus(text);
});
}
public event EventHandler StateChanged;
@@ -112,6 +118,9 @@ namespace OpenNest.Forms.Commands
: $"Found saved version {reconciled.Revision}. Review the current nest before saving again.");
return; // Never combine reconciliation with a write in the same click.
}
// Item codes another nest took since Auto Nest are asked about on every save.
if (!await ledgerRecheck.ConfirmAsync(owner, form, saveCopy ? "Save As" : "Save", asCopy: saveCopy))
return;
if (copyName != null)
form.Nest.Name = copyName;
else if (!saveCopy && form.Document.IsFirstDatabaseSave(serverUrl)
+111 -13
View File
@@ -25,8 +25,8 @@ internal sealed class CuttingInspectionPanel : UserControl
private readonly PlateView view;
private readonly Plate plate;
private CuttingParameters parameters;
private readonly Plate[] plates;
private readonly int[] plateNumbers;
private Plate[] plates;
private int[] plateNumbers;
private int selectedPlate;
private Plate preview;
private bool populatingWarnings;
@@ -36,6 +36,7 @@ internal sealed class CuttingInspectionPanel : UserControl
private readonly OverlapDisplayMode overlapDisplay;
private CuttingProposalInspection inspection = new();
private readonly ComboBox modeSelector = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
private readonly ComboBox scopeSelector = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
private CancellationTokenSource validationCancellation;
private bool closeRequested;
private CuttingInspectionMode? pendingMode;
@@ -43,6 +44,8 @@ internal sealed class CuttingInspectionPanel : UserControl
private bool pendingReplan;
private bool pendingClear;
private bool pendingCutOffSettings;
// A scope chosen while a plan ran; applied once that run's cancellation lands.
private bool? pendingScope;
// The running plan's headline, re-shown each second with its elapsed time; null when not ticking.
private string planningStatus;
private readonly System.Diagnostics.Stopwatch planningClock = new();
@@ -94,7 +97,7 @@ internal sealed class CuttingInspectionPanel : UserControl
};
private readonly Button cutOffSettingsButton = new() { Text = "Cut-off Settings…", AutoSize = true };
private readonly Units units;
private readonly bool allPlates;
private bool allPlates;
private AutomaticCutOffOptions cutOffOptions;
// Last cut-off settings chosen here, per unit system, for the rest of this session.
private static readonly System.Collections.Generic.Dictionary<Units, AutomaticCutOffOptions> lastCutOffOptions = new();
@@ -155,12 +158,7 @@ internal sealed class CuttingInspectionPanel : UserControl
this.editor = editor;
view = editor.PlateView;
plate = view.Plate;
var all = editor.Nest.Plates.ToList();
plates = allPlates ? all.Where(p => p.Parts.Count > 0).ToArray() : [plate];
if (plates.Length == 0)
plates = [plate];
plateNumbers = plates.Select(p => all.IndexOf(p) + 1).ToArray();
selectedPlate = System.Math.Max(0, Array.IndexOf(plates, plate));
SetPlates(allPlates);
this.parameters = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
context = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
viewport = view.CaptureViewport();
@@ -182,7 +180,11 @@ internal sealed class CuttingInspectionPanel : UserControl
{
if (!configuringMode) SwitchMode((CuttingInspectionMode)modeSelector.SelectedIndex);
};
this.allPlates = allPlates;
toolTip.SetToolTip(scopeSelector, "Plan the plate on screen, or every plate with parts in one plan.");
scopeSelector.SelectedIndexChanged += (_, _) =>
{
if (!configuringMode) ChangeScope(scopeSelector.SelectedIndex == 1);
};
units = editor.Nest.Units;
cutOffOptions = lastCutOffOptions.TryGetValue(units, out var last) ? Copy(last) : DefaultCutOffOptions(units);
ShowCutOffOptions();
@@ -197,7 +199,7 @@ internal sealed class CuttingInspectionPanel : UserControl
existingWorkLabel.Font = new Font(Font, FontStyle.Bold);
existingWorkBanner.Controls.AddRange([existingWorkLabel, clearExistingButton]);
existingWorkBanner.SetTone(Tone.Bad);
header.Controls.AddRange([modeSelector, existingWorkBanner, settingsRow, keepOrderCheckBox, cutOffRow,
header.Controls.AddRange([modeSelector, scopeSelector, existingWorkBanner, settingsRow, keepOrderCheckBox, cutOffRow,
validationScopeLabel, buttons, acceptWarningsCheckBox, navigation, previewLabel, detailLabel]);
statusBanner.Controls.Add(statusLabel);
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 };
@@ -415,10 +417,11 @@ internal sealed class CuttingInspectionPanel : UserControl
private void ConfigureMode()
{
var planning = Mode == CuttingInspectionMode.Plan;
configuringMode = true;
modeSelector.SelectedIndex = (int)Mode;
ShowScope(planning);
configuringMode = false;
var planning = Mode == CuttingInspectionMode.Plan;
settingsButton.Visible = settingsLabel.Visible = keepOrderCheckBox.Visible = applyButton.Visible = planning;
autoCutOffsCheckBox.Visible = cutOffSettingsButton.Visible = planning;
RefreshExistingWork();
@@ -429,6 +432,89 @@ internal sealed class CuttingInspectionPanel : UserControl
previewLabel.Text = "No current preview.";
}
/// <summary>True when this panel plans every plate with parts rather than only the plate on screen.</summary>
internal bool AllPlates => allPlates;
/// <summary>Saves the scope chosen in the panel for the next toolbar open; tests replace it.</summary>
internal Action<bool> RememberScope { get; set; } = all =>
{
Properties.Settings.Default.CuttingPlanAllPlates = all;
Properties.Settings.Default.Save();
};
/// <summary>Scopes the panel to the plate on screen or to every plate with parts (that plate when none has).</summary>
private void SetPlates(bool all)
{
allPlates = all;
var nestPlates = editor.Nest.Plates.ToList();
var current = nestPlates.Contains(view.Plate) ? view.Plate : plate;
plates = ScopePlates(nestPlates, current, all);
plateNumbers = plates.Select(p => nestPlates.IndexOf(p) + 1).ToArray();
selectedPlate = System.Math.Max(0, Array.IndexOf(plates, current));
}
private static Plate[] ScopePlates(System.Collections.Generic.List<Plate> nestPlates, Plate current, bool all)
{
var scoped = all ? nestPlates.Where(p => p.Parts.Count > 0).ToArray() : [current];
return scoped.Length == 0 ? [current] : scoped;
}
// Shown only when the two scopes would plan different plates.
private void ShowScope(bool planning)
{
var nestPlates = editor.Nest.Plates.ToList();
var current = nestPlates.Contains(view.Plate) ? view.Plate : plate;
var everyPlate = ScopePlates(nestPlates, current, true);
scopeSelector.Items.Clear();
scopeSelector.Items.AddRange(["Current plate only", $"All plates ({everyPlate.Length})"]);
scopeSelector.SelectedIndex = allPlates ? 1 : 0;
scopeSelector.Visible = planning && (allPlates || everyPlate.Length > 1 || !ReferenceEquals(everyPlate[0], current));
}
/// <summary>Replans for the chosen scope; a running plan is cancelled first so the plates never change under it.</summary>
internal void ChangeScope(bool all)
{
if (closed || closeRequested || pendingMode != null || Mode != CuttingInspectionMode.Plan
|| all == (pendingScope ?? allPlates))
return;
RememberScope?.Invoke(all);
if (IsPlanning)
{
pendingScope = all;
if (inspection.CloseRequested)
return; // Already finishing; the scope is applied when that lands.
pendingReplan = true;
ClearResult();
SetPlanning(true);
SetStatus(Tone.Neutral, "Restarting…");
inspection.RequestClose();
return;
}
if (ApplyScope(all))
StartPlanning();
}
// Takes the unapplied cut-offs off the old scope's plates before rescoping; false when they couldn't come off.
private bool ApplyScope(bool all)
{
ClearResult();
try
{
RevertStagedCutOffs();
}
catch (Exception ex)
{
SetStatus(Tone.Bad, "Couldn't remove the unapplied cut-offs.", ex.Message);
configuringMode = true;
ShowScope(true); // The plates stay in the old scope, so the selector does too.
configuringMode = false;
return false;
}
SetPlates(all);
ConfigureMode();
return true;
}
internal void SwitchMode(CuttingInspectionMode mode)
{
if (closed || closeRequested || mode == Mode && pendingMode == null) return;
@@ -704,6 +790,7 @@ internal sealed class CuttingInspectionPanel : UserControl
if (closeRequested || pendingMode != null)
{
pendingReplan = pendingClear = pendingCutOffSettings = false;
pendingScope = null;
FinishTransition();
return;
}
@@ -717,6 +804,17 @@ internal sealed class CuttingInspectionPanel : UserControl
pendingClear = false; // The cancelled run no longer reads the plates.
if (!ClearAppliedWork())
{
pendingScope = null;
SetPlanning(false);
return;
}
}
if (pendingScope is { } scoped)
{
pendingScope = null; // The clear above, if any, covered the scope it was confirmed for.
if (!ApplyScope(scoped))
{
pendingCutOffSettings = false;
SetPlanning(false);
return;
}
@@ -875,7 +973,7 @@ internal sealed class CuttingInspectionPanel : UserControl
// unless closing or switching; validation still locks them while it runs.
var replannable = !running || Mode == CuttingInspectionMode.Plan && !closeRequested && pendingMode == null;
settingsButton.Enabled = keepOrderCheckBox.Enabled = planButton.Enabled = replannable;
cutOffSettingsButton.Enabled = clearExistingButton.Enabled = replannable;
cutOffSettingsButton.Enabled = clearExistingButton.Enabled = scopeSelector.Enabled = replannable;
previousButton.Enabled = !running && selectedPlate > 0;
nextButton.Enabled = !running && selectedPlate < plates.Length - 1;
acceptWarningsCheckBox.Enabled = !running;
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Data.MaterialLedger;
namespace OpenNest.Forms;
/// <summary>
/// Lists the nest's item codes that are no longer free for it and asks what to do on this
/// save or post: go on after acknowledging, remove the codes, or cancel. Like pre-post
/// verification, going on needs the acknowledgement each time; nothing remembers it.
/// </summary>
public sealed class LedgerCodeConflictForm : Form
{
private readonly CheckBox acknowledgeBox;
private readonly Button proceedButton;
private readonly Button removeButton;
public LedgerCodeConflictForm(string action, IReadOnlyList<LedgerCodeConflict> conflicts)
{
ArgumentNullException.ThrowIfNull(conflicts);
Text = "Ledger Stock Changed";
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(560, 340);
MinimumSize = new Size(440, 300);
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
FormBorderStyle = FormBorderStyle.Sizable;
var codes = conflicts.Select(c => c.ItemCode).Distinct(StringComparer.Ordinal).Count();
var headline = new Label
{
Text = (codes == 1 ? "1 item code on this nest is" : $"{codes} item codes on this nest are")
+ " no longer free for it. Another job may cut the same sheet.\n"
+ "Removing the codes keeps the layout; MaterialLedger then matches those sheets by size.",
Dock = DockStyle.Top,
AutoSize = true,
MaximumSize = new Size(536, 0),
Padding = new Padding(0, 0, 0, 8),
};
var list = new ListView
{
Name = "conflictList",
Dock = DockStyle.Fill,
View = View.Details,
FullRowSelect = true,
HeaderStyle = ColumnHeaderStyle.Nonclickable,
};
list.Columns.Add("Plate", 50);
list.Columns.Add("Item code", 120);
list.Columns.Add("Why", 340);
foreach (var conflict in conflicts)
list.Items.Add(new ListViewItem(new[] { conflict.PlateNumber.ToString(), conflict.ItemCode, conflict.Reason }));
acknowledgeBox = new CheckBox
{
Name = "acknowledgeBox",
Text = $"{action} with these codes although the sheets may be taken",
Dock = DockStyle.Bottom,
AutoSize = true,
Padding = new Padding(0, 6, 0, 0),
};
acknowledgeBox.CheckedChanged += (_, _) => proceedButton.Enabled = acknowledgeBox.Checked;
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(0, 6, 0, 0),
};
var cancel = new Button { Name = "cancelButton", Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
proceedButton = new Button { Name = "proceedButton", Text = $"{action} Anyway", AutoSize = true, Enabled = false };
removeButton = new Button { Name = "removeButton", Text = $"Remove Codes and {action}", AutoSize = true };
proceedButton.Click += (_, _) => Finish(acknowledgeBox.Checked ? LedgerCodeRecheck.Choice.Proceed : LedgerCodeRecheck.Choice.Cancel);
removeButton.Click += (_, _) => Finish(LedgerCodeRecheck.Choice.RemoveCodes);
buttons.Controls.Add(cancel);
buttons.Controls.Add(proceedButton);
buttons.Controls.Add(removeButton);
var body = new Panel { Dock = DockStyle.Fill, Padding = new Padding(12) };
body.Controls.Add(list);
body.Controls.Add(headline);
body.Controls.Add(acknowledgeBox);
body.Controls.Add(buttons);
Controls.Add(body);
AcceptButton = removeButton;
CancelButton = cancel;
}
/// <summary>What the programmer chose; Cancel until a button is clicked.</summary>
internal LedgerCodeRecheck.Choice Choice { get; private set; } = LedgerCodeRecheck.Choice.Cancel;
private void Finish(LedgerCodeRecheck.Choice choice)
{
// Check again here; an enabled button alone is not consent enforcement.
if (choice == LedgerCodeRecheck.Choice.Cancel)
return;
Choice = choice;
DialogResult = DialogResult.OK;
Close();
}
}
+184
View File
@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data.MaterialLedger;
namespace OpenNest.Forms
{
/// <summary>
/// Before a nest with ledger item codes is saved to the nest server or posted, rereads the
/// ledger's free stock and the codes on other open nests and asks about any code that is no
/// longer this nest's to use. The answer counts for this attempt only; nothing is persisted.
/// A ledger that is not set up or cannot be read skips the check: it never blocks a save.
/// </summary>
internal sealed class LedgerCodeRecheck
{
public enum Choice
{
Cancel,
Proceed,
RemoveCodes,
}
public const string RemoveLabel = "Remove Ledger Codes";
private readonly Func<bool> ledgerConfigured;
private readonly Func<string, string, double, CancellationToken, Task<LedgerStockResult>> fetchStock;
private readonly Func<Guid, CancellationToken, Task<LedgerHoldsResult>> fetchHolds;
private readonly Func<Guid, CancellationToken, Task<LedgerCodesInUse>> fetchCodesInUse;
private readonly Func<IWin32Window, string, IReadOnlyList<LedgerCodeConflict>, Choice> ask;
private readonly Action<string> showStatus;
public LedgerCodeRecheck(Action<string> showStatus)
: this(() => MaterialLedgerStore.Settings.IsConfigured,
(material, grade, thickness, token) => MaterialLedgerStore.CreateClient().GetStockAsync(material, grade, thickness, token),
(id, token) => MaterialLedgerStore.CreateClient().GetHoldsAsync(id, token),
LedgerCodesOnOtherNests.ReadAsync,
Ask,
showStatus)
{
}
internal LedgerCodeRecheck(Func<bool> ledgerConfigured,
Func<string, string, double, CancellationToken, Task<LedgerStockResult>> fetchStock,
Func<Guid, CancellationToken, Task<LedgerHoldsResult>> fetchHolds,
Func<Guid, CancellationToken, Task<LedgerCodesInUse>> fetchCodesInUse,
Func<IWin32Window, string, IReadOnlyList<LedgerCodeConflict>, Choice> ask,
Action<string> showStatus)
{
this.ledgerConfigured = ledgerConfigured;
this.fetchStock = fetchStock;
this.fetchHolds = fetchHolds;
this.fetchCodesInUse = fetchCodesInUse;
this.ask = ask;
this.showStatus = showStatus ?? (_ => { });
}
/// <summary>
/// True when <paramref name="action"/> ("Save" or "Post") may go on: no codes, nothing
/// to check, nothing taken, or the programmer chose to go on or to remove the codes.
/// False when they cancelled or the window closed meanwhile. A Save As
/// (<paramref name="asCopy"/>) is a new nest: the original's codes and holds count as
/// another nest's.
/// </summary>
public async Task<bool> ConfirmAsync(IWin32Window owner, EditNestForm form, string action, bool asCopy = false)
{
var nest = form.Nest;
if (!LedgerCodeCheck.HasCodes(nest.Plates) || !ledgerConfigured())
return true;
var material = nest.Material?.Name?.Trim() ?? "";
if (material.Length == 0 || !(nest.Thickness > 0))
return true;
var remoteId = asCopy ? Guid.Empty : form.Document.RemoteId;
LedgerStockResult stock;
LedgerHoldsResult holds;
LedgerCodesInUse inUse;
try
{
var stockTask = fetchStock(material, nest.Material.Grade?.Trim() ?? "",
LedgerStockMapper.ThicknessInches(nest.Thickness, nest.Units), CancellationToken.None);
var holdsTask = remoteId == Guid.Empty
? Task.FromResult<LedgerHoldsResult>(new LedgerHoldsResult.Ok(Array.Empty<LedgerHold>()))
: fetchHolds(remoteId, CancellationToken.None);
var inUseTask = fetchCodesInUse(remoteId, CancellationToken.None);
await Task.WhenAll(stockTask, holdsTask, inUseTask);
(stock, holds, inUse) = (stockTask.Result, holdsTask.Result, inUseTask.Result);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
showStatus($"Ledger codes not re-checked: {ex.Message}");
return !form.IsDisposed;
}
if (form.IsDisposed)
return false;
// The nest's own holds tell its sheets from ones taken by other work; without them
// every held sheet would look taken.
if (stock is not LedgerStockResult.Ok ok || holds is not LedgerHoldsResult.Ok held)
{
if ((Unavailable(stock) ?? Unavailable(holds)) is { } reason)
showStatus($"Ledger codes not re-checked: {reason}");
return true;
}
var conflicts = LedgerCodeCheck.Find(nest.Plates, ok.Items, held.Holds, inUse);
if (conflicts.Count == 0)
{
if (!inUse.IsKnown)
showStatus($"Ledger codes checked against MaterialLedger only; other nests not checked: {inUse.Unavailable}");
return true;
}
switch (ask(owner, action, conflicts))
{
case Choice.Proceed:
return !form.IsDisposed;
case Choice.RemoveCodes when !form.IsDisposed:
var codes = conflicts.Select(c => c.ItemCode).Distinct(StringComparer.Ordinal).ToList();
var removed = 0;
form.RecordChange(RemoveLabel, () => removed = LedgerCodeCheck.Remove(form.Nest.Plates, codes));
form.PlateView.Invalidate();
showStatus($"Removed {codes.Count} ledger item code{(codes.Count == 1 ? "" : "s")} from {removed} sheet{(removed == 1 ? "" : "s")}");
return true;
default:
showStatus($"{action} cancelled");
return false;
}
}
private static string Unavailable(LedgerStockResult result) => result switch
{
LedgerStockResult.Ok => null,
LedgerStockResult.NotConfigured => null,
LedgerStockResult.Unmapped => "MaterialLedger has no mapping for this material",
LedgerStockResult.Unauthorized => "MaterialLedger rejected the API key",
LedgerStockResult.Unreachable unreachable => $"MaterialLedger unreachable ({unreachable.Reason})",
_ => "MaterialLedger did not answer",
};
private static string Unavailable(LedgerHoldsResult result) => result switch
{
LedgerHoldsResult.Ok => null,
LedgerHoldsResult.NotConfigured => null,
LedgerHoldsResult.Unauthorized => "MaterialLedger rejected the API key",
LedgerHoldsResult.Unreachable unreachable => $"MaterialLedger unreachable ({unreachable.Reason})",
_ => "MaterialLedger did not answer",
};
private static Choice Ask(IWin32Window owner, string action, IReadOnlyList<LedgerCodeConflict> conflicts)
{
// A fresh dialog on every attempt: consent never outlives this call.
using var dialog = new LedgerCodeConflictForm(action, conflicts);
dialog.ShowDialog(owner);
return dialog.Choice;
}
}
/// <summary>Reads the ledger codes on other open server nests, or why they could not be read.</summary>
internal static class LedgerCodesOnOtherNests
{
/// <summary>
/// The codes on every open server nest except <paramref name="excludeNestId"/>. File mode
/// and an unusable server URL come back unknown, never as an exception.
/// </summary>
public static Task<LedgerCodesInUse> ReadAsync(Guid excludeNestId, CancellationToken cancellationToken)
{
if (!NestStorage.Settings.IsDatabaseMode)
return LedgerCodesInUse.ReadAsync(null, excludeNestId, cancellationToken);
Data.INestRepository repository;
try
{
repository = NestStorage.CreateRepository();
}
catch (ArgumentException)
{
return Task.FromResult(LedgerCodesInUse.Unknown("the nest server address is not valid"));
}
return LedgerCodesInUse.ReadAsync(repository, excludeNestId, cancellationToken);
}
}
}
+114 -5
View File
@@ -8,6 +8,7 @@ using System.Text.Json.Serialization;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Shapes;
using OpenNest.Support;
namespace OpenNest.Forms
{
@@ -275,6 +276,7 @@ namespace OpenNest.Forms
var tabs = new TabControl { Dock = DockStyle.Fill };
tabs.TabPages.Add(CreateTab("General", general));
tabs.TabPages.Add(CreateTab("Clamps", BuildClampsTab(machine)));
tabs.TabPages.Add(CreateTab("Support Bed", BuildSupportBedTab(machine)));
var host = new Panel { Dock = DockStyle.Fill, Height = 420 };
host.Controls.Add(tabs);
@@ -402,6 +404,111 @@ namespace OpenNest.Forms
return panel;
}
// The machine's support bed, which new nests assigned the machine receive. A machine without
// one shows the legacy shared support-grid.json; the first edit stores the shown values.
private Control BuildSupportBedTab(MachineConfig machine)
{
var shown = MachineSettings.LoadSupportGridSettings(machine)
.ConvertedTo(MachineClamps.UnitsOf(machine.Units));
string Suffix(object _) => shown.Units == Units.Millimeters ? "mm" : "in";
void Set(Action<SupportGridSettings> apply)
{
machine.SupportGrid ??= shown;
apply(machine.SupportGrid);
MarkDirty();
}
var layout = CreateDetailLayout();
var row = 0;
var enabledBox = new CheckBox
{
Text = "Give new nests this support bed",
AutoSize = true,
Checked = shown.Enabled,
};
enabledBox.CheckedChanged += (s, e) => Set(g => g.Enabled = enabledBox.Checked);
AddField(layout, ref row, "New nests:", enabledBox);
var kindCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = InputWidth };
kindCombo.Items.AddRange(new object[] { "Grid of points", "Slats" });
kindCombo.SelectedIndex = shown.UseSlats ? 1 : 0;
AddField(layout, ref row, "Bed:", kindCombo);
var gridFields = new List<Control>
{
AddSupportField(layout, ref row, "Pitch along X:",
CreateLabeledNumeric(shown.PitchX, v => Set(g => g.PitchX = v), 4, Suffix, 100000, 0.125m, 0.0001m)),
AddSupportField(layout, ref row, "Pitch along Y:",
CreateLabeledNumeric(shown.PitchY, v => Set(g => g.PitchY = v), 4, Suffix, 100000, 0.125m, 0.0001m)),
AddSupportField(layout, ref row, "First point, X from the origin:",
CreateLabeledNumeric(shown.OffsetX, v => Set(g => g.OffsetX = v), 4, Suffix, 100000, 0.125m, -100000m)),
AddSupportField(layout, ref row, "First point, Y from the origin:",
CreateLabeledNumeric(shown.OffsetY, v => Set(g => g.OffsetY = v), 4, Suffix, 100000, 0.125m, -100000m)),
};
var axisCombo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = InputWidth };
axisCombo.Items.AddRange(new object[] { "Along X (the full length)", "Along Y (the full width)" });
axisCombo.SelectedIndex = shown.SlatAxis == SlatAxis.X ? 0 : 1;
axisCombo.SelectedIndexChanged += (s, e) =>
Set(g => g.SlatAxis = axisCombo.SelectedIndex == 0 ? SlatAxis.X : SlatAxis.Y);
var slatFields = new List<Control>
{
AddSupportField(layout, ref row, "Slats run:", axisCombo),
AddSupportField(layout, ref row, "Pitch between slats:",
CreateLabeledNumeric(shown.SlatPitch, v => Set(g => g.SlatPitch = v), 4, Suffix, 100000, 0.125m, 0.0001m)),
AddSupportField(layout, ref row, "Slat thickness:",
CreateLabeledNumeric(shown.SlatThickness, v => Set(g => g.SlatThickness = v), 4, Suffix, 100000, 0.125m)),
AddSupportField(layout, ref row, "First slat's center from the origin:",
CreateLabeledNumeric(shown.SlatOffset, v => Set(g => g.SlatOffset = v), 4, Suffix, 100000, 0.125m, -100000m)),
};
AddField(layout, ref row, "Registration tolerance:",
CreateLabeledNumeric(shown.RegistrationTolerance, v => Set(g => g.RegistrationTolerance = v), 4, Suffix, 100000, 0.125m));
AddField(layout, ref row, "Stability margin:",
CreateLabeledNumeric(shown.StabilityMargin, v => Set(g => g.StabilityMargin = v), 4, Suffix, 100000, 0.125m));
void UpdateEnabled()
{
foreach (var field in gridFields)
field.Enabled = kindCombo.SelectedIndex == 0;
foreach (var field in slatFields)
field.Enabled = kindCombo.SelectedIndex == 1;
}
kindCombo.SelectedIndexChanged += (s, e) =>
{
Set(g => g.UseSlats = kindCombo.SelectedIndex == 1);
UpdateEnabled();
};
UpdateEnabled();
var note = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
MaximumSize = new Size(560, 0),
ForeColor = SystemColors.GrayText,
Padding = new Padding(4, 0, 0, 8),
Text = "What cut parts rest on: a grid of contact points, one every pitch along X and Y, or "
+ "slats running the full length or width of the table, one every pitch. New nests "
+ "assigned this machine get this bed. Changing it here never changes the bed of "
+ "existing nests; use Load Machine Defaults in Nest > Support Bed for that.",
};
var panel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10), AutoScroll = true };
panel.Controls.Add(layout);
panel.Controls.Add(note);
return panel;
}
// Adds the row and returns its input, so the caller can enable it with the bed layout.
private static Control AddSupportField(TableLayoutPanel layout, ref int row, string label, Control control)
{
AddField(layout, ref row, label, control);
return control;
}
private GroupBox BuildMotionGroup(MachineConfig machine)
{
var group = CreateGroup("Motion (cut time estimates)");
@@ -893,16 +1000,17 @@ namespace OpenNest.Forms
Action<double> setter,
int decimals,
decimal max,
decimal increment
decimal increment,
decimal min = 0
)
{
var numeric = new NumericUpDown
{
DecimalPlaces = decimals,
Minimum = 0,
Minimum = min,
Maximum = max,
Increment = increment,
Value = System.Math.Clamp((decimal)value, 0, max),
Value = System.Math.Clamp((decimal)value, min, max),
Width = NumericWidth,
};
numeric.ValueChanged += (s, e) => setter((double)numeric.Value);
@@ -915,7 +1023,8 @@ namespace OpenNest.Forms
int decimals,
Func<object, string> suffix,
decimal max,
decimal increment
decimal increment,
decimal min = 0
)
{
var row = new TableLayoutPanel
@@ -927,7 +1036,7 @@ namespace OpenNest.Forms
row.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
row.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
var numeric = CreateNumericBox(value, setter, decimals, max, increment);
var numeric = CreateNumericBox(value, setter, decimals, max, increment, min);
var suffixLabel = new Label
{
+3
View File
@@ -21,7 +21,10 @@ namespace OpenNest.Forms
components.Dispose();
}
if (disposing)
{
updaterStatus?.Dispose();
statusStripToolTips?.Dispose();
}
base.Dispose(disposing);
}
+21 -2
View File
@@ -306,15 +306,34 @@ namespace OpenNest.Forms
editForm.OpenValidationInspection();
}
private void PostProcessor_Click(object sender, EventArgs e)
private bool checkingPostLedgerCodes;
private async void PostProcessor_Click(object sender, EventArgs e)
{
var menuItem = (ToolStripMenuItem)sender;
var postProcessor = menuItem.Tag as IPostProcessor;
var editForm = activeForm;
if (postProcessor == null || editForm == null)
if (postProcessor == null || editForm == null || checkingPostLedgerCodes)
return;
// Item codes another nest took since Auto Nest are asked about on every post.
checkingPostLedgerCodes = true;
try
{
var recheck = new LedgerCodeRecheck(text =>
{
if (!IsDisposed)
statusLabel1.Text = text;
});
if (!await recheck.ConfirmAsync(this, editForm, "Post") || IsDisposed || editForm.IsDisposed)
return;
}
finally
{
checkingPostLedgerCodes = false;
}
// Closed progress windows can still have a fill awaiting its final commit.
// Keep the nest stable throughout verification and all owned modal dialogs.
if (MdiChildren.OfType<EditNestForm>().Any(form => form.CuttingInspection != null))
+4
View File
@@ -111,6 +111,10 @@ namespace OpenNest.Forms
private void NestPlanCutting_Click(object sender, EventArgs e) => ShowCuttingPlan(allPlates: true);
// The toolbar opens with the scope last chosen in the panel; the menus keep their own.
private void ToolbarPlanCutting_Click(object sender, EventArgs e) =>
ShowCuttingPlan(Properties.Settings.Default.CuttingPlanAllPlates);
// Native tests capture this notice without opening a modal dialog in session zero.
internal System.Action<string, string> CuttingPlanNotice { get; set; }
+12 -3
View File
@@ -109,13 +109,14 @@ namespace OpenNest.Forms
storage = new NestStorageController(this, text => statusLabel1.Text = text);
storage.StateChanged += Storage_StateChanged;
InitializeComponent();
Icon = Resources.app_icon;
statusStrip1.Items.Insert(statusStrip1.Items.IndexOf(materialStatusLabel) + 1, ledgerStatusLabel);
ledgerStatusLabel.Click += LedgerStatusLabel_Click;
statusStrip1.ShowItemToolTips = true; // The hold list lives in the ledger label's tooltip.
statusStripToolTips = new StatusStripToolTips(statusStrip1); // The hold list lives in the ledger label's tooltip.
statusBar = new MainStatusBar(statusLabel1, locationStatusLabel, selectionStatusLabel,
machineStatusLabel, materialStatusLabel, plateUtilStatusLabel, ledgerStatusLabel);
toolStrip1.Items.Add(CreateActionButton("btnCutPlanner", "Plan Cutting",
Resources.cut_planner, PlatePlanCutting_Click));
Resources.cut_planner, ToolbarPlanCutting_Click));
toolStrip1.Items.Add(CreateActionButton("btnSimulateCutting", "Simulate Cutting",
Resources.cut_simulate, SimulateCutting_Click));
toolStrip1.Items.Add(CreateActionButton("btnSequenceByPath", "Sequence by Path",
@@ -167,6 +168,7 @@ namespace OpenNest.Forms
}
private UpdaterStatus updaterStatus;
private StatusStripToolTips statusStripToolTips;
private void InitializeHelpMenu()
{
@@ -174,8 +176,15 @@ namespace OpenNest.Forms
contents.Click += (_, _) => HelpTopics.Show(this, HelpTopics.Home);
var firstNest = new ToolStripMenuItem("&Your First Nest");
firstNest.Click += (_, _) => HelpTopics.Show(this, HelpTopics.FirstNest);
var checkForUpdates = new ToolStripMenuItem("Check for &Updates...");
checkForUpdates.Click += (_, _) => updaterStatus?.CheckForUpdates();
var about = new ToolStripMenuItem("&About OpenNest");
about.Click += (_, _) => updaterStatus?.ShowAbout();
var help = new ToolStripMenuItem("&Help");
help.DropDownItems.AddRange(new ToolStripItem[] { contents, firstNest });
help.DropDownItems.AddRange(new ToolStripItem[]
{
contents, firstNest, new ToolStripSeparator(), checkForUpdates, about,
});
menuStrip1.Items.Add(help);
}
+26 -23
View File
@@ -24,6 +24,9 @@ namespace OpenNest.Forms
// Mouse-follow mode refreshes at most this often; each text change relayouts the strip.
private const int LocationRefreshIntervalMs = 100;
// Once the mouse rests this long, the label shows its final point.
private const int LocationSettleMs = 30;
private readonly ToolStripStatusLabel status;
private readonly ToolStripStatusLabel location;
private readonly ToolStripStatusLabel selection;
@@ -31,11 +34,11 @@ namespace OpenNest.Forms
private readonly ToolStripStatusLabel material;
private readonly ToolStripStatusLabel plateUtil;
private readonly ToolStripStatusLabel ledger;
private readonly Timer locationTimer;
private readonly Timer locationSettleTimer;
private EditNestForm form;
private bool clickUpdatesLocation = !Settings.Default.StatusBarLocationFollowsMouse;
private bool locationPending;
private long lastLocationUpdate;
public MainStatusBar(ToolStripStatusLabel status, ToolStripStatusLabel location,
ToolStripStatusLabel selection, ToolStripStatusLabel machine, ToolStripStatusLabel material,
@@ -49,8 +52,8 @@ namespace OpenNest.Forms
this.material = material;
this.plateUtil = plateUtil;
locationTimer = new Timer { Interval = LocationRefreshIntervalMs };
locationTimer.Tick += LocationTimer_Tick;
locationSettleTimer = new Timer { Interval = LocationSettleMs };
locationSettleTimer.Tick += LocationSettleTimer_Tick;
}
/// <summary>
@@ -160,11 +163,13 @@ namespace OpenNest.Forms
return;
}
location.Text = string.Format(
var text = string.Format(
"Location: [{0}, {1}]",
form.PlateView.CurrentPoint.X.ToString("n4"),
form.PlateView.CurrentPoint.Y.ToString("n4")
);
if (location.Text != text)
location.Text = text;
}
public void UpdatePlate()
@@ -269,39 +274,37 @@ namespace OpenNest.Forms
private void PlateView_PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => UpdatePlate();
/// <summary>
/// Throttles mouse-follow updates: the first move shows at once, later moves inside
/// the interval collapse into one trailing update so the label settles on the last point.
/// Throttles mouse-follow updates: moves refresh the label at most once per interval,
/// and a settle timer restarted by each skipped move shows the last point once the
/// mouse rests. Moves drive the throttle themselves because WM_TIMER is starved while
/// input and repaints are queued.
/// </summary>
private void PlateView_MouseMove(object sender, MouseEventArgs e)
{
if (locationTimer.Enabled)
locationSettleTimer.Stop();
if (Environment.TickCount64 - lastLocationUpdate >= LocationRefreshIntervalMs)
{
locationPending = true;
UpdateThrottledLocation();
return;
}
UpdateLocation();
locationTimer.Start();
locationSettleTimer.Start();
}
private void LocationTimer_Tick(object sender, EventArgs e)
private void LocationSettleTimer_Tick(object sender, EventArgs e)
{
if (!locationPending)
{
locationTimer.Stop();
return;
}
locationPending = false;
UpdateLocation();
locationSettleTimer.Stop();
UpdateThrottledLocation();
}
private void StopLocationThrottle()
private void UpdateThrottledLocation()
{
locationTimer.Stop();
locationPending = false;
UpdateLocation();
lastLocationUpdate = Environment.TickCount64;
}
private void StopLocationThrottle() => locationSettleTimer.Stop();
private void PlateView_MouseClick(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
+29 -6
View File
@@ -39,6 +39,15 @@ public sealed class NestHistoryForm : Form
public bool Changed { get; private set; }
public event EventHandler HistoryChanged;
/// <summary>Raised when the highlighted version changes on a loaded history page.</summary>
public event EventHandler RevisionSelected;
/// <summary>The highlighted version when it is not the current one; otherwise null.</summary>
public NestRecord SelectedHistoricalRevision =>
session.Current != null && SelectedRevision is NestRecord record && record.Revision != session.Current.Revision
? record
: null;
public NestHistoryForm(INestRepository repository, string serverUrl, Guid id = default, bool embedded = false)
{
this.repository = repository;
@@ -74,7 +83,12 @@ public sealed class NestHistoryForm : Form
await ReadAsync(0);
}
};
revisions.SelectionChanged += (_, _) => UpdateButtons();
revisions.SelectionChanged += (_, _) =>
{
UpdateButtons();
if (!populating && session.Current != null)
RevisionSelected?.Invoke(this, EventArgs.Empty);
};
var refresh = new Button { Text = "Refresh", AutoSize = true };
refresh.Click += async (_, _) => { if (recovery) await ReadDeletedAsync(); else await ReadAsync(0); };
previous.Click += async (_, _) => await ReadAsync(System.Math.Max(0, session.Page.Offset - NestHistorySession.PageSize));
@@ -191,19 +205,28 @@ public sealed class NestHistoryForm : Form
try
{
if (!await task || IsDisposed || run != readGeneration) return;
foreach (var record in session.Page.Items)
populating = true;
try
{
var row = revisions.Rows.Add(record.Revision,
(record.Revision == session.Current.Revision ? "Current" : "Historical") + (record.Deleted ? " (deleted)" : ""),
record.SavedAt.ToString("g"), record.Name, record.Status, record.Comments, record.RestoredFromRevision, record.FileSize);
revisions.Rows[row].Tag = record;
foreach (var record in session.Page.Items)
{
var row = revisions.Rows.Add(record.Revision,
(record.Revision == session.Current.Revision ? "Current" : "Historical") + (record.Deleted ? " (deleted)" : ""),
record.SavedAt.ToString("g"), record.Name, record.Status, record.Comments, record.RestoredFromRevision, record.FileSize);
revisions.Rows[row].Tag = record;
}
}
finally { populating = false; }
RevisionSelected?.Invoke(this, EventArgs.Empty);
status.Text = $"{serverUrl} — Nest {selectedId} — Current version {session.Current.Revision}; {session.Page.Total} versions. Displaying {offset + 1}–{offset + session.Page.Items.Count}.";
}
catch (Exception ex) { if (!IsDisposed && run == readGeneration) status.Text = ex.Message; }
finally { if (!IsDisposed) UpdateButtons(); }
}
/// <summary>Downloads one version on the displayed page; null when the page changed meanwhile.</summary>
public Task<byte[]> DownloadRevisionAsync(int revision) => session.DownloadAsync(revision, serverUrl, lifetime.Token);
private async Task InspectAsync()
{
if (SelectedRevision is not NestRecord record || busy) return;
+16 -3
View File
@@ -49,6 +49,7 @@
this.updateGroupBox = new System.Windows.Forms.GroupBox();
this.updateFeedUrlBox = new System.Windows.Forms.TextBox();
this.setUpdateFeedButton = new System.Windows.Forms.Button();
this.includeDevBuildsBox = new System.Windows.Forms.CheckBox();
this.ledgerGroupBox = new System.Windows.Forms.GroupBox();
this.ledgerUrlLabel = new System.Windows.Forms.Label();
this.ledgerUrlBox = new System.Windows.Forms.TextBox();
@@ -203,7 +204,7 @@
// strategyGroupBox
//
this.strategyGroupBox.Controls.Add(this.strategyGrid);
this.strategyGroupBox.Location = new System.Drawing.Point(12, 457);
this.strategyGroupBox.Location = new System.Drawing.Point(12, 485);
this.strategyGroupBox.Name = "strategyGroupBox";
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
this.strategyGroupBox.TabIndex = 2;
@@ -262,9 +263,10 @@
//
this.updateGroupBox.Controls.Add(this.updateFeedUrlBox);
this.updateGroupBox.Controls.Add(this.setUpdateFeedButton);
this.updateGroupBox.Controls.Add(this.includeDevBuildsBox);
this.updateGroupBox.Location = new System.Drawing.Point(12, 391);
this.updateGroupBox.Name = "updateGroupBox";
this.updateGroupBox.Size = new System.Drawing.Size(684, 60);
this.updateGroupBox.Size = new System.Drawing.Size(684, 88);
this.updateGroupBox.TabIndex = 3;
this.updateGroupBox.TabStop = false;
this.updateGroupBox.Text = "Auto-update feed URL (blank uses GitHub Releases; set per machine)";
@@ -287,6 +289,16 @@
this.setUpdateFeedButton.UseVisualStyleBackColor = true;
this.setUpdateFeedButton.Click += new System.EventHandler(this.SetUpdateFeed_Click);
//
// includeDevBuildsBox
//
this.includeDevBuildsBox.AutoSize = true;
this.includeDevBuildsBox.Location = new System.Drawing.Point(12, 58);
this.includeDevBuildsBox.Name = "includeDevBuildsBox";
this.includeDevBuildsBox.Size = new System.Drawing.Size(460, 20);
this.includeDevBuildsBox.TabIndex = 2;
this.includeDevBuildsBox.Text = "Include development builds (latest master; takes effect after restart)";
this.includeDevBuildsBox.UseVisualStyleBackColor = true;
//
// ledgerGroupBox
//
this.ledgerGroupBox.Controls.Add(this.ledgerUrlLabel);
@@ -362,7 +374,7 @@
this.AcceptButton = this.saveButton;
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
this.CancelButton = this.cancelButton;
this.ClientSize = new System.Drawing.Size(708, 697);
this.ClientSize = new System.Drawing.Size(708, 725);
this.Controls.Add(this.strategyGroupBox);
this.Controls.Add(this.updateGroupBox);
this.Controls.Add(this.ledgerGroupBox);
@@ -415,6 +427,7 @@
private System.Windows.Forms.GroupBox updateGroupBox;
private System.Windows.Forms.TextBox updateFeedUrlBox;
private System.Windows.Forms.Button setUpdateFeedButton;
private System.Windows.Forms.CheckBox includeDevBuildsBox;
private System.Windows.Forms.GroupBox ledgerGroupBox;
private System.Windows.Forms.Label ledgerUrlLabel;
private System.Windows.Forms.TextBox ledgerUrlBox;
+2
View File
@@ -82,6 +82,7 @@ namespace OpenNest.Forms
{
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
updateFeedUrlBox.Text = Settings.Default.UpdateFeedUrl;
includeDevBuildsBox.Checked = Settings.Default.UpdateIncludeDevBuilds;
ledgerUrlBox.Text = MaterialLedgerStore.Settings.BaseUrl;
ledgerKeyBox.Text = MaterialLedgerStore.Settings.ApiKey;
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
@@ -105,6 +106,7 @@ namespace OpenNest.Forms
private void SaveSettings()
{
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
Settings.Default.UpdateIncludeDevBuilds = includeDevBuildsBox.Checked;
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
Settings.Default.CuttingPlanEffort = (double)planningEffortBox.Value;
Settings.Default.ActiveColorScheme =
+98 -10
View File
@@ -49,6 +49,11 @@ public sealed class SavedNestsForm : Form
private readonly TabPage versionsTab;
private NestHistoryForm historyView;
private Guid? historyId;
// Historical version shown in the details and preview instead of the current one.
private int? previewRevision;
private int revisionGeneration;
private readonly Dictionary<int, NestDetails> revisionDetails = new();
private readonly Timer revisionTimer;
private readonly SplitContainer detailsSplit;
private readonly PlateView platePreview;
private readonly Button previousPlateButton;
@@ -281,6 +286,12 @@ public sealed class SavedNestsForm : Form
if (SelectedNestId() is Guid id)
await ShowDetailsAsync(id);
};
revisionTimer = new Timer { Interval = 250 };
revisionTimer.Tick += async (_, _) =>
{
revisionTimer.Stop();
await ShowSelectedRevisionAsync();
};
var refreshButton = new Button { Text = "Refresh", AutoSize = true };
refreshButton.Click += async (_, _) => await RunAsync(session.RefreshAsync);
@@ -526,12 +537,16 @@ public sealed class SavedNestsForm : Form
detailsTimer.Stop();
var id = SelectedNestId();
if (historyId != id)
{
historyView?.Dispose();
historyView = null;
historyId = null;
}
DropHistoryView();
UpdateHistoryView();
if (historyView?.SelectedHistoricalRevision != null)
{
// The Versions tab has an earlier version highlighted: show that one, not the current.
revisionTimer.Stop();
revisionTimer.Start();
return;
}
if (id == null)
{
details.Clear();
@@ -565,7 +580,7 @@ public sealed class SavedNestsForm : Form
{
try
{
if (await details.LoadAsync(id) && !IsDisposed)
if (await details.LoadAsync(id) && !IsDisposed && previewRevision == null)
{
PopulateDetails(details.Details);
PrefetchNearby(id);
@@ -713,9 +728,14 @@ public sealed class SavedNestsForm : Form
{
if (detailsTabs.SelectedTab != versionsTab || SelectedNestId() is not Guid id || historyId == id)
return;
historyView?.Dispose();
DropHistoryView();
historyView = new NestHistoryForm(repository, serverUrl, id, embedded: true);
historyId = id;
historyView.RevisionSelected += (_, _) =>
{
revisionTimer.Stop();
revisionTimer.Start();
};
historyView.HistoryChanged += (_, _) =>
{
// Let the history operation finish before refreshing the browse selection.
@@ -732,6 +752,75 @@ public sealed class SavedNestsForm : Form
historyView.Show();
}
/// <summary>Disposes the embedded history and returns the details to the current version.</summary>
private void DropHistoryView()
{
historyView?.Dispose();
historyView = null;
historyId = null;
revisionTimer.Stop();
revisionGeneration++;
previewRevision = null;
revisionDetails.Clear();
}
/// <summary>
/// Shows the version highlighted in the Versions tab in the plates, drawings and preview.
/// The current version comes from <see cref="NestDetailsSession"/>; earlier versions are
/// downloaded once per embedded history view and kept, since saved versions never change.
/// </summary>
private async Task ShowSelectedRevisionAsync()
{
var view = historyView;
var record = view?.SelectedHistoricalRevision;
var run = ++revisionGeneration;
if (record == null)
{
if (previewRevision != null)
{
previewRevision = null;
ScheduleDetails();
}
return;
}
previewRevision = record.Revision;
if (revisionDetails.TryGetValue(record.Revision, out var cached))
{
PopulateRevision(record, cached);
return;
}
ShowDetailsMessage($"Loading version {record.Revision}...");
try
{
var bytes = await view.DownloadRevisionAsync(record.Revision);
if (bytes == null || IsDisposed || run != revisionGeneration)
return;
var nestDetails = await Task.Run(() =>
{
using var stream = new MemoryStream(bytes);
return NestDetails.FromNest(new NestReader(stream) { BestFitStore = BestFitCacheStore.Shared }.Read());
});
if (IsDisposed || run != revisionGeneration)
return;
revisionDetails[record.Revision] = nestDetails;
PopulateRevision(record, nestDetails);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
if (!IsDisposed && run == revisionGeneration)
ShowDetailsMessage($"Could not load version {record.Revision}: {ex.Message}");
}
}
private void PopulateRevision(NestRecord record, NestDetails nestDetails)
{
PopulateDetails(nestDetails);
detailsStatus.Text = $"Version {record.Revision} (historical) — {detailsStatus.Text}";
}
private async Task ShowDeletedAsync()
{
using var dialog = new NestHistoryForm(repository, serverUrl);
@@ -804,9 +893,7 @@ public sealed class SavedNestsForm : Form
// Drop archive previews and embedded history, including prefetched snapshots.
details.Dispose();
details = new NestDetailsSession(LoadDetailsAsync);
historyView?.Dispose();
historyView = null;
historyId = null;
DropHistoryView();
ShowDetailsMessage("");
await RunAsync(session.RefreshAsync);
foreach (DataGridViewRow row in nestGrid.Rows)
@@ -887,6 +974,7 @@ public sealed class SavedNestsForm : Form
{
searchTimer.Dispose();
detailsTimer.Dispose();
revisionTimer.Dispose();
session.Dispose();
details.Dispose();
}
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace OpenNest.Forms
{
/// <summary>
/// Shows a status strip's item tooltips above the strip. The strip's own tooltips open
/// below the cursor; at the bottom of the screen Windows pushes them back over the
/// cursor, the strip sees a mouse leave and hides them, and they flash on and off.
/// </summary>
internal sealed class StatusStripToolTips : IDisposable
{
private const int Gap = 2;
private readonly ToolStrip strip;
private readonly ToolTip toolTip = new();
private readonly Timer hoverTimer = new() { Interval = SystemInformation.MouseHoverTime };
private ToolStripItem hoverItem;
private string shownText;
public StatusStripToolTips(ToolStrip strip)
{
this.strip = strip;
strip.ShowItemToolTips = false;
strip.MouseMove += Strip_MouseMove;
strip.MouseLeave += Strip_MouseLeave;
strip.MouseDown += Strip_MouseDown;
hoverTimer.Tick += HoverTimer_Tick;
}
public void Dispose()
{
hoverTimer.Dispose();
toolTip.Dispose();
}
private void Strip_MouseMove(object sender, MouseEventArgs e)
{
var item = strip.GetItemAt(e.Location);
if (item == hoverItem)
return;
Hide();
hoverItem = item;
if (!string.IsNullOrEmpty(item?.ToolTipText))
hoverTimer.Start();
}
private void Strip_MouseLeave(object sender, EventArgs e)
{
Hide();
hoverItem = null;
}
// The next move re-arms the hover, so a click that changes the text (a hold refresh) shows the new tip.
private void Strip_MouseDown(object sender, MouseEventArgs e)
{
Hide();
hoverItem = null;
}
private void HoverTimer_Tick(object sender, EventArgs e)
{
hoverTimer.Stop();
Show();
}
private void Show()
{
var text = hoverItem?.ToolTipText;
if (string.IsNullOrEmpty(text))
{
Hide();
return;
}
// The tooltip draws its text in the status font plus a few pixels of border and margin.
var height = TextRenderer.MeasureText(text, SystemFonts.StatusFont).Height + 10;
toolTip.Show(text, strip, hoverItem.Bounds.Left, -height - Gap);
shownText = text;
}
private void Hide()
{
hoverTimer.Stop();
if (shownText == null)
return;
toolTip.Hide(strip);
shownText = null;
}
}
}
+23 -16
View File
@@ -3,6 +3,7 @@ using System.Drawing;
using System.IO;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Help;
using OpenNest.Support;
namespace OpenNest.Forms;
@@ -68,20 +69,6 @@ public sealed class SupportGridForm : Form
WrapContents = false,
Padding = new Padding(10),
};
layout.Controls.Add(new Label
{
AutoSize = true,
MaximumSize = new Size(430, 0),
Padding = new Padding(0, 0, 0, 8),
Text = "What cut parts rest on: a grid of contact points, one every pitch along X and Y, the "
+ "first at the offset from the plate origin into the sheet; or slats, each a strip of "
+ "the slat thickness running the full length or width of the table, one every pitch, "
+ "the first centered at the offset. A part whose center of gravity is not inside its "
+ "contacts by the stability margin can tip; with \"Only tab parts the support grid can't "
+ "hold level\" in Cutting Settings, Plan Cutting tabs it where the tab steadies it. "
+ "The registration tolerance allows for a sheet loaded off its place.",
});
useGridBox = new CheckBox { Text = "This nest's sheets rest on a support bed", AutoSize = true, Checked = bed != null };
layout.Controls.Add(useGridBox);
@@ -163,14 +150,34 @@ public sealed class SupportGridForm : Form
defaultsGroup.Controls.Add(defaultsLayout);
layout.Controls.Add(defaultsGroup);
var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Anchor = AnchorStyles.Right };
// The help link sits left of OK/Cancel; the row stretches to the dialog's widest control.
var footer = new TableLayoutPanel { AutoSize = true, ColumnCount = 2, Anchor = AnchorStyles.Left | AnchorStyles.Right };
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
var helpLink = new LinkLabel
{
Text = "How the support bed works",
AutoSize = true,
Anchor = AnchorStyles.Left,
};
helpLink.LinkClicked += (_, _) => HelpTopics.Show(this, HelpTopics.SupportGrid);
var buttons = new FlowLayoutPanel
{
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
WrapContents = false,
Anchor = AnchorStyles.Right,
};
var cancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
var ok = new Button { Text = "OK", AutoSize = true };
buttons.Controls.Add(cancel);
buttons.Controls.Add(ok);
layout.Controls.Add(buttons);
footer.Controls.Add(helpLink, 0, 0);
footer.Controls.Add(buttons, 1, 0);
layout.Controls.Add(footer);
Controls.Add(layout);
HelpTopics.Attach(this, HelpTopics.SupportGrid);
AcceptButton = ok;
CancelButton = cancel;
+90 -9
View File
@@ -1,19 +1,26 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.Windows.Forms;
namespace OpenNest.Forms
{
/// <summary>
/// Background update checks (GitHub Releases, or the per-machine feed URL from the
/// Options dialog) and the status-bar item that announces a downloaded update. Never
/// Background and on-demand (Help > Check for Updates) update checks (GitHub Releases,
/// or the per-machine feed URL from the Options dialog), the status-bar version label
/// and the status-bar item that announces a downloaded update. Never
/// restarts on its own: it restarts only when the user clicks the item and confirms.
/// A download left unapplied installs at the next startup (Velopack's default
/// auto-apply in <see cref="MainApp"/>).
/// </summary>
internal sealed class UpdaterStatus : IDisposable
{
private readonly Form owner;
private readonly UpdaterService updater = new();
private const string ReleasesUrl = "https://github.com/ajisaacs/OpenNest/releases";
private readonly ToolStripStatusLabel label;
private bool checking;
public UpdaterStatus(Form owner, StatusStrip statusStrip, Font baseFont)
{
@@ -27,6 +34,13 @@ namespace OpenNest.Forms
label.Click += Label_Click;
statusStrip.Items.Add(label);
statusStrip.Items.Add(new ToolStripStatusLabel
{
Text = "v" + CurrentVersion,
ToolTipText = "OpenNest version",
ForeColor = SystemColors.GrayText,
});
updater.UpdateReady += version =>
{
if (owner.IsHandleCreated)
@@ -34,8 +48,63 @@ namespace OpenNest.Forms
};
}
public string CurrentVersion => updater.CurrentVersion;
public void Start() => updater.Start();
/// <summary>Checks for an update now and reports the outcome; offers a restart when one is ready.</summary>
public async void CheckForUpdates()
{
if (checking)
return;
checking = true;
var previousCursor = owner.Cursor;
owner.Cursor = Cursors.WaitCursor;
UpdaterService.CheckResult result;
try
{
result = await updater.CheckNowAsync();
}
finally
{
owner.Cursor = previousCursor;
checking = false;
}
switch (result)
{
case UpdaterService.CheckResult.UpdateReady:
ShowUpdateReady(updater.PendingVersion);
PromptRestart($"OpenNest {updater.PendingVersion} has been downloaded. Restart now to apply it? Any unsaved nest will be lost.");
break;
case UpdaterService.CheckResult.UpToDate:
MessageBox.Show(owner, $"OpenNest {CurrentVersion} is the latest version.",
"Check for Updates", MessageBoxButtons.OK, MessageBoxIcon.Information);
break;
case UpdaterService.CheckResult.Failed:
MessageBox.Show(owner, "Could not reach the update source. Check the network connection and try again.",
"Check for Updates", MessageBoxButtons.OK, MessageBoxIcon.Warning);
break;
case UpdaterService.CheckResult.NotInstalled:
var open = MessageBox.Show(owner,
$"This copy of OpenNest ({CurrentVersion}) was not installed with the OpenNest installer, so it cannot update itself.\n\nOpen the releases page to download the latest version?",
"Check for Updates", MessageBoxButtons.YesNo, MessageBoxIcon.Information);
if (open == DialogResult.Yes)
OpenReleasesPage();
break;
}
}
public void ShowAbout()
{
MessageBox.Show(owner, $"OpenNest {CurrentVersion}\n\n{ReleasesUrl}",
"About OpenNest", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
public void Dispose() => updater.Dispose();
private void Label_Click(object sender, EventArgs e)
@@ -43,17 +112,29 @@ namespace OpenNest.Forms
if (!updater.HasPendingUpdate)
return;
var result = MessageBox.Show(
owner,
"An OpenNest update has been downloaded. Restart now to apply it? Any unsaved nest will be lost.",
"Update ready",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question
);
PromptRestart("An OpenNest update has been downloaded. Restart now to apply it? Any unsaved nest will be lost.");
}
private void PromptRestart(string message)
{
var result = MessageBox.Show(owner, message, "Update ready", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (result == DialogResult.Yes)
updater.ApplyAndRestart();
}
private void OpenReleasesPage()
{
try
{
Process.Start(new ProcessStartInfo(ReleasesUrl) { UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show(owner, $"Could not open {ReleasesUrl}: {ex.Message}",
"Check for Updates", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
private void ShowUpdateReady(string version)
{
label.Text = $"Update {version} ready - click to restart";
+1 -1
View File
@@ -33,7 +33,7 @@ namespace OpenNest.Help
{
pendingPage = page;
Text = "OpenNest Help";
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
Icon = Properties.Resources.app_icon;
StartPosition = FormStartPosition.Manual;
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
Size = new Size(System.Math.Min(1100, area.Width), System.Math.Min(900, area.Height));
+1
View File
@@ -18,6 +18,7 @@ namespace OpenNest.Help
public const string ImportDrawings = "import-drawings";
public const string AutoNest = "auto-nest";
public const string Post = "post";
public const string SupportGrid = "support-grid";
/// <summary>Overrides the help folder, e.g. a local opennest-help <c>site/</c> build.</summary>
public const string DirectoryVariable = "OPENNEST_HELP_DIR";
+2 -2
View File
@@ -173,7 +173,7 @@ namespace OpenNest
/// Draws the id over a disc of <paramref name="background"/>, so a line drawn through the
/// label's point, such as a cut-off's own cut, does not strike through the number.
/// </summary>
internal void DrawLabel(Graphics g, string id, bool muted, Brush background)
internal void DrawLabel(Graphics g, string id, Brush background, Brush text)
{
var size = MeasureLabel(g, id);
if (!LabelFits(size))
@@ -181,7 +181,7 @@ namespace OpenNest
var diameter = LabelDiscDiameter(size);
g.FillEllipse(background,
_labelScreenPoint.X - diameter / 2, _labelScreenPoint.Y - diameter / 2, diameter, diameter);
DrawLabel(g, id, muted);
g.DrawString(id, ProgramIdFont, text, _labelScreenPoint.X, _labelScreenPoint.Y, LabelFormat);
}
private SizeF MeasureLabel(Graphics g, string id)
+2
View File
@@ -6,6 +6,7 @@
<AssemblyName>OpenNest</AssemblyName>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationHighDpiMode>DpiUnaware</ApplicationHighDpiMode>
<ApplicationIcon>Resources\OpenNest.ico</ApplicationIcon>
<Version>0.4.0</Version>
</PropertyGroup>
<ItemGroup>
@@ -28,6 +29,7 @@
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
<ProjectReference Include="..\OpenNest.Desktop\OpenNest.Desktop.csproj" />
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Configuration;
using System.IO;
using System.Text.Json;
namespace OpenNest.Properties
{
/// <summary>
/// Stores the desktop app's user-scoped <see cref="Settings"/> in
/// %APPDATA%\OpenNest\user-settings.json instead of .NET's default user.config.
/// </summary>
/// <remarks>
/// The default provider writes to %LOCALAPPDATA%\OpenNest\&lt;exe&gt;_Url_&lt;hash&gt;\&lt;version&gt;\,
/// which is inside the Velopack install folder (setup wipes it on reinstall) and
/// starts empty at every assembly version. This file survives both. When it does
/// not exist yet, values the default provider still holds for this copy are
/// carried over once.
/// </remarks>
public sealed class AppDataSettingsProvider : SettingsProvider
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly string _path;
private readonly Func<SettingsProvider> _legacyProvider;
public AppDataSettingsProvider()
: this(DefaultPath, () => new LocalFileSettingsProvider()) { }
/// <param name="path">The settings file.</param>
/// <param name="legacyProvider">Supplies values to carry over when <paramref name="path"/> does not exist; null for none.</param>
public AppDataSettingsProvider(string path, Func<SettingsProvider> legacyProvider)
{
_path = path;
_legacyProvider = legacyProvider;
}
/// <summary>%APPDATA%\OpenNest\user-settings.json.</summary>
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest", "user-settings.json");
public override string ApplicationName { get; set; } = "OpenNest";
public override void Initialize(string name, NameValueCollection config)
=> base.Initialize(string.IsNullOrEmpty(name) ? nameof(AppDataSettingsProvider) : name, config);
public override SettingsPropertyValueCollection GetPropertyValues(SettingsContext context, SettingsPropertyCollection collection)
{
var stored = File.Exists(_path) ? Load() : Migrate(context, collection);
var values = new SettingsPropertyValueCollection();
foreach (SettingsProperty property in collection)
{
var value = new SettingsPropertyValue(property);
if (stored.TryGetValue(property.Name, out var serialized))
{
value.SerializedValue = serialized;
value.IsDirty = false;
}
values.Add(value);
}
return values;
}
public override void SetPropertyValues(SettingsContext context, SettingsPropertyValueCollection collection)
{
// Keep entries this build does not know about, so older and newer copies share the file.
var stored = Load();
foreach (SettingsPropertyValue value in collection)
{
// Untouched defaults stay out of the file so a later default change still applies.
if (!value.IsDirty && (value.UsingDefaultValue || value.SerializedValue is null))
continue;
if (value.SerializedValue is string serialized)
stored[value.Property.Name] = serialized;
else
stored.Remove(value.Property.Name);
value.IsDirty = false;
}
Write(stored);
}
private Dictionary<string, string> Load()
{
try
{
return File.Exists(_path)
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(_path)) ?? new()
: new();
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return new();
}
}
private Dictionary<string, string> Migrate(SettingsContext context, SettingsPropertyCollection collection)
{
var stored = new Dictionary<string, string>();
if (_legacyProvider is null)
return stored;
try
{
var legacy = _legacyProvider();
legacy.Initialize(null, null);
// user.config reports a missing setting as its default text; skip those.
foreach (SettingsPropertyValue value in legacy.GetPropertyValues(context, collection))
{
if (value.SerializedValue is string serialized
&& serialized != value.Property.DefaultValue as string)
stored[value.Property.Name] = serialized;
}
}
catch (Exception ex) when (ex is ConfigurationException or IOException or UnauthorizedAccessException)
{
// An unreadable user.config only means there is nothing to carry over.
return new();
}
if (stored.Count > 0)
Write(stored);
return stored;
}
private void Write(Dictionary<string, string> stored)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path)));
var temp = _path + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(stored, JsonOptions));
File.Move(temp, _path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Settings are a convenience: keep running with the in-memory values.
}
}
}
}
+7
View File
@@ -83,6 +83,13 @@ namespace OpenNest.Properties {
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Icon app_icon {
get {
object obj = ResourceManager.GetObject("app_icon", resourceCulture);
return ((System.Drawing.Icon)(obj));
}
}
internal static System.Drawing.Bitmap cut_planner {
get {
object obj = ResourceManager.GetObject("cut_planner", resourceCulture);
+3
View File
@@ -205,4 +205,7 @@
<data name="drawing_library" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\drawing_library.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="app_icon" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\OpenNest.ico;System.Drawing.Icon, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>
+26
View File
@@ -307,6 +307,19 @@ namespace OpenNest.Properties {
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
// Also offer the rolling dev-build pre-release from GitHub Releases.
public bool UpdateIncludeDevBuilds {
get {
return ((bool)(this["UpdateIncludeDevBuilds"]));
}
set {
this["UpdateIncludeDevBuilds"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("Laser")]
@@ -359,6 +372,19 @@ namespace OpenNest.Properties {
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("True")]
// The Plan Cutting toolbar button plans every plate; false plans only the plate on screen.
public bool CuttingPlanAllPlates {
get {
return ((bool)(this["CuttingPlanAllPlates"]));
}
set {
this["CuttingPlanAllPlates"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
+10
View File
@@ -0,0 +1,10 @@
using System.Configuration;
namespace OpenNest.Properties
{
// Keeps user settings outside the Velopack install folder; see AppDataSettingsProvider.
[SettingsProvider(typeof(AppDataSettingsProvider))]
internal sealed partial class Settings
{
}
}
+6
View File
@@ -74,6 +74,9 @@
<Setting Name="UpdateFeedUrl" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="UpdateIncludeDevBuilds" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
<Setting Name="DrawingLibraryCutType" Type="System.String" Scope="User">
<Value Profile="(Default)">Laser</Value>
</Setting>
@@ -86,6 +89,9 @@
<Setting Name="CuttingPlanEffort" Type="System.Double" Scope="User">
<Value Profile="(Default)">1</Value>
</Setting>
<Setting Name="CuttingPlanAllPlates" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">True</Value>
</Setting>
<Setting Name="CutOffLockHeadHeight" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more