diff --git a/.claude/launch.json b/.claude/launch.json index aace5da..91e9a66 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -2,10 +2,10 @@ "version": "0.0.1", "configurations": [ { - "name": "browser-wasm", - "runtimeExecutable": "dotnet", - "runtimeArgs": ["run", "--project", "src/ScanNTune.Browser", "-c", "Debug"], - "port": 5235 + "name": "web", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "web", "run", "dev"], + "port": 5173 } ] } diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..971cbb6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +web/e2e/fixtures/*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/release.yml b/.github/release.yml deleted file mode 100644 index 40c2d70..0000000 --- a/.github/release.yml +++ /dev/null @@ -1,6 +0,0 @@ -changelog: - categories: - - title: Features - labels: [feature] - - title: Fixes - labels: [fix, bug] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 3dd0fe4..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build & test - -# Per-change gate: builds and runs the full test suite on every pull request and on pushes to master. -# Scoped to master on push so cutting a release (which pushes a tag) doesn't re-run CI on the same -# commit; feature branches are covered by their pull request. - -on: - pull_request: - push: - branches: [master] - -permissions: - contents: read - -concurrency: - group: build-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Nerdbank.GitVersioning needs full history to compute the version - - - name: Set up .NET 10 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Install wasm-tools workload - run: dotnet workload install wasm-tools - - - name: Restore local tools - run: dotnet tool restore - - - name: Build & run full test suite - run: pwsh ./build.ps1 --target Test --configuration Release diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index af05165..ed6fd2b 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -1,8 +1,7 @@ name: Deploy web app -# Publishes the WebAssembly build to GitHub Pages on every push to master (and on demand). The wasm -# native relink (OpenCV) makes this a slow-ish job, but it keeps the hosted web app in step with master. - +# Builds the Vue app in web/ and publishes web/dist to GitHub Pages on every push to master (and on +# demand). Everything is static, so Pages serves it with no server and no special headers. on: push: branches: [master] @@ -17,40 +16,28 @@ concurrency: group: pages cancel-in-progress: false +defaults: + run: + working-directory: web + jobs: build: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Nerdbank.GitVersioning needs full history to stamp the version - - # Enables Pages for the repo (if needed) with the "GitHub Actions" source, so no manual settings step. - - name: Configure Pages - uses: actions/configure-pages@v5 - with: - enablement: true - - - name: Set up .NET 10 - uses: actions/setup-dotnet@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - dotnet-version: '10.0.x' - - - name: Install wasm-tools workload - run: dotnet workload install wasm-tools - - - name: Publish the browser head - run: dotnet publish src/ScanNTune.Browser -c Release -o publish - - # GitHub Pages otherwise strips folders that start with an underscore (like _framework). + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run build - name: Disable Jekyll - run: touch publish/wwwroot/.nojekyll - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + run: touch dist/.nojekyll + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 with: - path: publish/wwwroot + path: web/dist deploy: needs: build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7f8507a..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Release - -# Manual release: publishes a GitHub release with the Windows installer + update feed and a -# self-contained portable zip (no install required), with notes built from the labelled pull -# requests since the previous release tag. -# Run it from the Actions tab after bumping the major/minor version in version.json. - -on: - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -jobs: - release: - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Nerdbank.GitVersioning needs full history to compute the version - - - name: Set up .NET 10 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Install wasm-tools workload - run: dotnet workload install wasm-tools - - - name: Restore local tools - run: dotnet tool restore - - - name: Build installer and publish the release - run: pwsh ./build.ps1 --target Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml new file mode 100644 index 0000000..9cb6896 --- /dev/null +++ b/.github/workflows/web-ci.yml @@ -0,0 +1,43 @@ +name: Web CI + +# Builds, unit-tests, and end-to-end-tests the Vue web app on every pull request and on pushes to master. +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +defaults: + run: + working-directory: web + +jobs: + build-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run build + - run: npm test + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + lfs: true # the real scan fixtures under web/e2e/fixtures are stored in Git LFS + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run e2e diff --git a/.gitignore b/.gitignore index 8edf6e9..8b423bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,490 +1,22 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from `dotnet new gitignore` +# The app lives in web/ (Vue + Vite); its build artifacts are ignored by web/.gitignore. +# This root ignore only covers OS, editor, and stray dependency/output files. -# dotenv files -.env - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET -project.lock.json -project.fragment.lock.json -artifacts/ - -# Tye -.tye/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat +# Dependencies and build output (in case tooling runs from the repo root) node_modules/ +dist/ +dist-ssr/ +*.tsbuildinfo -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml -.idea/ - -# NUKE build temp + release artifacts -.nuke/temp/ -artifacts/ - -## -## Visual studio for Mac -## - - -# globs -Makefile.in -*.userprefs -*.usertasks -config.make -config.status -aclocal.m4 -install-sh -autom4te.cache/ -*.tar.gz -tarballs/ -test-results/ +# Local env / secrets +.env +.env.local +*.local -# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore -# General +# OS files .DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore -# Windows thumbnail cache files Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# Vim temporary swap files +# Editor / IDE +.idea/ +.vscode/ *.swp - -# Renamed OpenCvSharp wasm native archive: a build artifact the browser head csproj target emits -# (120 MB, regenerated each build), not source. -OpenCvSharpExtern.a diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json deleted file mode 100644 index 76ce3f8..0000000 --- a/.nuke/build.schema.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "definitions": { - "Host": { - "type": "string", - "enum": [ - "AppVeyor", - "AzurePipelines", - "Bamboo", - "Bitbucket", - "Bitrise", - "GitHubActions", - "GitLab", - "Jenkins", - "Rider", - "SpaceAutomation", - "TeamCity", - "Terminal", - "TravisCI", - "VisualStudio", - "VSCode" - ] - }, - "ExecutableTarget": { - "type": "string", - "enum": [ - "Compile", - "Pack", - "PackPortable", - "PublishDesktop", - "Release", - "Restore", - "RunDesktop", - "Test" - ] - }, - "Verbosity": { - "type": "string", - "description": "", - "enum": [ - "Verbose", - "Normal", - "Minimal", - "Quiet" - ] - }, - "NukeBuild": { - "properties": { - "Continue": { - "type": "boolean", - "description": "Indicates to continue a previously failed build attempt" - }, - "Help": { - "type": "boolean", - "description": "Shows the help text for this build assembly" - }, - "Host": { - "description": "Host for execution. Default is 'automatic'", - "$ref": "#/definitions/Host" - }, - "NoLogo": { - "type": "boolean", - "description": "Disables displaying the NUKE logo" - }, - "Partition": { - "type": "string", - "description": "Partition to use on CI" - }, - "Plan": { - "type": "boolean", - "description": "Shows the execution plan (HTML)" - }, - "Profile": { - "type": "array", - "description": "Defines the profiles to load", - "items": { - "type": "string" - } - }, - "Root": { - "type": "string", - "description": "Root directory during build execution" - }, - "Skip": { - "type": "array", - "description": "List of targets to be skipped. Empty list skips all dependencies", - "items": { - "$ref": "#/definitions/ExecutableTarget" - } - }, - "Target": { - "type": "array", - "description": "List of targets to be invoked. Default is '{default_target}'", - "items": { - "$ref": "#/definitions/ExecutableTarget" - } - }, - "Verbosity": { - "description": "Logging verbosity during build execution. Default is 'Normal'", - "$ref": "#/definitions/Verbosity" - } - } - } - }, - "allOf": [ - { - "properties": { - "Configuration": { - "type": "string", - "description": "Configuration to build — default is 'Debug' (local) or 'Release' (server)" - }, - "GitHubToken": { - "type": "string", - "description": "GitHub token for publishing releases. Defaults to the GH_TOKEN or GITHUB_TOKEN environment variable" - } - } - }, - { - "$ref": "#/definitions/NukeBuild" - } - ] -} diff --git a/.nuke/parameters.json b/.nuke/parameters.json deleted file mode 100644 index 0967ef4..0000000 --- a/.nuke/parameters.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/CLAUDE.md b/CLAUDE.md index 513455d..52a8aee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,184 +5,148 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview A tool that **auto-calibrates a 3D printer's XY shrinkage and skew from a flatbed scan** of a printed -calibration coupon — no manual caliper measurements. The user prints `calibration_coupon.scad` (an open -lattice of measurement rings), scans it, and the software reads the geometry with OpenCV and emits ready --to-paste firmware/slicer corrections. +calibration coupon: no manual caliper measurements. The user prints `calibration_coupon.scad` (an open +lattice of measurement rings), scans it, and the software reads the geometry with OpenCV.js and emits +ready-to-paste firmware/slicer corrections. -The measurement principle: ring **centres** give true X/Y scale and skew (centres are immune to over/under --extrusion — extrusion changes a ring's wall width, not its centre). The correction math mirrors the Vector -3D "Califlower" calculator (Klipper `SET_SKEW`, Marlin `XY_SKEW_FACTOR`/steps-per-mm, Orca/Super shrinkage -%, RRF `M556`). +The measurement principle: ring **centres** give true X/Y scale and skew (centres are immune to +over/under-extrusion, because extrusion changes a ring's wall width, not its centre). The correction math +mirrors the Vector 3D "Califlower" calculator (Klipper `SET_SKEW`, Marlin `XY_SKEW_FACTOR`/steps-per-mm, +Orca/Super shrinkage %, RRF `M556`). Orientation is automatic. The coupon's origin-corner ring **and its +X neighbour** are printed SOLID (no -hole) — a two-ring marker the software reads: `origin → neighbour` is the coupon's +X, which resolves +hole): a two-ring marker the software reads. `origin → neighbour` is the coupon's +X, which resolves rotation AND mirror-flip with no manual input (see "Coupon & orientation" below). -## Build & Run +## The app: a Vue 3 web app -The solution lives at `src/ScanNTune.slnx` (new XML solution format). Core, UI, App and Tests target -`net10.0`; the browser head targets `net10.0-browser` and needs the `wasm-tools` workload -(`dotnet workload install wasm-tools`). +The app is a plain web app under `web/` (Vue 3 + TypeScript + Vite + Vuetify). **Web is the only target.** +The CV measurement pipeline is ported to TypeScript and runs in a **Web Worker** using **OpenCV.js**, so +analysis is off the main thread (the page never freezes), needs no cross-origin-isolation headers (works on +GitHub Pages), and is fast (V8 JIT + an optimized OpenCV.js build). Native `` and +`` mean there is no soft-keyboard, touch-stepper, or iOS file-input workaround to carry. + +Commands (run inside `web/`): ```bash -dotnet restore src/ScanNTune.slnx -dotnet build src/ScanNTune.slnx # builds every head, incl. the browser wasm native relink -dotnet run --project src/ScanNTune.App # launch the Windows desktop UI -dotnet run --project src/ScanNTune.Browser # serve the WebAssembly app for local dev -dotnet test src/ScanNTune.Tests # run the pipeline tests +npm install +npm run dev # Vite dev server at http://localhost:5173/ScanNTune/ +npm run build # vue-tsc typecheck + production build to web/dist +npm test # Vitest: engine unit tests + fixture-backed CV tests +npm run e2e # Playwright end-to-end over the real scans in web/e2e/fixtures ``` -The browser head publishes to a static site: `dotnet publish src/ScanNTune.Browser -c Release -o publish` -produces `publish/wwwroot`, which `.github/workflows/deploy-web.yml` ships to GitHub Pages on every push to -`master`. If a wasm build ever aborts at load with a Mono "double fault" (usually after changing a native -build flag), the incremental native build is stale: `rm -rf src/ScanNTune.Browser/bin src/ScanNTune.Browser/obj` -and rebuild clean. - -Tests are NUnit and cover the CV pipeline end-to-end against `ScanNTune.Tests/TestFiles/TestData_2solid.png` -(a perfect render of the coupon *with the two-solid marker* → ~0% scale, ~0° skew, 23 detectable holes). The -suite rotates, mirror-flips, stretches, and shears it to prove rotation/flip-invariance and skew recovery. -Add new fixtures by dropping an image into `TestFiles/` and asserting its known answer. - -When iterating on the app, **stop any running `ScanNTune.App` before rebuilding** — a live instance -locks `ScanNTune.Core.dll` and the App build fails to copy it (`taskkill`/`Stop-Process`). - -Coupon model (OpenSCAD): render a top view with -`openscad -o out.png --projection=ortho --camera=0,0,0,0,0,0,150 --viewall --autocenter calibration_coupon.scad`; -re-export the STL with `openscad -o calibration_coupon.stl calibration_coupon.scad` (~90s CGAL render). There -is no CLI to run the engine on an arbitrary scan yet — use an `[Explicit]` NUnit test (or add a small CLI). - -## Projects - -The app is split into a headless engine, a shared UI library, and two platform "heads", so the CV/calc -logic stays reusable and each platform carries only its own glue: - -- **ScanNTune.Core**, the engine, with **no UI dependency**: load image, detect ring centres (sub-pixel), - map to the grid + resolve orientation from the two-solid marker, affine-fit for X/Y scale + skew. - Libraries: managed `OpenCvSharp4`, `MathNet.Numerics`. Core references **only managed OpenCvSharp**; - each head supplies the native runtime. -- **ScanNTune.UI**, shared Avalonia (`net10.0`): the Views, ViewModels, `ViewLocator`, controls, themes, - assets (including the bundled coupon STL), the platform abstractions `IPlatformImaging` (decode image - bytes to a BGR `Mat`) and `ICouponExporter`, and the Autofac `UiModule`. Both heads reuse it. -- **ScanNTune.App**, the **Windows desktop head**: `Program`/`App`/`MainWindow`, Velopack updates, the - Serilog file sink, `OpenCvImaging`, `WindowsCouponExporter`, `JsonCalibrationStore`, and `AppModule`. - References `OpenCvSharp4.runtime.win`. (A WIA scanner source could plug in here as another head service.) -- **ScanNTune.Browser**, the **WebAssembly head** (`net10.0-browser`): `Avalonia.Browser`, `SkiaImaging` - (the wasm OpenCV build ships no image codecs, so Skia decodes), `BrowserCouponExporter`, a localStorage-backed - calibration store, a JS interop module (`wwwroot/interop.js`), and `BrowserModule`. References - `OpenCvSharp4.runtime.wasm` + `SkiaSharp`. Runs entirely client-side. Several deliberate wasm workarounds - are commented in `ScanNTune.Browser.csproj` and the platform files (P/Invoke module rename to - `OpenCvSharpExtern.a`, `EmccLinkOptimizationFlag=-O0`, `WasmAllowUndefinedSymbols`, a raised heap size, - `JsonSerializerIsReflectionEnabledByDefault`, and `TrimmerRootAssembly` roots for the reflection users, - including Autofac whose registration sources the trimmer otherwise strips): keep them, do not "simplify" them away. -- **ScanNTune.Tests**, NUnit; end-to-end pipeline tests over fixture scans. Carries `runtime.win` so - OpenCV runs on Windows. - -**Composition is Autofac.** Each head builds a container from `UiModule` (Core engine services + view models -+ an open-generic `ILogger<>` over a head-supplied `ILoggerFactory`) plus its own module (platform imaging, -coupon export, the calibration/settings stores, the logger factory), then resolves `MainWindowViewModel`. -Adding a platform service is a new registration in a head module, with no edits elsewhere (rule 3). - -The analyze pipeline is three injected stages, `IRingDetector`, `IGridMapper`, `IAffineSolver`, composed by -`CouponAnalyzer`. Output is produced on demand by two services the UI calls: `ICorrectionFormatter` -(per-flavour firmware/slicer snippets) and `IOverlayRenderer` (annotated scan; `RenderOverlay` returns a -`Mat` the UI turns straight into a bitmap, so overlays work without an image encoder in wasm). The -measurement key: ring **centres** drive scale/skew (extrusion-immune); absolute scale needs -`AnalysisOptions.PxPerMm` (scanner DPI / 25.4), set from the coupon baseline (mm) and scanner DPI in the app, -otherwise only anisotropy + skew are meaningful. - -Scans reach the engine as **image bytes**: the shared view models load the picked/dropped file via Avalonia -`StorageProvider` and decode through `IPlatformImaging` (OpenCV on desktop, Skia in the browser), so the UI -has no filesystem-path dependency. - -The model source (`calibration_coupon.scad`) and its exported `calibration_coupon.stl` live at the repo -root. +Structure: + +- **`web/src/engine/`**: the framework-agnostic measurement engine (no Vue, no DOM assumptions beyond what + OpenCV.js needs). Each function takes the loaded `cv` instance as a parameter, so OpenCV.js stays out of + the main bundle (it lives in the worker chunk, loaded on first analysis) and tests can inject it. Stages: + `ringDetector`, `gridMapper`, `affineSolver`, `couponAnalyzer`, `scanCombiner`, `cardEdgeMeasurer`, + `overlayRenderer`, `correctionFormatter`, plus `types`, `opencv` (loader), `imageData`, and shared + helpers `math`/`cvUtils`. +- **`web/src/worker/`**: a Comlink Web Worker (`analysis.worker.ts`) exposing `analyzeTwoScans` and + `measureCardScan`; `decode.ts` decodes image bytes with `createImageBitmap` + `OffscreenCanvas` and + renders overlays back as `ImageBitmap`. `web/src/workerClient.ts` is the only thing the UI calls for CV. +- **`web/src/components/`**: thin Vue pages (`ScanPage`, `CalibrationPage`, `ResultsPage`) plus the guide + diagrams and controls, over Pinia stores in `web/src/stores/` (`useApp` for navigation + payload, + `useCalibration` for the localStorage-backed scanner calibration). +- **Tests**: `web/tests/` (Vitest engine + fixture CV tests, with `tests/helpers/cv.ts` and + `tests/fixtures/TestData_2solid.png`) and `web/e2e/` (Playwright over the real scans in + `web/e2e/fixtures/`). + +Absolute scale needs a known px/mm (scanner DPI is rarely exact), so the app measures a standard ISO/IEC +7810 plastic card (`cardEdgeMeasurer`) to learn the true px/mm; without it, only anisotropy and skew are +meaningful. + +Two durable gotchas: +- **OpenCV.js loads via a default import** (`import cvReady from '@techstark/opencv-js'`), NOT a namespace or + dynamic `import()`. Its `module.exports` is a Promise, which a namespace/dynamic import turns into a broken + thenable ("Promise.prototype.then called on incompatible receiver") in both Vitest and the browser build; a + bundler default import returns `module.exports` (the real Promise) directly. In Vitest the engine CV tests + load it with a native `require` instead (see `web/tests/helpers/cv.ts`), because even the default import is + re-wrapped by Vitest's module runner. +- **Vite `base` is `/ScanNTune/`** (the GitHub Pages project path); asset URLs and the STL download depend on + `import.meta.env.BASE_URL`. The app version shown in the brand bar is injected from `package.json` at build + time via the Vite `define` `__APP_VERSION__`. + +CI: `.github/workflows/web-ci.yml` builds, unit-tests, and e2e-tests the app on pull requests and on pushes +to `master`; `.github/workflows/deploy-web.yml` builds `web/dist` and publishes it to GitHub Pages on every +push to `master` (served at `https://jaak0b.github.io/ScanNTune/`). + +The measurement engine was ported 1:1 from a retired C# implementation and validated against the same +`TestData_2solid.png` fixture at the same tolerances (23 rings, ~0 skew, isotropy), plus Playwright over the +real scans (the card recovers ~23.6 px/mm; the two-scan flow completes on 35 MP scans without freezing). +Do not change the ported math without re-validating those fixtures (rule 1). + +The coupon model source (`calibration_coupon.scad`) and its exported `calibration_coupon.stl` live at the +repo root (the STL is also copied into `web/public/` for the in-app download). Re-render the model with +OpenSCAD: `openscad -o calibration_coupon.stl calibration_coupon.scad` (~90s CGAL render); preview a top +view with `openscad -o out.png --projection=ortho --camera=0,0,0,0,0,0,150 --viewall --autocenter +calibration_coupon.scad`. ## Coupon & orientation The coupon is an open lattice of `grid_n` × `grid_n` rings joined by ribs (default 5×5, 100 mm baseline). Two rings are printed SOLID (no hole) as the **orientation marker**: the origin corner and its +X neighbour. -`GridMapper` finds the unique "corner + edge-neighbour" pair of missing (holeless) grid vertices; +`gridMapper` finds the unique "corner + edge-neighbour" pair of missing (holeless) grid vertices; `origin → neighbour` is the coupon's +X. Because that gives the true physical axes, X/Y labels **and** the -skew sign come out correct at any rotation or mirror-flip — **no manual flip flag**. The marker is -**required**: if it can't be located `GridMapper.Map` throws (it tolerates at most one stray missed hole — a stray adjacent to a corner -makes the marker ambiguous and is rejected too — but not an absent marker; there is deliberately no -rotation-only fallback). +skew sign come out correct at any rotation or mirror-flip: **no manual flip flag**. The marker is +**required**: if it can't be located `mapGrid` throws (it tolerates at most one stray missed hole, but a +stray adjacent to a corner makes the marker ambiguous and is rejected too, and an absent marker is rejected: +there is deliberately no rotation-only fallback). -`RingDetector` gotcha: the circularity gate is **loose (0.20)** because real printed/scanned holes are rough -(~0.2–0.8 circularity). Rings are separated from the much larger square lattice cells by a **size cluster** -(radius-median filter), NOT by circularity — a strict threshold silently drops nearly every ring on a real -scan. +`ringDetector` gotcha: the circularity gate is **loose (0.20)** because real printed/scanned holes are rough +(~0.2 to 0.8 circularity). Rings are separated from the much larger square lattice cells by a **size +cluster** (radius-median filter), NOT by circularity: a strict threshold silently drops nearly every ring on +a real scan. ## Conventions -The coding rules are strict; each is numbered for unambiguous reference: - -1. **No static methods or properties** except `public const` fields — use injected instance services and - virtual dispatch instead. -2. **No empty catch blocks** — a catch must log and/or return a meaningful value. -3. **Open/closed** — a new subtype (a new CV stage, output formatter, scanner source, …) should require - zero edits outside its own file. -4. **Logging is mandatory — a null logger is FORBIDDEN.** Every component that can throw, swallow, or - recover from an error must log through `ILogger` (Microsoft.Extensions.Logging.Abstractions). The - app backs that abstraction with **Serilog**, writing to a rolling file under - `%LocalAppData%\ScanNTune\logs`; the desktop head wires global handlers so every unhandled/unobserved - exception is logged. Never pass `null` as a logger and never leave a `catch` that neither logs nor - returns a meaningful value (pairs with rule 2). Core stays engine-only: it depends on the - `ILogger` abstraction, never on Serilog — the app supplies the concrete logger. -5. **No AI attribution anywhere that touches git or GitHub** — not in commit messages, PR titles or - descriptions, issue/PR comments, tags, or release notes. Concretely: no `Co-Authored-By` trailer, - no "Generated with Claude Code" (or any similar "made/assisted by AI") line, and no AI tool name - anywhere in the history or on GitHub. This applies to every `git` and `gh`/GitHub API action - without exception. Keep commit messages to a single short sentence (a concise subject line, no body). -6. **Self-review before handoff — multi-file changes only.** Before presenting a multi-file change for - commit approval, run a medium-effort `/code-review` scoped to the change. Every finding it surfaces - requires a logged disposition — paste the finder list verbatim and mark each one **Fixed** (name the - commit that resolves it), **False positive** (the finding is factually wrong, proven with the quoted - line that refutes it), or **Owner-waived** (you flagged it to the owner and they chose not to fix it). +The coding rules are strict; each is numbered for unambiguous reference. Do not cite these rule numbers in +shipped source, comments, or UI text: they are guidance for how to work, not documentation of the code. + +1. **Measurement integrity: established methods only, never a fudge.** Every change to the measurement + pipeline (ring detection, centre estimation, affine/robust fitting, correction math) must be an + established, published algorithm or a standard library primitive (OpenCV.js, ml-matrix), chosen because + it is the correct model for the problem, and named as such (e.g. "Taubin circle fit", "Huber + M-estimator", "Circle Hough Transform"). NEVER introduce a hand-tuned constant, empirical offset, axis + "nudge", or bias correction fitted to make one particular scan's numbers look right: that overfits the + sample and lies on the next one. Before trusting a pipeline change, validate it against the synthetic + `TestData_2solid.png` fixture: it must not regress there, and only then judge it on real scans. + +2. **No silently swallowed errors.** A `catch` must do something meaningful: surface the error to the user, + rethrow, or return a value the caller can act on. Never leave an empty `catch`. The engine throws typed + errors (`ScanAnalysisError` carries the detected rings) so the UI can explain a failed scan; keep that + contract. + +3. **Keep the engine framework-agnostic and modular.** Code in `web/src/engine/` must not import Vue, Pinia, + or touch the DOM beyond what OpenCV.js needs; the UI, the worker, and the tests all import it directly. A + new CV stage, output flavour, or scanner source should be added as its own module, not by editing + unrelated ones. + +4. **Limited AI attribution in git/GitHub.** A `Co-Authored-By: Claude <...>` trailer IS allowed on commits. + Beyond that trailer, no AI attribution anywhere: no "Generated with Claude Code" (or any similar + "made/assisted by AI") line, and no AI tool name in the commit subject or body, PR titles or descriptions, + issue/PR comments, tags, or release notes. Keep commit messages to a single short sentence (a concise + subject line, no body). + +5. **Self-review before handoff: multi-file changes only.** Before presenting a multi-file change for commit + approval, run a medium-effort `/code-review` scoped to the change. Every finding it surfaces requires a + logged disposition: paste the finder list verbatim and mark each one **Fixed** (name the commit that + resolves it), **False positive** (the finding is factually wrong, proven with the quoted line that + refutes it), or **Owner-waived** (you flagged it to the owner and they chose not to fix it). "Pre-existing," "out of scope," "low value," or "cosmetic" are not valid reasons to silently drop a - finding — skipping a correct finding is the owner's decision, never yours. - -7. **Measurement integrity — established methods only, never a fudge.** Every change to the - measurement pipeline (ring detection, centre estimation, affine/robust fitting, correction math) - must be an established, published algorithm or a standard library primitive (OpenCV, MathNet), - chosen because it is the correct model for the problem — and named as such (e.g. "Taubin circle - fit", "Huber M-estimator", "Circle Hough Transform"). NEVER introduce a hand-tuned constant, - empirical offset, axis "nudge", or bias correction fitted to make one particular scan's numbers - look right: that overfits the sample and lies on the next one. Before trusting a pipeline change, - validate it against the synthetic fixture — it must not regress there — and only then judge it on - real scans. - -8. **No direct commits to `master` — every change ships as a pull request.** Committing or pushing - straight to `master` is forbidden. Every change goes on its own branch and lands through a PR - opened with `gh`. **The PR title must read as a release note** — a single user-facing sentence. - Owner review gates everything: never create the branch's commit, push, or open the PR until the - owner has explicitly approved the change in chat — present the diff summary and ask, and proceed - only after a clear "yes". Rule 5 (no AI attribution anywhere that touches git or GitHub) applies - without exception to the branch, the commits, the PR, and every `gh`/GitHub API action. - -9. **Release notes come from labeled PRs — label every user-facing PR.** Notes are generated from PR - titles via `.github/release.yml`: a PR appears only if it carries `feature` (→ Features) or - `fix`/`bug` (→ Fixes). Label any user-facing change accordingly (pairs with rule 8 — the PR title - is the release-note sentence); leave CI/chore/docs-only PRs unlabeled. - -10. **Never use the em-dash character `—`, and never use a hyphen `-` as a substitute for it.** The - em-dash is banned everywhere you write: source, comments, docs, UI text, commit messages, PR - titles and bodies, issue/PR comments, and chat replies. Do not swap in a hyphen `-` to get the - same dash-like pause either. Rewrite the sentence: use a colon, parentheses, a comma, or two - separate sentences. A hyphen is allowed ONLY where grammar genuinely requires one, such as a - compound modifier ("sub-pixel", "user-facing") or a hyphenated name. - -11. **Mobile / WebAssembly usability: the web app must stay usable on a phone, not just the desktop.** - Two browser constraints have already cost real debugging time; do not reintroduce either. - - **No free-text inputs.** The Android browser soft keyboard cannot commit typed characters into an - Avalonia `TextBox` (you can delete but not type; framework bugs AvaloniaUI/Avalonia#11662 and #11665, - still unfixed as of Avalonia 12.0.x). Every user-entered value uses a `NumericUpDown` stepper, or another - control that needs no keyboard. Do not add a `TextBox` for real input to the shared UI. Give steppers a - sensible per-field increment and floor, and no `Maximum` (never cap what the user may enter). - - **Large file loads must show progress and must not exhaust memory.** A real scan is 30+ MB and 35+ MP. - Decode previews downscaled with `Bitmap.DecodeToWidth`, never `new Bitmap(stream)`: a full-resolution - decode allocates well over 100 MB against the 256 MB wasm heap and intermittently runs out of memory (the - original "upload takes minutes, works after a few tries"). The slow part of a load is the file read - itself, because Avalonia marshals the bytes one at a time across the JS boundary, so turn the slot busy - indicator on before the read, not just the decode. Every file-ingest path (each upload slot and page) - must do both. The proper cure for the slow read is a bulk browser-side read, still to be done. + finding: skipping a correct finding is the owner's decision, never yours. + +6. **Get owner approval before committing or pushing.** Never run `git commit` or `git push` (or open a PR) + until the owner has explicitly approved the change in chat: present the diff summary and ask, and proceed + only after a clear "yes". Committing straight to `master` is fine (Pages auto-deploys on push, so there + is no PR or release ceremony required), but the approval gate applies to every commit and push without + exception. + +7. **Never use the em-dash character `—`, and never use a hyphen `-` as a substitute for it.** The em-dash + is banned everywhere you write: source, comments, docs, UI text, commit messages, PR titles and bodies, + issue/PR comments, and chat replies. Do not swap in a hyphen `-` to get the same dash-like pause either. + Rewrite the sentence: use a colon, parentheses, a comma, or two separate sentences. A hyphen is allowed + ONLY where grammar genuinely requires one, such as a compound modifier ("sub-pixel", "user-facing") or a + hyphenated name. diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index d3196c6..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - diff --git a/README.md b/README.md index 56be66d..5ae1c77 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # ScanNTune -[![Build](https://github.com/jaak0b/ScanNTune/actions/workflows/build.yml/badge.svg)](https://github.com/jaak0b/ScanNTune/actions/workflows/build.yml) -[![Latest release](https://img.shields.io/github/v/release/jaak0b/ScanNTune?display_name=tag)](https://github.com/jaak0b/ScanNTune/releases/latest) -[![Downloads](https://img.shields.io/github/downloads/jaak0b/ScanNTune/total)](https://github.com/jaak0b/ScanNTune/releases) -![Platform: Windows](https://img.shields.io/badge/platform-Windows-blue) +[![Web CI](https://github.com/jaak0b/ScanNTune/actions/workflows/web-ci.yml/badge.svg)](https://github.com/jaak0b/ScanNTune/actions/workflows/web-ci.yml) [![License: MIT](https://img.shields.io/github/license/jaak0b/ScanNTune)](LICENSE) **Calibrate your 3D printer's XY scale and skew by scanning a printed coupon on an ordinary office scanner. @@ -12,6 +9,14 @@ No calipers, no measuring, no typing numbers into a calculator.** The result is a ready-to-paste correction for your firmware or slicer, worked out from a flat scan of a printed coupon. +
+ +## ▶ [Open ScanNTune in your browser](https://jaak0b.github.io/ScanNTune/) + +**Runs entirely in your browser. Nothing to install, nothing uploaded to a server, on desktop or phone.** + +
+ ![ScanNTune results](img/ScanNTune_Results.png) > [!TIP] @@ -29,12 +34,6 @@ printed coupon. That's it. The whole thing takes a couple of minutes once the coupon is printed. -## Installation - -ScanNTune runs on Windows. Grab the latest version from the -[**Releases page**](https://github.com/jaak0b/ScanNTune/releases). It keeps itself up to date after that, -applying new versions quietly the next time you open it. - --- ## Just as good as measuring it by hand @@ -66,13 +65,21 @@ from a single scan instead of by hand with a caliper. standard plastic card instead (all cards are ISO/IEC 7810 ID-1, 85.60 by 53.98 mm) and reads the true pixels-per-millimetre from its edges. +The computer vision runs client-side in a Web Worker with [OpenCV.js](https://docs.opencv.org/), so a full +scan is analysed on your own machine without the page ever freezing. + ## Building from source -If you'd rather build it yourself, you'll need the .NET 10 SDK on Windows. +The app is a plain [Vue 3](https://vuejs.org/) + TypeScript + [Vite](https://vite.dev/) project under +[`web/`](web). You'll need [Node.js](https://nodejs.org/) 22 or newer. -```powershell -dotnet build src\ScanNTune.slnx -dotnet run --project src\ScanNTune.App +```bash +cd web +npm install +npm run dev # dev server at http://localhost:5173/ScanNTune/ +npm run build # production build to web/dist +npm test # Vitest unit + fixture-backed engine tests +npm run e2e # Playwright end-to-end over real scans ``` Want a different coupon size or grid? Edit [`calibration_coupon.scad`](calibration_coupon.scad) in OpenSCAD diff --git a/build.ps1 b/build.ps1 deleted file mode 100644 index 5383760..0000000 --- a/build.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -$BuildProjectFile = "$PSScriptRoot/build/_build.csproj" -$env:NUKE_ROOT = $PSScriptRoot -dotnet run --project $BuildProjectFile -- @args diff --git a/build/Build.cs b/build/Build.cs deleted file mode 100644 index 653852b..0000000 --- a/build/Build.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.Json; -using Nuke.Common; -using Nuke.Common.IO; -using Nuke.Common.Tooling; -using Nuke.Common.Tools.DotNet; -using Serilog; -using static Nuke.Common.Tools.DotNet.DotNetTasks; -using static Nuke.Common.Tools.Git.GitTasks; - -class Build : NukeBuild -{ - public static int Main() => Execute(x => x.Test); - - [Parameter("Configuration to build — default is 'Debug' (local) or 'Release' (server)")] - readonly string Configuration = IsLocalBuild ? "Debug" : "Release"; - - AbsolutePath SourceRoot => RootDirectory / "src"; - AbsolutePath Solution => SourceRoot / "ScanNTune.slnx"; - AbsolutePath TestProject => SourceRoot / "ScanNTune.Tests" / "ScanNTune.Tests.csproj"; - AbsolutePath DesktopProject => SourceRoot / "ScanNTune.App" / "ScanNTune.App.csproj"; - AbsolutePath AppIcon => SourceRoot / "ScanNTune.App" / "Assets" / "ScanNTune.ico"; - AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts"; - AbsolutePath DesktopPublishDirectory => ArtifactsDirectory / "desktop"; - AbsolutePath VelopackDirectory => ArtifactsDirectory / "velopack"; - - const string GitHubRepoUrl = "https://github.com/jaak0b/ScanNTune"; - - [Parameter("GitHub token for publishing releases. Defaults to the GH_TOKEN or GITHUB_TOKEN environment variable.")] - readonly string GitHubToken = Environment.GetEnvironmentVariable("GH_TOKEN") - ?? Environment.GetEnvironmentVariable("GITHUB_TOKEN"); - - Target Restore => _ => _ - .Executes(() => DotNetRestore(s => s.SetProjectFile(Solution))); - - Target Compile => _ => _ - .DependsOn(Restore) - .Executes(() => DotNetBuild(s => s - .SetProjectFile(Solution) - .SetConfiguration(Configuration) - .EnableNoRestore())); - - Target Test => _ => _ - .DependsOn(Compile) - .Executes(() => DotNetTest(s => s - .SetProjectFile(TestProject) - .SetConfiguration(Configuration) - .EnableNoRestore() - .EnableNoBuild())); - - Target RunDesktop => _ => _ - .DependsOn(Compile) - .Executes(() => - { - Assert.True(EnvironmentInfo.IsWin, "RunDesktop targets the Windows desktop head."); - DotNetRun(s => s - .SetProjectFile(DesktopProject) - .SetConfiguration(Configuration) - .EnableNoRestore()); - }); - - Target PublishDesktop => _ => _ - .Description("Publishes the Windows desktop head self-contained (win-x64) for packaging.") - .Executes(() => - { - DesktopPublishDirectory.CreateOrCleanDirectory(); - DotNetPublish(s => s - .SetProject(DesktopProject) - .SetConfiguration("Release") - .SetRuntime("win-x64") - .SetSelfContained(true) - .SetOutput(DesktopPublishDirectory)); - }); - - AbsolutePath PortableZip(string version) => ArtifactsDirectory / $"ScanNTune-{version}-win-x64-portable.zip"; - - Target PackPortable => _ => _ - .Description("Zips the self-contained desktop publish into a portable, no-install archive.") - .DependsOn(PublishDesktop) - .Executes(() => - { - var zip = PortableZip(ReleaseVersion()); - zip.DeleteFile(); - DesktopPublishDirectory.ZipTo(zip); - Log.Information("Portable zip → {Zip}", zip); - }); - - Target Pack => _ => _ - .Description("Builds the Velopack Windows installer and update feed into artifacts/velopack.") - .DependsOn(PublishDesktop) - .Executes(() => - { - VelopackDirectory.CreateOrCleanDirectory(); - var notesFile = WriteReleaseNotes(); - - Vpk("pack" - + " --packId ScanNTune" - + " --packTitle \"ScanNTune\"" - + " --packAuthors Jakob" - + $" --packVersion {ReleaseVersion()}" - + $" --packDir \"{DesktopPublishDirectory}\"" - + " --mainExe ScanNTune.App.exe" - + $" --icon \"{AppIcon}\"" - + $" --releaseNotes \"{notesFile}\"" - + $" --outputDir \"{VelopackDirectory}\""); - - Log.Information("Velopack output → {Dir}", VelopackDirectory); - }); - - Target Release => _ => _ - .Description("Publishes a GitHub release: the Windows installer + update feed + portable zip, with notes built from PR labels.") - .DependsOn(Test, Pack, PackPortable) - .Requires(() => GitHubToken) - .Executes(() => - { - var version = ReleaseVersion(); - var tag = $"v{version}"; - var releaseName = $"ScanNTune {version}"; - - Vpk("upload github" - + $" --repoUrl {GitHubRepoUrl}" - + $" --token {GitHubToken}" - + " --publish" - + $" --releaseName \"{releaseName}\"" - + $" --tag {tag}" - + $" --outputDir \"{VelopackDirectory}\"", - logInvocation: false); - - Gh($"release upload {tag} \"{PortableZip(version)}\" --clobber"); - - Log.Information("Released {Tag}: installer + update feed + portable zip", tag); - }); - - void Gh(string arguments) - { - var gh = ToolPathResolver.GetPathExecutable("gh"); - ProcessTasks.StartProcess(gh, arguments, RootDirectory, GitHubEnvironment(), logInvocation: false) - .AssertZeroExitCode(); - } - - void Vpk(string arguments, bool logInvocation = true) - { - var dotnet = ToolPathResolver.GetPathExecutable("dotnet"); - ProcessTasks.StartProcess(dotnet, "vpk " + arguments, RootDirectory, logInvocation: logInvocation) - .AssertZeroExitCode(); - } - - string ReleaseVersion() - { - var dotnet = ToolPathResolver.GetPathExecutable("dotnet"); - var process = ProcessTasks.StartProcess(dotnet, - "nbgv get-version --variable SimpleVersion", RootDirectory, logOutput: false); - process.AssertZeroExitCode(); - return process.Output - .Where(o => o.Type == OutputType.Std) - .Select(o => o.Text.Trim()) - .First(t => t.Length > 0); - } - - AbsolutePath WriteReleaseNotes() - { - var body = NotesFromPullRequests(); - var notesFile = ArtifactsDirectory / "release-notes.md"; - ArtifactsDirectory.CreateDirectory(); - notesFile.WriteAllText(body); - return notesFile; - } - - // Release notes come ONLY from the labelled pull requests (via GitHub's generate-notes, driven by - // .github/release.yml). There is deliberately no commit-message fallback: if generate-notes fails or - // returns nothing, we throw and cancel the release rather than ship low-quality notes. - string NotesFromPullRequests() - { - // Release .Requires the token; a local Pack without it is a dev build, not a release. - if (string.IsNullOrEmpty(GitHubToken)) - return "Local build — not an official release."; - - var headSha = GitLines("rev-parse HEAD").FirstOrDefault()?.Trim(); - var previousTag = GitLines("tag --list v* --sort=-version:refname").FirstOrDefault()?.Trim(); - - var arguments = new StringBuilder($"api repos/{GitHubRepoSlug}/releases/generate-notes") - .Append($" -f tag_name=v{ReleaseVersion()}"); - if (!string.IsNullOrEmpty(headSha)) - arguments.Append($" -f target_commitish={headSha}"); - if (!string.IsNullOrEmpty(previousTag)) - arguments.Append($" -f previous_tag_name={previousTag}"); - - var gh = ToolPathResolver.GetPathExecutable("gh"); - var process = ProcessTasks.StartProcess(gh, arguments.ToString(), RootDirectory, GitHubEnvironment(), logOutput: false); - process.AssertZeroExitCode(); - - var json = string.Join(Environment.NewLine, - process.Output.Where(o => o.Type == OutputType.Std).Select(o => o.Text)); - using var document = JsonDocument.Parse(json); - var body = document.RootElement.GetProperty("body").GetString(); - Assert.True(!string.IsNullOrWhiteSpace(body), - "GitHub generate-notes returned an empty release body — aborting. Label the user-facing PRs (feature/fix/bug) and retry."); - return body!.Trim(); - } - - IEnumerable GitLines(string arguments) => - Git(arguments, workingDirectory: RootDirectory, logOutput: false) - .Where(o => o.Type == OutputType.Std) - .Select(o => o.Text); - - string GitHubRepoSlug => new Uri(GitHubRepoUrl).AbsolutePath.Trim('/'); - - IReadOnlyDictionary GitHubEnvironment() - { - var environment = Environment.GetEnvironmentVariables() - .Cast() - .ToDictionary(entry => (string)entry.Key, entry => (string)entry.Value); - environment["GH_TOKEN"] = GitHubToken; - return environment; - } -} diff --git a/build/_build.csproj b/build/_build.csproj deleted file mode 100644 index 654b70b..0000000 --- a/build/_build.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - Exe - net10.0 - enable - enable - latest - _build - false - 1 - false - - - - - diff --git a/nuget.config b/nuget.config deleted file mode 100644 index 765346e..0000000 --- a/nuget.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/ScanNTune.App/App.axaml b/src/ScanNTune.App/App.axaml deleted file mode 100644 index a15095a..0000000 --- a/src/ScanNTune.App/App.axaml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/ScanNTune.App/App.axaml.cs b/src/ScanNTune.App/App.axaml.cs deleted file mode 100644 index d8948bc..0000000 --- a/src/ScanNTune.App/App.axaml.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using Autofac; -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; -using Avalonia.Threading; -using Microsoft.Extensions.Logging; -using Serilog; -using Serilog.Extensions.Logging; -using ScanNTune.App.Views; -using ScanNTune.Core.Storage; -using ScanNTune.Core.Updates; -using ScanNTune.UI.DependencyInjection; -using ScanNTune.UI.ViewModels; - -namespace ScanNTune.App; - -public partial class App : Application -{ - // Bridge Serilog (configured in Program.Main) to ILogger for the whole UI layer. At design time - // Serilog's Log.Logger is a silent logger, so this is a no-op there. - private readonly ILoggerFactory _loggerFactory = new SerilogLoggerFactory(Log.Logger); - private IContainer? _container; - - public override void Initialize() => AvaloniaXamlLoader.Load(this); - - public override void OnFrameworkInitializationCompleted() - { - ILogger log = _loggerFactory.CreateLogger(); - - // Catch everything the try/catch in Program.Main can't: exceptions on other threads and faults in - // un-awaited tasks. Without these, a background crash would vanish with no trace. - AppDomain.CurrentDomain.UnhandledException += (_, e) => - log.LogCritical(e.ExceptionObject as Exception, "Unhandled AppDomain exception."); - TaskScheduler.UnobservedTaskException += (_, e) => - { - log.LogError(e.Exception, "Unobserved task exception."); - e.SetObserved(); - }; - - // Compose the shared UI with the desktop head's platform services through Autofac. - var builder = new ContainerBuilder(); - builder.RegisterModule(new UiModule()); - builder.RegisterModule(new AppModule(_loggerFactory)); - _container = builder.Build(); - - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - var mainViewModel = _container.Resolve(); - desktop.MainWindow = new MainWindow { DataContext = mainViewModel }; - - StartBackgroundUpdateCheck(mainViewModel); - } - - base.OnFrameworkInitializationCompleted(); - } - - // Best-effort, off the UI thread: check for an update and, if one is found, stage it to apply on the next - // launch (never mid-session), then surface a "restart to update" cue. No-ops in dev / non-installed runs. - private void StartBackgroundUpdateCheck(MainWindowViewModel mainViewModel) - { - ILogger updaterLog = _loggerFactory.CreateLogger(); - ILogger checkLog = _loggerFactory.CreateLogger(); - - string appDataDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ScanNTune"); - IKeyValueStore store = new JsonKeyValueStore(appDataDir, _loggerFactory.CreateLogger()); - - _ = Task.Run(async () => - { - UpdateOutcome outcome = await new UpdateCheck(() => new VelopackAppUpdater(updaterLog), store, checkLog).RunAsync(); - if (outcome == UpdateOutcome.UpdateStaged) - Dispatcher.UIThread.Post(mainViewModel.MarkUpdateReady); - }); - } -} diff --git a/src/ScanNTune.App/AppModule.cs b/src/ScanNTune.App/AppModule.cs deleted file mode 100644 index 98f7b73..0000000 --- a/src/ScanNTune.App/AppModule.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Autofac; -using Microsoft.Extensions.Logging; -using ScanNTune.App.Platform; -using ScanNTune.Core.Calibration; -using ScanNTune.UI.Platform; - -namespace ScanNTune.App; - -/// -/// The desktop head's platform registrations: OpenCV imaging, the Windows coupon export, the JSON-file -/// calibration store, and the Serilog-backed logger factory the whole app logs through. -/// -public sealed class AppModule : Module -{ - private readonly ILoggerFactory _loggerFactory; - - public AppModule(ILoggerFactory loggerFactory) => _loggerFactory = loggerFactory; - - protected override void Load(ContainerBuilder builder) - { - builder.RegisterInstance(_loggerFactory).As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.Register(c => new JsonCalibrationStore(null, c.Resolve>())) - .As().SingleInstance(); - } -} diff --git a/src/ScanNTune.App/Assets/ScanNTune.ico b/src/ScanNTune.App/Assets/ScanNTune.ico deleted file mode 100644 index 38f83a3..0000000 Binary files a/src/ScanNTune.App/Assets/ScanNTune.ico and /dev/null differ diff --git a/src/ScanNTune.App/Platform/AvaloniaFilePicker.cs b/src/ScanNTune.App/Platform/AvaloniaFilePicker.cs deleted file mode 100644 index 0d29f09..0000000 --- a/src/ScanNTune.App/Platform/AvaloniaFilePicker.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Platform.Storage; -using Microsoft.Extensions.Logging; -using ScanNTune.UI.Platform; - -namespace ScanNTune.App.Platform; - -/// Desktop file picking through the native OS open dialog on the main window. -public sealed class AvaloniaFilePicker : IFilePicker -{ - private readonly StorageFileReader _reader = new(); - private readonly ILogger _logger; - - public AvaloniaFilePicker(ILogger logger) => _logger = logger; - - public async Task PickImageAsync(string title) - { - Window? window = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; - IStorageProvider? storage = window?.StorageProvider; - if (storage is null) - { - _logger.LogWarning("No main window storage provider available to pick a file."); - return null; - } - - var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions - { - Title = title, - AllowMultiple = false, - FileTypeFilter = - [ - new FilePickerFileType("Images") - { - Patterns = ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.tif", "*.tiff"] - } - ] - }); - if (files.Count == 0) - return null; - - byte[] data = await _reader.ReadAllBytesAsync(files[0]); - return new PickedFile(files[0].Name, data); - } -} diff --git a/src/ScanNTune.App/Platform/DesktopDeviceInfo.cs b/src/ScanNTune.App/Platform/DesktopDeviceInfo.cs deleted file mode 100644 index 436d5eb..0000000 --- a/src/ScanNTune.App/Platform/DesktopDeviceInfo.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ScanNTune.UI.Platform; - -namespace ScanNTune.App.Platform; - -// The desktop app always has a physical keyboard, so text entry stays on everywhere. -internal sealed class DesktopDeviceInfo : IDeviceInfo -{ - public bool IsTouchPrimary => false; -} diff --git a/src/ScanNTune.App/Platform/OpenCvImaging.cs b/src/ScanNTune.App/Platform/OpenCvImaging.cs deleted file mode 100644 index 8726cb8..0000000 --- a/src/ScanNTune.App/Platform/OpenCvImaging.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using OpenCvSharp; -using ScanNTune.UI.Platform; - -namespace ScanNTune.App.Platform; - -/// Desktop imaging: OpenCV decodes images on its own. -public sealed class OpenCvImaging : IPlatformImaging -{ - public Mat DecodeBgr(byte[] data) - { - Mat image = Cv2.ImDecode(data, ImreadModes.Color); - if (image.Empty()) - { - image.Dispose(); - throw new InvalidOperationException("Could not decode the image."); - } - return image; - } - - public (int Width, int Height) GetImageSize(byte[] data) - { - // Desktop decodes natively and fast, so a full decode to read the size is fine here; there is no - // memory pressure to warrant a header-only path as there is in the browser. - using Mat image = Cv2.ImDecode(data, ImreadModes.Color); - if (image.Empty()) - throw new InvalidOperationException("Could not decode the image."); - return (image.Width, image.Height); - } -} diff --git a/src/ScanNTune.App/Platform/WindowsCouponExporter.cs b/src/ScanNTune.App/Platform/WindowsCouponExporter.cs deleted file mode 100644 index a8ee30c..0000000 --- a/src/ScanNTune.App/Platform/WindowsCouponExporter.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading.Tasks; -using Avalonia.Platform; -using Microsoft.Extensions.Logging; -using ScanNTune.UI.Platform; - -namespace ScanNTune.App.Platform; - -/// -/// Desktop coupon export: drop a fresh copy of the bundled STL in a temp folder (so the original stays -/// pristine) and pop the Windows "Open with…" chooser so the user can send it to their slicer. -/// -public sealed class WindowsCouponExporter : ICouponExporter -{ - private readonly ILogger _logger; - - public WindowsCouponExporter(ILogger logger) => _logger = logger; - - public Task ExportAsync() - { - try - { - string dir = Path.Combine(Path.GetTempPath(), "ScanNTune"); - Directory.CreateDirectory(dir); - string dest = Path.Combine(dir, "calibration_coupon.stl"); - - using (Stream source = AssetLoader.Open(new Uri("avares://ScanNTune.UI/Assets/calibration_coupon.stl"))) - using (FileStream file = File.Create(dest)) - source.CopyTo(file); - - // OpenAs_RunDLL forces the "Open with..." chooser regardless of file associations. The - // ShellExecute "openas" verb instead fails with "no application associated" for a type (like - // .stl) that has no default handler. It reads the rest of the command line as the path, which a - // space would split, so pass the 8.3 short path when the temp path happens to contain a space. - string arg = dest.Contains(' ') ? ShortPath(dest) : dest; - Process.Start(new ProcessStartInfo("rundll32.exe") - { - Arguments = $"shell32.dll,OpenAs_RunDLL {arg}", - UseShellExecute = false, - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not export/open the coupon STL."); - throw; - } - - return Task.CompletedTask; - } - - // Win32 8.3 short path (e.g. C:\Users\FIRSTL~1\...): a space-free form of an existing path, so the - // space-delimited OpenAs_RunDLL argument can't be truncated. Falls back to the original path if the - // short name is unavailable (8.3 generation disabled on the volume). - private string ShortPath(string path) - { - var buffer = new StringBuilder(260); - uint length = GetShortPathName(path, buffer, (uint)buffer.Capacity); - return length > 0 && length < buffer.Capacity ? buffer.ToString() : path; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern uint GetShortPathName(string lpszLongPath, StringBuilder lpszShortPath, uint cchBuffer); -} diff --git a/src/ScanNTune.App/Program.cs b/src/ScanNTune.App/Program.cs deleted file mode 100644 index 67f19b4..0000000 --- a/src/ScanNTune.App/Program.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Avalonia; -using System; -using System.IO; -using Serilog; -using Serilog.Extensions.Logging; -using Velopack; - -namespace ScanNTune.App; - -sealed class Program -{ - // Initialization code. Don't use any Avalonia, third-party APIs or any - // SynchronizationContext-reliant code before AppMain is called: things aren't initialized - // yet and stuff might break. - [STAThread] - public static void Main(string[] args) - { - string logDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ScanNTune", "logs"); - Directory.CreateDirectory(logDir); - - // Serilog is the single logging backend for the whole app: rolling daily file plus the debug - // output. App.axaml.cs bridges it to ILogger and wires the global exception handlers. - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Debug() - .Enrich.FromLogContext() - .WriteTo.Debug() - .WriteTo.File( - Path.Combine(logDir, "scanntune-.log"), - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: 7, - outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}") - .CreateLogger(); - - try - { - Log.Information("ScanNTune starting."); - - // Must run first: handles Velopack's install/update/uninstall hooks (may exit the process). - VelopackApp.Build() - .SetLogger(new VelopackLoggerAdapter(new SerilogLoggerFactory(Log.Logger).CreateLogger("Velopack"))) - .Run(); - - BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); - } - catch (Exception ex) - { - Log.Fatal(ex, "ScanNTune terminated unexpectedly."); - } - finally - { - Log.CloseAndFlush(); - } - } - - // Avalonia configuration, don't remove; also used by visual designer. - public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure() - .UsePlatformDetect() -#if DEBUG - .WithDeveloperTools() -#endif - .WithInterFont() - .LogToTrace(); -} diff --git a/src/ScanNTune.App/ScanNTune.App.csproj b/src/ScanNTune.App/ScanNTune.App.csproj deleted file mode 100644 index 00a2e47..0000000 --- a/src/ScanNTune.App/ScanNTune.App.csproj +++ /dev/null @@ -1,38 +0,0 @@ - - - WinExe - net10.0 - enable - app.manifest - Assets\ScanNTune.ico - true - - - - - - - - - - - - - - None - All - - - - - - - - - - - - - - - diff --git a/src/ScanNTune.App/VelopackAppUpdater.cs b/src/ScanNTune.App/VelopackAppUpdater.cs deleted file mode 100644 index 9740977..0000000 --- a/src/ScanNTune.App/VelopackAppUpdater.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using ScanNTune.Core.Updates; -using Velopack; -using Velopack.Locators; -using Velopack.Sources; - -namespace ScanNTune.App; - -/// -/// Velopack-backed updater against the GitHub release feed. The staged update is applied silently after the -/// user closes the app — restart: false means it lands on the next launch, never interrupting a session. -/// Outside an installed build (e.g. dev runs) is false and it no-ops. -/// The Velopack is given the same Serilog-backed logger as the rest of the app. -/// -public sealed class VelopackAppUpdater : IAppUpdater -{ - private readonly UpdateManager _manager; - private readonly ILogger _logger; - private UpdateInfo? _pending; - - public VelopackAppUpdater(ILogger logger) - { - _logger = logger; - // Velopack takes its logger via the locator (its own IVelopackLogger); bridge it to our Serilog log. - IVelopackLocator locator = VelopackLocator.CreateDefaultForPlatform(logger: new VelopackLoggerAdapter(logger)); - _manager = new UpdateManager(new GithubSource("https://github.com/jaak0b/ScanNTune", null, false), null, locator); - } - - public async Task CheckForUpdateAsync() - { - if (!_manager.IsInstalled) - { - _logger.LogInformation("Not an installed build; skipping the update check."); - return false; - } - - _pending = await _manager.CheckForUpdatesAsync().ConfigureAwait(false); - _logger.LogInformation("Update check from {Version}: {Result}.", - _manager.CurrentVersion, - _pending is null ? "up to date" : $"{_pending.TargetFullRelease.Version} available"); - return _pending is not null; - } - - public async Task DownloadUpdateAsync() - { - if (_pending is null) - return; - - _logger.LogInformation("Downloading update {Version}…", _pending.TargetFullRelease.Version); - await _manager.DownloadUpdatesAsync(_pending).ConfigureAwait(false); - _logger.LogInformation("Update {Version} downloaded.", _pending.TargetFullRelease.Version); - } - - public void ApplyUpdateOnExit() - { - if (_pending is null) - return; - - _logger.LogInformation("Staging update {Version} to apply on exit.", _pending.TargetFullRelease.Version); - _manager.WaitExitThenApplyUpdates(_pending.TargetFullRelease, silent: true, restart: false); - } -} diff --git a/src/ScanNTune.App/VelopackLoggerAdapter.cs b/src/ScanNTune.App/VelopackLoggerAdapter.cs deleted file mode 100644 index 2546a2c..0000000 --- a/src/ScanNTune.App/VelopackLoggerAdapter.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; -using Velopack.Logging; - -namespace ScanNTune.App; - -/// -/// Bridges Velopack's own onto the app's Serilog-backed -/// , so update-engine diagnostics land in the same log file as everything else. -/// The two level enums line up one-to-one. -/// -public sealed class VelopackLoggerAdapter : IVelopackLogger -{ - private readonly ILogger _logger; - - public VelopackLoggerAdapter(ILogger logger) => _logger = logger; - - public void Log(VelopackLogLevel logLevel, string? message, Exception? exception) - => _logger.Log(ToMicrosoft(logLevel), exception, "{VelopackMessage}", message); - - private LogLevel ToMicrosoft(VelopackLogLevel level) => level switch - { - VelopackLogLevel.Trace => LogLevel.Trace, - VelopackLogLevel.Debug => LogLevel.Debug, - VelopackLogLevel.Information => LogLevel.Information, - VelopackLogLevel.Warning => LogLevel.Warning, - VelopackLogLevel.Error => LogLevel.Error, - VelopackLogLevel.Critical => LogLevel.Critical, - _ => LogLevel.Information, - }; -} diff --git a/src/ScanNTune.App/Views/MainWindow.axaml b/src/ScanNTune.App/Views/MainWindow.axaml deleted file mode 100644 index 0d1a9fc..0000000 --- a/src/ScanNTune.App/Views/MainWindow.axaml +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ScanNTune.App/Views/MainWindow.axaml.cs b/src/ScanNTune.App/Views/MainWindow.axaml.cs deleted file mode 100644 index 2d1b177..0000000 --- a/src/ScanNTune.App/Views/MainWindow.axaml.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Interactivity; -using Avalonia.Media; -using Serilog; - -namespace ScanNTune.App.Views; - -public partial class MainWindow : Window -{ - public const string RepositoryUrl = "https://github.com/jaak0b/ScanNTune"; - - // The maximize button swaps between these two glyphs as the window state changes. - private readonly Geometry _maximizeIcon = Geometry.Parse("M0,0 H10 V10 H0 Z"); - private readonly Geometry _restoreIcon = Geometry.Parse("M0,3 H7 V10 H0 Z M3,3 V0 H10 V7 H7"); - - public MainWindow() - { - InitializeComponent(); - - // Drop the native title bar + caption buttons (Avalonia 12 only draws them for - // WindowDecorations.Full) but keep the resizable border, then extend our own chrome over the top. - WindowDecorations = WindowDecorations.BorderOnly; - ExtendClientAreaToDecorationsHint = true; - } - - private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e) - { - if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) - BeginMoveDrag(e); - } - - private void OnTitleBarDoubleTapped(object? sender, TappedEventArgs e) => ToggleMaximize(); - - private void OnMinimize(object? sender, RoutedEventArgs e) => WindowState = WindowState.Minimized; - - private void OnMaximizeRestore(object? sender, RoutedEventArgs e) => ToggleMaximize(); - - private void OnClose(object? sender, RoutedEventArgs e) => Close(); - - private async void OnOpenRepository(object? sender, RoutedEventArgs e) - { - try - { - await Launcher.LaunchUriAsync(new Uri(RepositoryUrl)); - } - catch (Exception ex) - { - Log.ForContext().Error(ex, "Could not open the repository URL {Url}.", RepositoryUrl); - } - } - - private void ToggleMaximize() - => WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - if (change.Property == WindowStateProperty) - MaxIcon.Data = WindowState == WindowState.Maximized ? _restoreIcon : _maximizeIcon; - } -} diff --git a/src/ScanNTune.App/app.manifest b/src/ScanNTune.App/app.manifest deleted file mode 100644 index 00961ff..0000000 --- a/src/ScanNTune.App/app.manifest +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/ScanNTune.Browser/App.axaml b/src/ScanNTune.Browser/App.axaml deleted file mode 100644 index 3c0d58c..0000000 --- a/src/ScanNTune.Browser/App.axaml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/src/ScanNTune.Browser/App.axaml.cs b/src/ScanNTune.Browser/App.axaml.cs deleted file mode 100644 index eaa8b75..0000000 --- a/src/ScanNTune.Browser/App.axaml.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Autofac; -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Markup.Xaml; -using Microsoft.Extensions.Logging; -using ScanNTune.UI.DependencyInjection; -using ScanNTune.UI.ViewModels; - -namespace ScanNTune.Browser; - -public partial class App : Application -{ - // The browser has no filesystem, so log to the devtools console through a thread-free provider - // (the framework console logger uses a background thread, unsupported in single-threaded wasm). - // There is no Velopack update check here: the web app updates when the page is redeployed. - private readonly ILoggerFactory _loggerFactory = - LoggerFactory.Create(b => b.AddProvider(new BrowserConsoleLoggerProvider()).SetMinimumLevel(LogLevel.Information)); - private IContainer? _container; - - public override void Initialize() => AvaloniaXamlLoader.Load(this); - - public override void OnFrameworkInitializationCompleted() - { - // Compose the shared UI with the WebAssembly head's platform services through Autofac. - var builder = new ContainerBuilder(); - builder.RegisterModule(new UiModule()); - builder.RegisterModule(new BrowserModule(_loggerFactory)); - _container = builder.Build(); - - if (ApplicationLifetime is ISingleViewApplicationLifetime single) - single.MainView = new MainView { DataContext = _container.Resolve() }; - - base.OnFrameworkInitializationCompleted(); - } -} diff --git a/src/ScanNTune.Browser/BrowserConsoleLogger.cs b/src/ScanNTune.Browser/BrowserConsoleLogger.cs deleted file mode 100644 index 7cb3e20..0000000 --- a/src/ScanNTune.Browser/BrowserConsoleLogger.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; - -namespace ScanNTune.Browser; - -/// -/// A minimal, synchronous that writes to the browser devtools console via -/// . The framework console logger spins up a background processing thread, which -/// single-threaded WebAssembly does not support, so we use this instead. -/// -public sealed class BrowserConsoleLoggerProvider : ILoggerProvider -{ - public ILogger CreateLogger(string categoryName) => new BrowserConsoleLogger(categoryName); - - public void Dispose() - { - } - - private sealed class BrowserConsoleLogger : ILogger - { - private readonly string _category; - - public BrowserConsoleLogger(string category) => _category = category; - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - if (!IsEnabled(logLevel)) - return; - string message = formatter(state, exception); - Console.WriteLine(exception is null - ? $"[{logLevel}] {_category}: {message}" - : $"[{logLevel}] {_category}: {message} {exception.GetType().Name}: {exception.Message}"); - } - } -} diff --git a/src/ScanNTune.Browser/BrowserModule.cs b/src/ScanNTune.Browser/BrowserModule.cs deleted file mode 100644 index e4e51bf..0000000 --- a/src/ScanNTune.Browser/BrowserModule.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Autofac; -using Microsoft.Extensions.Logging; -using ScanNTune.Browser.Platform; -using ScanNTune.Core.Calibration; -using ScanNTune.UI.Platform; - -namespace ScanNTune.Browser; - -/// -/// The WebAssembly head's platform registrations: Skia imaging, the coupon download, and the localStorage-backed -/// calibration store, plus a console-backed logger factory (browser devtools console). -/// -public sealed class BrowserModule : Module -{ - private readonly ILoggerFactory _loggerFactory; - - public BrowserModule(ILoggerFactory loggerFactory) => _loggerFactory = loggerFactory; - - protected override void Load(ContainerBuilder builder) - { - builder.RegisterInstance(_loggerFactory).As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - } -} diff --git a/src/ScanNTune.Browser/MainView.axaml b/src/ScanNTune.Browser/MainView.axaml deleted file mode 100644 index 63a9cbb..0000000 --- a/src/ScanNTune.Browser/MainView.axaml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/ScanNTune.Browser/MainView.axaml.cs b/src/ScanNTune.Browser/MainView.axaml.cs deleted file mode 100644 index 46bc4a6..0000000 --- a/src/ScanNTune.Browser/MainView.axaml.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using ScanNTune.Browser.Platform; - -namespace ScanNTune.Browser; - -public partial class MainView : UserControl -{ - public MainView() => AvaloniaXamlLoader.Load(this); - - private void OnOpenRepository(object? sender, RoutedEventArgs e) - => BrowserInterop.OpenUrl("https://github.com/jaak0b/ScanNTune"); -} diff --git a/src/ScanNTune.Browser/Platform/BrowserCouponExporter.cs b/src/ScanNTune.Browser/Platform/BrowserCouponExporter.cs deleted file mode 100644 index b1e8e00..0000000 --- a/src/ScanNTune.Browser/Platform/BrowserCouponExporter.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using Avalonia.Platform; -using ScanNTune.UI.Platform; - -namespace ScanNTune.Browser.Platform; - -/// -/// Browser coupon export: read the bundled STL asset and trigger a browser download, so the user gets the -/// file directly rather than viewing it (the desktop head instead opens the OS "Open with" chooser). -/// -public sealed class BrowserCouponExporter : ICouponExporter -{ - public Task ExportAsync() - { - using Stream stream = AssetLoader.Open(new Uri("avares://ScanNTune.UI/Assets/calibration_coupon.stl")); - using var buffer = new MemoryStream(); - stream.CopyTo(buffer); - BrowserInterop.DownloadFile("calibration_coupon.stl", Convert.ToBase64String(buffer.ToArray()), "model/stl"); - return Task.CompletedTask; - } -} diff --git a/src/ScanNTune.Browser/Platform/BrowserDeviceInfo.cs b/src/ScanNTune.Browser/Platform/BrowserDeviceInfo.cs deleted file mode 100644 index e98b389..0000000 --- a/src/ScanNTune.Browser/Platform/BrowserDeviceInfo.cs +++ /dev/null @@ -1,16 +0,0 @@ -using ScanNTune.UI.Platform; - -namespace ScanNTune.Browser.Platform; - -/// -/// Reports whether the browser's primary pointer is a touch screen. A phone or tablet matches the CSS -/// (pointer: coarse) query; a desktop browser driven by a mouse or trackpad does not. The value is read -/// lazily on first use (so the interop module is loaded by then) and cached, since the primary pointer does -/// not change within a session. -/// -internal sealed class BrowserDeviceInfo : IDeviceInfo -{ - private bool? _isTouchPrimary; - - public bool IsTouchPrimary => _isTouchPrimary ??= BrowserInterop.IsTouchPrimary(); -} diff --git a/src/ScanNTune.Browser/Platform/BrowserFilePicker.cs b/src/ScanNTune.Browser/Platform/BrowserFilePicker.cs deleted file mode 100644 index a42549c..0000000 --- a/src/ScanNTune.Browser/Platform/BrowserFilePicker.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Globalization; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using ScanNTune.UI.Platform; - -namespace ScanNTune.Browser.Platform; - -/// -/// Browser file picking through a real, directly-tapped <input type=file> in a sheet (see -/// interop.js). iOS Safari only opens a file dialog from a genuine DOM gesture, so we own the input instead -/// of Avalonia's picker (which awaits before the click and loses the gesture on iOS). The bytes come back in -/// one bulk copy, which is also far faster than Avalonia's per-byte marshalling of a large scan. -/// -public sealed class BrowserFilePicker : IFilePicker -{ - private readonly ILogger _logger; - - public BrowserFilePicker(ILogger logger) => _logger = logger; - - public async Task PickImageAsync(string title) - { - // The JS side returns "name\nlength" once a file is chosen, or null on cancel. The bytes then come - // across in one MemoryView copy rather than element by element. - string? meta = await BrowserInterop.PickImageFile(title); - if (meta is null) - return null; - - int separator = meta.LastIndexOf('\n'); - if (separator < 0 - || !int.TryParse(meta.AsSpan(separator + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out int length) - || length < 0) - { - _logger.LogWarning("File pick returned malformed metadata."); - BrowserInterop.ClearPickedBytes(); // drop the held bytes we will not read - return null; - } - - string name = meta[..separator]; - var data = new byte[length]; - if (length > 0) - BrowserInterop.CopyPickedBytes(data); - return new PickedFile(name, data); - } -} diff --git a/src/ScanNTune.Browser/Platform/BrowserInterop.cs b/src/ScanNTune.Browser/Platform/BrowserInterop.cs deleted file mode 100644 index c98d4e3..0000000 --- a/src/ScanNTune.Browser/Platform/BrowserInterop.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Runtime.InteropServices.JavaScript; -using System.Threading.Tasks; - -namespace ScanNTune.Browser.Platform; - -/// -/// Bindings to the "interop" JS module (wwwroot/interop.js). The wrappers exist because localStorage and -/// window.open need their own this; calling them detached via a bare global path throws. The module -/// is loaded once at startup via . -/// -internal static partial class BrowserInterop -{ - internal const string ModuleName = "interop"; - // Resolved relative to _framework/, so step up to the site root where wwwroot/interop.js is served. - // "../interop.js" works both locally and under a GitHub Pages sub-path base href. - internal const string ModulePath = "../interop.js"; - - [JSImport("getItem", ModuleName)] - internal static partial string? GetItem(string key); - - [JSImport("setItem", ModuleName)] - internal static partial void SetItem(string key, string value); - - [JSImport("removeItem", ModuleName)] - internal static partial void RemoveItem(string key); - - [JSImport("openUrl", ModuleName)] - internal static partial void OpenUrl(string url); - - [JSImport("isTouchPrimary", ModuleName)] - internal static partial bool IsTouchPrimary(); - - [JSImport("downloadFile", ModuleName)] - internal static partial void DownloadFile(string name, string base64, string mime); - - // Shows the file sheet and resolves to "name\nlength" once a real file input is tapped and read, or null - // on cancel. The chosen bytes are held on the JS side and copied out in one shot by CopyPickedBytes. - [JSImport("pickImageFile", ModuleName)] - internal static partial Task PickImageFile(string title); - - [JSImport("copyPickedBytes", ModuleName)] - internal static partial void CopyPickedBytes([JSMarshalAs] Span destination); - - [JSImport("clearPickedBytes", ModuleName)] - internal static partial void ClearPickedBytes(); -} diff --git a/src/ScanNTune.Browser/Platform/LocalStorageCalibrationStore.cs b/src/ScanNTune.Browser/Platform/LocalStorageCalibrationStore.cs deleted file mode 100644 index c1762ac..0000000 --- a/src/ScanNTune.Browser/Platform/LocalStorageCalibrationStore.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Runtime.InteropServices.JavaScript; -using System.Text.Json; -using Microsoft.Extensions.Logging; -using ScanNTune.Core.Calibration; - -namespace ScanNTune.Browser.Platform; - -/// -/// Persists the scanner calibration in the browser's localStorage (the same JSON the desktop writes to a -/// file), so a calibration survives page reloads. A missing or unreadable/degenerate value is treated as -/// "not calibrated" rather than throwing, mirroring JsonCalibrationStore. -/// -public sealed class LocalStorageCalibrationStore : ICalibrationStore -{ - private const string Key = "scanntune.calibration"; - private readonly ILogger _logger; - private readonly JsonSerializerOptions _json = new() { WriteIndented = true }; - - public LocalStorageCalibrationStore(ILogger logger) => _logger = logger; - - public ScannerCalibration? Load() - { - try - { - string? json = BrowserInterop.GetItem(Key); - if (string.IsNullOrEmpty(json)) - return null; - ScannerCalibration? calibration = JsonSerializer.Deserialize(json, _json); - return calibration is { IsUsable: true } ? calibration : null; - } - // A JSException covers localStorage being unavailable (private-browsing, disabled storage, a - // sandboxed iframe); like the desktop store's I/O catch, treat it as "not calibrated" not a crash. - catch (Exception ex) when (ex is JsonException or JSException) - { - _logger.LogWarning(ex, "Could not read scanner calibration from localStorage; treating as uncalibrated."); - return null; - } - } - - public void Save(ScannerCalibration calibration) - { - ArgumentNullException.ThrowIfNull(calibration); - BrowserInterop.SetItem(Key, JsonSerializer.Serialize(calibration, _json)); - } - - public void Clear() - { - try - { - BrowserInterop.RemoveItem(Key); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not clear scanner calibration in localStorage."); - } - } -} diff --git a/src/ScanNTune.Browser/Platform/SkiaImaging.cs b/src/ScanNTune.Browser/Platform/SkiaImaging.cs deleted file mode 100644 index cc2058d..0000000 --- a/src/ScanNTune.Browser/Platform/SkiaImaging.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.IO; -using OpenCvSharp; -using ScanNTune.UI.Platform; -using SkiaSharp; - -namespace ScanNTune.Browser.Platform; - -/// -/// Browser imaging: the wasm OpenCV build has no image codecs, so decode with Skia (present for Avalonia -/// rendering) into a BGR Mat, the same 3-channel layout Cv2.ImRead(..., Color) hands the engine on desktop. -/// -public sealed class SkiaImaging : IPlatformImaging -{ - public Mat DecodeBgr(byte[] data) - { - using SKBitmap decoded = SKBitmap.Decode(data) - ?? throw new InvalidOperationException("Skia failed to decode the image."); - // Normalise to a known BGRA8888 layout so the channel order into OpenCV is deterministic. - using var bgra = new SKBitmap(new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Unpremul)); - if (!decoded.CopyTo(bgra, SKColorType.Bgra8888)) - throw new InvalidOperationException("Skia could not convert the image to BGRA8888."); - - // Pass Skia's actual row stride: SKBitmap may pad rows, and assuming width*4 would shear the image. - using var wrapper = Mat.FromPixelData(bgra.Height, bgra.Width, MatType.CV_8UC4, bgra.GetPixels(), bgra.RowBytes); - Mat bgr = new Mat(); - Cv2.CvtColor(wrapper, bgr, ColorConversionCodes.BGRA2BGR); - return bgr; - } - - public (int Width, int Height) GetImageSize(byte[] data) - { - // SKCodec reads just the image header, so this stays cheap and low-memory even for a large photo - // (no full-resolution decode, which is the whole point of not doing it on the wasm thread). - using var stream = new MemoryStream(data); - using SKCodec codec = SKCodec.Create(stream) - ?? throw new InvalidOperationException("Skia could not read the image header."); - return (codec.Info.Width, codec.Info.Height); - } -} diff --git a/src/ScanNTune.Browser/Program.cs b/src/ScanNTune.Browser/Program.cs deleted file mode 100644 index 7a87233..0000000 --- a/src/ScanNTune.Browser/Program.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Runtime.InteropServices.JavaScript; -using System.Runtime.Versioning; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Browser; -using ScanNTune.Browser.Platform; - -[assembly: SupportedOSPlatform("browser")] - -namespace ScanNTune.Browser; - -internal sealed partial class Program -{ - private static async Task Main(string[] args) - { - // Load the JS interop module before Avalonia starts, so the localStorage-backed stores are usable - // from the first view model constructed in OnFrameworkInitializationCompleted. - await JSHost.ImportAsync(BrowserInterop.ModuleName, BrowserInterop.ModulePath); - - await BuildAvaloniaApp() - .WithInterFont() - .StartBrowserAppAsync("out"); - } - - public static AppBuilder BuildAvaloniaApp() - => AppBuilder.Configure(); -} diff --git a/src/ScanNTune.Browser/Properties/launchSettings.json b/src/ScanNTune.Browser/Properties/launchSettings.json deleted file mode 100644 index 3ad2d9e..0000000 --- a/src/ScanNTune.Browser/Properties/launchSettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "profiles": { - "ScanNTune.Browser": { - "commandName": "Project", - "launchBrowser": false, - "applicationUrl": "http://localhost:5235" - } - } -} diff --git a/src/ScanNTune.Browser/ScanNTune.Browser.csproj b/src/ScanNTune.Browser/ScanNTune.Browser.csproj deleted file mode 100644 index bde1dab..0000000 --- a/src/ScanNTune.Browser/ScanNTune.Browser.csproj +++ /dev/null @@ -1,92 +0,0 @@ - - - net10.0-browser - Exe - true - enable - true - - -O0 - - true - - 268435456 - true - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <_OpenCvExternRenamed>$(IntermediateOutputPath)OpenCvSharpExtern.a - - - - <_OpenCvExternArchive Include="$(PkgOpenCvSharp4_runtime_wasm)\runtimes\browser-wasm\**\libOpenCvSharpExtern.a" /> - - - - - - - - diff --git a/src/ScanNTune.Browser/runtimeconfig.template.json b/src/ScanNTune.Browser/runtimeconfig.template.json deleted file mode 100644 index e2af20d..0000000 --- a/src/ScanNTune.Browser/runtimeconfig.template.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "wasmHostProperties": { - "perHostConfig": [ - { - "name": "browser", - "host": "browser" - } - ] - } -} diff --git a/src/ScanNTune.Browser/wwwroot/app.css b/src/ScanNTune.Browser/wwwroot/app.css deleted file mode 100644 index f11833f..0000000 --- a/src/ScanNTune.Browser/wwwroot/app.css +++ /dev/null @@ -1,18 +0,0 @@ -html, body { - margin: 0; - padding: 0; -} - -#out { - width: 100vw; - height: 100vh; - height: 100dvh; -} - -/* iOS Safari auto-zooms the whole page when a focused input's font is under 16px, and over the Avalonia - canvas the pinch gesture can't zoom back out. Avalonia's hidden text-entry element is 13.3px, so focusing - a field (for example tapping a stepper's number box) zooms in with no way out. Force it to 16px: the - element is invisible, so this only changes iOS's zoom decision, nothing visual. */ -.avalonia-input-element { - font-size: 16px !important; -} diff --git a/src/ScanNTune.Browser/wwwroot/index.html b/src/ScanNTune.Browser/wwwroot/index.html deleted file mode 100644 index e726565..0000000 --- a/src/ScanNTune.Browser/wwwroot/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - ScanNTune - - - - - - - - -
- - - - diff --git a/src/ScanNTune.Browser/wwwroot/interop.js b/src/ScanNTune.Browser/wwwroot/interop.js deleted file mode 100644 index ce503bb..0000000 --- a/src/ScanNTune.Browser/wwwroot/interop.js +++ /dev/null @@ -1,238 +0,0 @@ -// Small wrappers so .NET [JSImport] can call browser methods that need their own `this` (calling -// localStorage.getItem or window.open detached throws "Illegal invocation"). -export function getItem(key) { return globalThis.localStorage.getItem(key); } -export function setItem(key, value) { globalThis.localStorage.setItem(key, value); } -export function removeItem(key) { globalThis.localStorage.removeItem(key); } -export function openUrl(url) { globalThis.open(url, "_blank"); } - -// True when the primary pointer is a touch screen (phone/tablet). The shared UI uses this to turn off text -// entry in the numeric fields, since a mobile soft keyboard cannot type into them. -export function isTouchPrimary() { return globalThis.matchMedia("(pointer: coarse)").matches; } - -// Trigger a browser download of base64-encoded bytes under the given filename. -export function downloadFile(name, base64, mime) { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - const url = URL.createObjectURL(new Blob([bytes], { type: mime })); - const a = document.createElement("a"); - a.href = url; - a.download = name; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -let _pickedBytes = null; -let _activeFinish = null; - -// Show a sheet with a real, directly-tapped . A genuine tap on the input is what lets iOS -// Safari open the file dialog (a programmatic click after an await does not, which is why Avalonia's own -// picker fails there). Resolves to "name\nlength" once a file is chosen and read, or null on cancel/dismiss. -export function pickImageFile(title) { - // Only one sheet at a time: a second call (e.g. a double tap) dismisses the first rather than stacking. - if (_activeFinish) _activeFinish(null); - return new Promise((resolve) => { - const overlay = document.createElement("div"); - overlay.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center;"; - - let settled = false; - let diagCleanup = null; - const finish = (value) => { - if (settled) return; - settled = true; - if (diagCleanup) diagCleanup(); - if (_activeFinish === finish) _activeFinish = null; - overlay.remove(); - resolve(value); - }; - _activeFinish = finish; - - const sheet = document.createElement("div"); - sheet.style.cssText = "background:#24263a;color:#e6e8f0;width:100%;max-width:520px;box-sizing:border-box;border-radius:16px 16px 0 0;padding:20px;font-family:system-ui,sans-serif;"; - - const heading = document.createElement("div"); - heading.textContent = title; - heading.style.cssText = "font-size:15px;font-weight:500;margin-bottom:14px;"; - - // Show the REAL directly and visibly. iOS Safari opens the OS file dialog only from a - // genuine tap on a visible input; a transparent overlay input (opacity:0) does get tapped but iOS will - // not honour it, so the dialog never opens (confirmed on a device). Its button chrome is themed via - // ::file-selector-button; the 16px font stops iOS zooming in on focus. - const style = document.createElement("style"); - style.textContent = - ".snt-file{display:block;margin:2px auto 0;font-size:16px;color:#e6e8f0;max-width:100%;}" + - ".snt-file::file-selector-button,.snt-file::-webkit-file-upload-button{" + - "background:#3f6fd8;color:#fff;border:0;border-radius:10px;padding:13px 16px;" + - "font-size:16px;font-weight:500;margin-right:10px;cursor:pointer;font-family:inherit;}"; - - const input = document.createElement("input"); - input.type = "file"; - input.className = "snt-file"; - // Only the raster formats the engine can actually decode (OpenCV on desktop, Skia in the browser), so - // the user is not offered SVG/HEIC/AVIF and the like that would just fail after upload. - input.accept = ".png,.jpg,.jpeg,.bmp,.tif,.tiff,.webp,image/png,image/jpeg,image/bmp,image/tiff,image/webp"; - - // Keep real event listeners on the file input: iOS Safari opens the OS file dialog on tap only when the - // input looks interactive to it, and an input carrying touch/click listeners qualifies where a bare one - // in this sheet did not (confirmed on an iPhone). DO NOT remove these listeners; they are what makes the - // picker work on iOS. They log to the console, which is also handy for debugging. - let dn = 0; - const dadd = (m) => console.log("[picker] " + (++dn) + ") " + m); - dadd("UA " + navigator.userAgent); - ["touchstart", "touchend", "pointerdown", "pointerup", "pointercancel", "click", "change"].forEach((t) => - input.addEventListener(t, (e) => dadd(t + " defaultPrevented=" + e.defaultPrevented))); - // Surface thrown errors, unhandled promise rejections and console.error (where .NET/Avalonia report - // failures) to the console too. - const onDiagError = (e) => dadd("ERROR " + (e && (e.message || (e.reason && (e.reason.message || e.reason)) || e.type) || e)); - const origConsoleError = console.error; - console.error = (...a) => { - try { dadd("console.error " + a.map((x) => (x && x.message) || String(x)).join(" ")); } catch (_) { /* keep logging even if formatting a value throws */ } - origConsoleError.apply(console, a); - }; - globalThis.addEventListener("error", onDiagError); - globalThis.addEventListener("unhandledrejection", onDiagError); - diagCleanup = () => { - globalThis.removeEventListener("error", onDiagError); - globalThis.removeEventListener("unhandledrejection", onDiagError); - console.error = origConsoleError; - }; - - const cancel = document.createElement("button"); - cancel.type = "button"; - cancel.textContent = "Cancel"; - cancel.style.cssText = "display:block;width:100%;margin-top:10px;background:transparent;color:#9aa0b8;border:0;padding:12px;font-size:15px;cursor:pointer;"; - - input.addEventListener("change", async () => { - const file = input.files && input.files[0]; - if (!file) { finish(null); return; } - const bytes = new Uint8Array(await file.arrayBuffer()); - _pickedBytes = bytes; - finish(file.name + "\n" + bytes.length); - }); - cancel.addEventListener("click", () => finish(null)); - // Dismiss on a fresh press on the backdrop, NOT on click: the tap that opens the sheet is a touch-down - // on the button, and its follow-up click lands mid-screen on this backdrop; a click handler would then - // close the sheet the instant it opened. A pointerdown only fires for a new, deliberate tap outside. - overlay.addEventListener("pointerdown", (e) => { if (e.target === overlay) finish(null); }); - - sheet.appendChild(style); - sheet.appendChild(heading); - sheet.appendChild(input); - sheet.appendChild(cancel); - overlay.appendChild(sheet); - document.body.appendChild(overlay); - }); -} - -// Copy the last picked file's bytes into the .NET-provided buffer in one bulk memory write. -export function copyPickedBytes(dest) { - if (_pickedBytes) dest.set(_pickedBytes); - _pickedBytes = null; -} - -// Drop any held bytes without copying (used when the .NET side rejects the pick), so a large buffer is freed. -export function clearPickedBytes() { - _pickedBytes = null; -} - -// --------------------------------------------------------------------------------------------------------- -// In-app debug console (web app only; this module is not loaded by the desktop head). A discreet floating -// button opens a panel that shows captured console output plus uncaught errors and promise rejections, with -// Copy and Share, so a phone user can send us the log without any devtools. The C# side logs through the -// browser console too (BrowserConsoleLogger), so its output appears here as well. Add console.log anywhere in -// the JS or C# and it shows up. -(function initDebugConsole() { - if (typeof document === "undefined" || typeof window === "undefined") return; - if (window.__sntDebug) return; // a re-import must not double-hook the console - window.__sntDebug = true; - - const MAX = 500; - const lines = []; - let bodyEl = null; // the
 in the open panel, or null when closed
-
-    const fmt = (a) => {
-        try {
-            if (a instanceof Error) return a.stack || (a.name + ": " + a.message);
-            if (a !== null && typeof a === "object") return JSON.stringify(a);
-            return String(a);
-        } catch (_) { return "[unserializable]"; }
-    };
-    const stamp = () => { try { return new Date().toLocaleTimeString(); } catch (_) { return ""; } };
-    const text = () => lines.join("\n");
-    const push = (level, args) => {
-        lines.push(stamp() + " [" + level + "] " + args.map(fmt).join(" "));
-        if (lines.length > MAX) lines.shift();
-        if (bodyEl) { bodyEl.textContent = text(); bodyEl.scrollTop = bodyEl.scrollHeight; }
-    };
-
-    ["log", "info", "warn", "error"].forEach((m) => {
-        const orig = console[m] ? console[m].bind(console) : function () { };
-        console[m] = function (...a) { try { push(m, a); } catch (_) { /* logging must never throw */ } orig(...a); };
-    });
-    window.addEventListener("error", (e) => push("error", [e.message + " @ " + (e.filename || "") + ":" + (e.lineno || "")]));
-    window.addEventListener("unhandledrejection", (e) => push("reject", [(e.reason && (e.reason.stack || e.reason.message)) || e.reason]));
-
-    const mkBtn = (label, bg, fg) => {
-        const b = document.createElement("button");
-        b.type = "button";
-        b.textContent = label;
-        b.style.cssText = "background:" + bg + ";color:" + fg + ";border:0;border-radius:8px;padding:8px 12px;font:14px system-ui,sans-serif;cursor:pointer;";
-        return b;
-    };
-
-    let panel = null;
-    const close = () => { if (panel) { panel.remove(); panel = null; bodyEl = null; } };
-    const open = () => {
-        if (panel) return;
-        panel = document.createElement("div");
-        panel.style.cssText = "position:fixed;inset:0;z-index:2147483646;background:#14151f;color:#e6e8f0;display:flex;flex-direction:column;";
-        const bar = document.createElement("div");
-        bar.style.cssText = "display:flex;gap:8px;padding:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid rgba(255,255,255,0.1);";
-        const title = document.createElement("div");
-        title.textContent = "Debug log";
-        title.style.cssText = "font:600 14px system-ui,sans-serif;margin-right:auto;";
-        const copy = mkBtn("Copy", "#3f6fd8", "#fff");
-        const share = mkBtn("Share", "#3f6fd8", "#fff");
-        const clear = mkBtn("Clear", "#5a3f3f", "#fff");
-        const closeBtn = mkBtn("Close", "transparent", "#9aa0b8");
-        bodyEl = document.createElement("pre");
-        bodyEl.style.cssText = "flex:1;margin:0;padding:10px;overflow:auto;white-space:pre-wrap;word-break:break-word;font:11px/1.5 monospace;background:#0c0d14;";
-        bodyEl.textContent = text();
-        copy.addEventListener("click", async () => {
-            try { await navigator.clipboard.writeText(text()); }
-            catch (_) {
-                const ta = document.createElement("textarea");
-                ta.value = text();
-                document.body.appendChild(ta);
-                ta.select();
-                try { document.execCommand("copy"); } catch (e2) { /* nothing else to try */ }
-                ta.remove();
-            }
-            copy.textContent = "Copied";
-            setTimeout(() => { copy.textContent = "Copy"; }, 1200);
-        });
-        share.addEventListener("click", async () => {
-            try { if (navigator.share) { await navigator.share({ title: "ScanNTune debug log", text: text() }); } else { share.textContent = "No share"; } }
-            catch (_) { /* user cancelled the share sheet */ }
-        });
-        clear.addEventListener("click", () => { lines.length = 0; bodyEl.textContent = ""; });
-        closeBtn.addEventListener("click", close);
-        bar.append(title, copy, share, clear, closeBtn);
-        panel.append(bar, bodyEl);
-        document.body.appendChild(panel);
-    };
-
-    const toggle = document.createElement("button");
-    toggle.type = "button";
-    toggle.textContent = "debug";
-    toggle.setAttribute("aria-label", "Open the debug log");
-    toggle.style.cssText = "position:fixed;left:8px;bottom:8px;z-index:2147483645;background:rgba(40,42,58,0.7);color:#9aa0b8;border:1px solid rgba(255,255,255,0.15);border-radius:8px;font:11px system-ui,sans-serif;padding:5px 8px;opacity:0.55;cursor:pointer;";
-    toggle.addEventListener("click", () => (panel ? close() : open()));
-
-    const attach = () => { if (document.body) document.body.appendChild(toggle); };
-    if (document.body) attach(); else window.addEventListener("DOMContentLoaded", attach);
-
-    push("info", ["debug console ready"]);
-})();
diff --git a/src/ScanNTune.Browser/wwwroot/main.js b/src/ScanNTune.Browser/wwwroot/main.js
deleted file mode 100644
index bf1555e..0000000
--- a/src/ScanNTune.Browser/wwwroot/main.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { dotnet } from './_framework/dotnet.js'
-
-const is_browser = typeof window != "undefined";
-if (!is_browser) throw new Error(`Expected to be running in a browser`);
-
-const dotnetRuntime = await dotnet
-    .withDiagnosticTracing(false)
-    .withApplicationArgumentsFromQuery()
-    .create();
-
-const config = dotnetRuntime.getConfig();
-
-await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]);
diff --git a/src/ScanNTune.Core/AffineModel.cs b/src/ScanNTune.Core/AffineModel.cs
deleted file mode 100644
index 9c46b31..0000000
--- a/src/ScanNTune.Core/AffineModel.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// The best-fit affine map from nominal millimetres to measured pixels, decomposed into the
-/// quantities we care about: per-axis scale (px per mm) and the skew error (the X/Y corner
-/// angle minus 90°; positive = opened past square, negative = closed, i.e. sheared x' = x + t·y).
-/// 
-public sealed record AffineModel(
-    double ScaleXPxPerMm,
-    double ScaleYPxPerMm,
-    double SkewDegrees,
-    double RmsResidualPx,
-    int PointCount);
diff --git a/src/ScanNTune.Core/AnalysisOptions.cs b/src/ScanNTune.Core/AnalysisOptions.cs
deleted file mode 100644
index 7374558..0000000
--- a/src/ScanNTune.Core/AnalysisOptions.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// Inputs that tune a single analysis run.
-/// 
-public sealed record AnalysisOptions
-{
-    public CouponSpec Coupon { get; init; } = new();
-
-    /// 
-    /// True scale of the source image in pixels per millimetre (scanner DPI / 25.4, or a
-    /// reference object). Required to report absolute X/Y shrinkage. When null, the analyzer
-    /// reports anisotropy only (X vs Y), assuming the average scale is correct.
-    /// 
-    public double? PxPerMm { get; init; }
-
-    public double? CurrentStepsPerMmX { get; init; }
-    public double? CurrentStepsPerMmY { get; init; }
-    public double? CurrentRotationDistanceX { get; init; }
-    public double? CurrentRotationDistanceY { get; init; }
-}
diff --git a/src/ScanNTune.Core/Calibration/CardEdgeMeasurer.cs b/src/ScanNTune.Core/Calibration/CardEdgeMeasurer.cs
deleted file mode 100644
index 7b59de6..0000000
--- a/src/ScanNTune.Core/Calibration/CardEdgeMeasurer.cs
+++ /dev/null
@@ -1,274 +0,0 @@
-using MathNet.Numerics;
-using MathNet.Numerics.Statistics;
-using Microsoft.Extensions.Logging;
-using OpenCvSharp;
-
-namespace ScanNTune.Core.Calibration;
-
-/// 
-/// Measures a reference card's long side to sub-pixel precision. It locates the card as the largest
-/// object that contrasts with the (assumed uniform) background — working for a dark card on white or
-/// a pale card on a dark backing alike — then fits each long edge as a straight line from sub-pixel
-/// gradient-peak edge points (one per scan row) and takes the perpendicular distance between the two
-/// lines. Fitting the whole straight edge is what makes the result independent of where the card sits,
-/// how it's rotated, and any single bad row; only the card's colour must differ from the background.
-///
-/// The long side is measured in a frame where it runs horizontally (a portrait card is transposed
-/// first), so one left/right edge routine covers both orientations.
-/// 
-public sealed class CardEdgeMeasurer : IScaleReferenceMeasurer
-{
-    private readonly ILogger? _logger;
-
-    public CardEdgeMeasurer(ILogger? logger = null)
-    {
-        _logger = logger;
-    }
-
-    public ScaleReferenceResult Measure(string imagePath, double knownLongSideMm, double nominalDpi)
-    {
-        using Mat image = Cv2.ImRead(imagePath, ImreadModes.Color);
-        if (image.Empty())
-            return Fail($"Could not read the image: {imagePath}");
-        return Measure(image, knownLongSideMm, nominalDpi);
-    }
-
-    public ScaleReferenceResult Measure(Mat image, double knownLongSideMm, double nominalDpi)
-    {
-        if (image is null || image.Empty())
-            throw new ArgumentException("Image is null or empty.", nameof(image));
-        if (knownLongSideMm <= 0)
-            throw new ArgumentOutOfRangeException(nameof(knownLongSideMm), "The reference length must be positive.");
-
-        using Mat gray = ToGray(image);
-        if (!TryFindCardBox(gray, out Rect box, out string? boxError))
-            return Fail(boxError!);
-
-        bool portrait = box.Height > box.Width;
-        Mat work = gray;
-        Mat? transposed = null;
-        Rect wbox = box;
-        if (portrait)
-        {
-            transposed = new Mat();
-            Cv2.Transpose(gray, transposed);
-            work = transposed;
-            wbox = new Rect(box.Y, box.X, box.Height, box.Width);
-        }
-
-        try
-        {
-            int halfWin = Math.Clamp((int)(wbox.Width * 0.02), 12, 60);
-            int y0 = wbox.Y + (int)(wbox.Height * 0.15);
-            int y1 = wbox.Y + (int)(wbox.Height * 0.85);
-
-            (double mL, double cL, double rmsL, int nL) = FitVerticalEdge(work, y0, y1, wbox.Left, halfWin);
-            (double mR, double cR, double rmsR, int nR) = FitVerticalEdge(work, y0, y1, wbox.Right, halfWin);
-            if (nL < 15 || nR < 15)
-                return Fail("Couldn't trace the card's long edges. Check the scan contrast and that the whole card is on the glass.");
-
-            double yMid = (y0 + y1) / 2.0;
-            double xL = mL * yMid + cL;
-            double xR = mR * yMid + cR;
-            double mAvg = (mL + mR) / 2.0;
-            double widthPx = (xR - xL) / Math.Sqrt(1 + mAvg * mAvg);
-            if (widthPx <= 0)
-                return Fail("The detected edges don't bound a card. Try re-scanning.");
-
-            double pxPerMm = widthPx / knownLongSideMm;
-            double parallelDeg = Math.Abs(Math.Atan(mL) - Math.Atan(mR)) * 180.0 / Math.PI;
-            double straightness = Math.Max(rmsL, rmsR);
-            double detectedMm = nominalDpi > 0 ? widthPx / (nominalDpi / 25.4) : 0;
-
-            return new ScaleReferenceResult(true, pxPerMm, widthPx, detectedMm, straightness, parallelDeg, Math.Min(nL, nR));
-        }
-        finally
-        {
-            transposed?.Dispose();
-        }
-    }
-
-    private Mat ToGray(Mat image)
-    {
-        var gray = new Mat();
-        if (image.Channels() == 1)
-            image.CopyTo(gray);
-        else
-            Cv2.CvtColor(image, gray, ColorConversionCodes.BGR2GRAY);
-
-        // The per-pixel access below assumes 8-bit; down-convert a 16-bit (or deeper) scan so a
-        // high-bit-depth TIFF doesn't misread as bytes.
-        if (gray.Type() != MatType.CV_8UC1)
-        {
-            var gray8 = new Mat();
-            double scale = gray.ElemSize() >= 2 ? 255.0 / 65535.0 : 1.0;
-            gray.ConvertTo(gray8, MatType.CV_8U, scale);
-            gray.Dispose();
-            gray = gray8;
-        }
-        return gray;
-    }
-
-    /// 
-    /// Finds the card as the largest external contour after separating it from the background by an
-    /// Otsu threshold, with the polarity chosen from the border so it works whether the card is
-    /// darker or brighter than the background.
-    /// 
-    private bool TryFindCardBox(Mat gray, out Rect box, out string? error)
-    {
-        box = default;
-        error = null;
-        using var binary = new Mat();
-        Cv2.Threshold(gray, binary, 0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu);
-        if (BorderMean(binary) > 127.0)
-            Cv2.BitwiseNot(binary, binary); // make the card white whichever way the contrast falls
-
-        using var kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(5, 5));
-        Cv2.MorphologyEx(binary, binary, MorphTypes.Close, kernel);
-
-        Cv2.FindContours(binary, out Point[][] contours, out _, RetrievalModes.External, ContourApproximationModes.ApproxSimple);
-        double bestArea = 0;
-        Rect best = default;
-        foreach (Point[] contour in contours)
-        {
-            Rect r = Cv2.BoundingRect(contour);
-            double area = (double)r.Width * r.Height;
-            if (area > bestArea)
-            {
-                bestArea = area;
-                best = r;
-            }
-        }
-
-        if (bestArea <= 0)
-        {
-            error = "No object found in the scan.";
-            return false;
-        }
-        if (best.Width < 120 || best.Height < 120)
-        {
-            error = "The detected object is too small. Is the card in the scan?";
-            return false;
-        }
-        if (bestArea / ((double)gray.Width * gray.Height) > 0.92)
-        {
-            error = "Couldn't separate the card from the background. A pale card needs a dark sheet behind it.";
-            return false;
-        }
-        box = best;
-        return true;
-    }
-
-    /// 
-    /// Fits x = c + m·y to a near-vertical edge — one sub-pixel edge point per row in the search band,
-    /// via MathNet's least-squares line fit, then one robust (MAD) outlier-rejection pass so a stray
-    /// row (a dust speck or a smudge on one line) can't tilt the edge.
-    /// 
-    private (double m, double c, double rms, int n) FitVerticalEdge(Mat img, int y0, int y1, int xCentre, int halfWin)
-    {
-        var ys = new List();
-        var xs = new List();
-        for (int y = y0; y <= y1; y++)
-        {
-            double xe = SubPixEdge(img, y, xCentre - halfWin, xCentre + halfWin);
-            if (!double.IsNaN(xe))
-            {
-                ys.Add(y);
-                xs.Add(xe);
-            }
-        }
-        if (ys.Count < 3)
-            return (0, 0, 0, ys.Count);
-
-        double[] ya = ys.ToArray();
-        double[] xa = xs.ToArray();
-        (double c, double m) = Fit.Line(ya, xa); // x = c + m·y
-
-        // Robust scale from the residuals (MAD → Gaussian sigma, as in AffineSolver); skip rejection
-        // when the residuals are ~0 (a clean fit with nothing to trim).
-        double[] absResiduals = new double[ya.Length];
-        for (int i = 0; i < ya.Length; i++)
-            absResiduals[i] = Math.Abs(xa[i] - (m * ya[i] + c));
-        double sigma = 1.4826 * Statistics.Median(absResiduals);
-        if (sigma > 1e-6)
-        {
-            var y2 = new List();
-            var x2 = new List();
-            for (int i = 0; i < ya.Length; i++)
-            {
-                if (absResiduals[i] <= 3 * sigma)
-                {
-                    y2.Add(ya[i]);
-                    x2.Add(xa[i]);
-                }
-            }
-            if (y2.Count >= 3)
-            {
-                ya = y2.ToArray();
-                xa = x2.ToArray();
-                (c, m) = Fit.Line(ya, xa);
-            }
-        }
-        return (m, c, Rms(ya, xa, m, c), ya.Length);
-    }
-
-    /// 
-    /// Sub-pixel column position of the strongest intensity step within [xLo, xHi] on row y, via the
-    /// gradient magnitude peak with parabolic interpolation. Magnitude (not sign) so it finds the
-    /// edge for either card/background polarity.
-    /// 
-    private double SubPixEdge(Mat img, int y, int xLo, int xHi)
-    {
-        xLo = Math.Max(1, xLo);
-        xHi = Math.Min(img.Cols - 2, xHi);
-        double best = -1;
-        int bx = -1;
-        for (int x = xLo; x <= xHi; x++)
-        {
-            double g = Math.Abs(img.At(y, x + 1) - img.At(y, x - 1));
-            if (g > best)
-            {
-                best = g;
-                bx = x;
-            }
-        }
-        if (bx <= xLo || bx >= xHi || best < 8)
-            return double.NaN; // no real edge here (a flat, noise-only window)
-
-        double gm = Math.Abs(img.At(y, bx) - img.At(y, bx - 2));
-        double gc = Math.Abs(img.At(y, bx + 1) - img.At(y, bx - 1));
-        double gp = Math.Abs(img.At(y, bx + 2) - img.At(y, bx));
-        double denom = gm - 2 * gc + gp;
-        double sub = Math.Abs(denom) < 1e-9 ? 0 : 0.5 * (gm - gp) / denom;
-        return bx + Math.Clamp(sub, -1, 1);
-    }
-
-    private double Rms(double[] ys, double[] xs, double m, double c)
-    {
-        if (ys.Length == 0)
-            return 0;
-        double s = 0;
-        for (int i = 0; i < ys.Length; i++)
-        {
-            double e = xs[i] - (m * ys[i] + c);
-            s += e * e;
-        }
-        return Math.Sqrt(s / ys.Length);
-    }
-
-    private double BorderMean(Mat binary)
-    {
-        using Mat top = binary.Row(0);
-        using Mat bottom = binary.Row(binary.Rows - 1);
-        using Mat left = binary.Col(0);
-        using Mat right = binary.Col(binary.Cols - 1);
-        return (Cv2.Mean(top).Val0 + Cv2.Mean(bottom).Val0 +
-                Cv2.Mean(left).Val0 + Cv2.Mean(right).Val0) / 4.0;
-    }
-
-    private ScaleReferenceResult Fail(string message)
-    {
-        _logger?.LogInformation("Scale reference not measured: {Message}", message);
-        return new ScaleReferenceResult(false, 0, 0, 0, 0, 0, 0, message);
-    }
-}
diff --git a/src/ScanNTune.Core/Calibration/ICalibrationStore.cs b/src/ScanNTune.Core/Calibration/ICalibrationStore.cs
deleted file mode 100644
index 0d96b74..0000000
--- a/src/ScanNTune.Core/Calibration/ICalibrationStore.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace ScanNTune.Core.Calibration;
-
-/// Persists the one-time scanner calibration between sessions.
-public interface ICalibrationStore
-{
-    /// The stored calibration, or null if the scanner has never been calibrated.
-    ScannerCalibration? Load();
-
-    void Save(ScannerCalibration calibration);
-
-    /// Forget the stored calibration (so the next run prompts to calibrate again).
-    void Clear();
-}
diff --git a/src/ScanNTune.Core/Calibration/IScaleReferenceMeasurer.cs b/src/ScanNTune.Core/Calibration/IScaleReferenceMeasurer.cs
deleted file mode 100644
index 050ce98..0000000
--- a/src/ScanNTune.Core/Calibration/IScaleReferenceMeasurer.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.Core.Calibration;
-
-/// 
-/// Measures a known-length reference object in a scan to recover the scan's true pixels-per-mm.
-/// 
-public interface IScaleReferenceMeasurer
-{
-    /// The scan containing only the reference on a contrasting background.
-    /// The reference's measured long side, in millimetres.
-    /// The DPI the scan was captured at — used only to report the detected
-    /// size in millimetres for the caller's sanity check, not for the px/mm result itself.
-    ScaleReferenceResult Measure(Mat image, double knownLongSideMm, double nominalDpi);
-
-    /// Loads the scan from disk and measures it, so callers need not touch OpenCV.
-    ScaleReferenceResult Measure(string imagePath, double knownLongSideMm, double nominalDpi);
-}
diff --git a/src/ScanNTune.Core/Calibration/JsonCalibrationStore.cs b/src/ScanNTune.Core/Calibration/JsonCalibrationStore.cs
deleted file mode 100644
index 574f112..0000000
--- a/src/ScanNTune.Core/Calibration/JsonCalibrationStore.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System.Text.Json;
-using Microsoft.Extensions.Logging;
-
-namespace ScanNTune.Core.Calibration;
-
-/// 
-/// Stores the scanner calibration as JSON under the user's application-data folder. A missing or
-/// unreadable file is treated as "not calibrated" (returns null) rather than throwing, so a first run
-/// or a corrupt file simply prompts the user to calibrate again.
-/// 
-public sealed class JsonCalibrationStore : ICalibrationStore
-{
-    private readonly string _path;
-    private readonly ILogger? _logger;
-    private readonly JsonSerializerOptions _json = new() { WriteIndented = true };
-
-    public JsonCalibrationStore(string? path = null, ILogger? logger = null)
-    {
-        _path = path ?? Path.Combine(
-            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
-            "ScanNTune", "scanner-calibration.json");
-        _logger = logger;
-    }
-
-    public ScannerCalibration? Load()
-    {
-        try
-        {
-            if (!File.Exists(_path))
-                return null;
-            ScannerCalibration? calibration = JsonSerializer.Deserialize(File.ReadAllText(_path), _json);
-            return calibration is { IsUsable: true } ? calibration : null;
-        }
-        catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
-        {
-            _logger?.LogWarning(ex, "Could not read scanner calibration from {Path}; treating as uncalibrated.", _path);
-            return null;
-        }
-    }
-
-    public void Save(ScannerCalibration calibration)
-    {
-        ArgumentNullException.ThrowIfNull(calibration);
-        string? dir = Path.GetDirectoryName(_path);
-        if (!string.IsNullOrEmpty(dir))
-            Directory.CreateDirectory(dir);
-        // Write to a temp file and move it into place, so a crash mid-write can't leave a truncated
-        // file that the next Load would silently discard as "not calibrated".
-        string temp = _path + ".tmp";
-        File.WriteAllText(temp, JsonSerializer.Serialize(calibration, _json));
-        File.Move(temp, _path, overwrite: true);
-    }
-
-    public void Clear()
-    {
-        try
-        {
-            if (File.Exists(_path))
-                File.Delete(_path);
-        }
-        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
-        {
-            _logger?.LogWarning(ex, "Could not delete scanner calibration at {Path}.", _path);
-        }
-    }
-}
diff --git a/src/ScanNTune.Core/Calibration/ScaleReferenceResult.cs b/src/ScanNTune.Core/Calibration/ScaleReferenceResult.cs
deleted file mode 100644
index 2676120..0000000
--- a/src/ScanNTune.Core/Calibration/ScaleReferenceResult.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace ScanNTune.Core.Calibration;
-
-/// 
-/// The outcome of measuring a known-length reference (a bank card) in a scan: the pixels-per-mm the
-/// scan actually resolves, plus quality figures that let the caller judge and sanity-check the fit.
-/// When  is false the reference could not be measured and 
-/// says why; the numeric fields are then zero.
-/// 
-public sealed record ScaleReferenceResult(
-    bool Success,
-    double PxPerMm,
-    double MeasuredWidthPx,
-    double DetectedMm,
-    double StraightnessPx,
-    double ParallelismDegrees,
-    int EdgePointCount,
-    string? Message = null);
diff --git a/src/ScanNTune.Core/Calibration/ScannerCalibration.cs b/src/ScanNTune.Core/Calibration/ScannerCalibration.cs
deleted file mode 100644
index 28b2081..0000000
--- a/src/ScanNTune.Core/Calibration/ScannerCalibration.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace ScanNTune.Core.Calibration;
-
-/// 
-/// A stored scanner calibration: the true pixels-per-mm the scanner resolves, recovered by measuring
-/// a reference of known length.  is the scanner's scale error relative
-/// to its nominal DPI and is roughly constant across DPI settings, so  can
-/// apply the same calibration to a coupon scanned at any resolution.
-/// 
-public sealed record ScannerCalibration(
-    double PxPerMm,
-    double Dpi,
-    double ReferenceMm,
-    double MeasuredWidthPx,
-    double StraightnessPx,
-    double ParallelismDegrees,
-    DateTime CalibratedUtc)
-{
-    /// Pixels-per-mm the DPI setting nominally implies (DPI / 25.4), before the scanner's error.
-    [JsonIgnore]
-    public double NominalPxPerMm => Dpi / 25.4;
-
-    /// Measured px/mm ÷ nominal px/mm — the scanner's isotropic scale error (~1.0).
-    [JsonIgnore]
-    public double CorrectionFactor => NominalPxPerMm > 0 ? PxPerMm / NominalPxPerMm : 1.0;
-
-    /// The DPI the scanner effectively resolves at (PxPerMm × 25.4).
-    [JsonIgnore]
-    public double EffectiveDpi => PxPerMm * 25.4;
-
-    /// The scale error as a percentage of nominal (negative = the scanner reads small).
-    [JsonIgnore]
-    public double PercentVsNominal => (CorrectionFactor - 1.0) * 100.0;
-
-    /// The true px/mm for a scan taken at , applying the stored error.
-    public double PxPerMmAtDpi(double dpi) => (dpi / 25.4) * CorrectionFactor;
-
-    /// 
-    /// Whether the calibration is usable. A non-positive DPI or px/mm is degenerate (a partial or
-    /// hand-edited file) and would silently apply no scale correction, so such a value is treated as
-    /// uncalibrated by the stores rather than loaded.
-    /// 
-    [JsonIgnore]
-    public bool IsUsable => Dpi > 0 && PxPerMm > 0;
-}
diff --git a/src/ScanNTune.Core/CalibrationResult.cs b/src/ScanNTune.Core/CalibrationResult.cs
deleted file mode 100644
index 00cf04c..0000000
--- a/src/ScanNTune.Core/CalibrationResult.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// The outcome of analysing a scanned calibration coupon. Scale figures are percentage errors
-/// (measured vs nominal; positive = oversize); skew is the corner-angle error in degrees,
-/// measured minus the nominal 90° (positive = opened past square, negative = closed, i.e.
-/// sheared x' = x + t·y).
-/// 
-public sealed record CalibrationResult(
-    double XScalePercent,
-    double YScalePercent,
-    double SkewDegrees,
-    int RingsDetected,
-    double MeasuredPxPerMmX,
-    double MeasuredPxPerMmY,
-    double RmsResidualPx,
-    IReadOnlyList Rings,
-    Orientation Orientation);
diff --git a/src/ScanNTune.Core/Combining/IScanCombiner.cs b/src/ScanNTune.Core/Combining/IScanCombiner.cs
deleted file mode 100644
index 9f270bc..0000000
--- a/src/ScanNTune.Core/Combining/IScanCombiner.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace ScanNTune.Core.Combining;
-
-/// 
-/// Combines two scans of the same coupon — the second taken after a quarter-turn — into a single
-/// result whose scale/skew are free of the scanner's own geometric distortion.
-/// 
-public interface IScanCombiner
-{
-    TwoScanResult Combine(CalibrationResult scanA, CalibrationResult scanB);
-}
diff --git a/src/ScanNTune.Core/Combining/ScannerCancellingCombiner.cs b/src/ScanNTune.Core/Combining/ScannerCancellingCombiner.cs
deleted file mode 100644
index 921a84c..0000000
--- a/src/ScanNTune.Core/Combining/ScannerCancellingCombiner.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-namespace ScanNTune.Core.Combining;
-
-/// 
-/// Cancels the scanner's fixed geometric error by averaging two scans taken a quarter-turn apart.
-///
-/// A flatbed scanner applies a distortion that is fixed in the bed frame (its X and Y scales differ,
-/// plus a small skew). Both scans are already reported in the coupon's own frame — the two-solid
-/// marker resolves the axes — so for scan A the coupon's X lies along the scanner's X, while for the
-/// quarter-turned scan B the coupon's X lies along the scanner's Y. Writing each measurement as
-/// printer + scanner, the part-frame readings are:
-///   A.X = pX + sX,  A.Y = pY + sY,   A.skew = pSkew + sSkew
-///   B.X = pX + sY,  B.Y = pY + sX,   B.skew = pSkew − sSkew
-/// so the average recovers the printer term (scanner cancels) and the half-difference recovers the
-/// scanner term. Only the anisotropy (X vs Y) and skew are separated this way; the scanner's common
-/// isotropic scale still rides along with absolute size and needs the DPI reference, as before.
-/// 
-public sealed class ScannerCancellingCombiner : IScanCombiner
-{
-    /// 
-    /// How far from an exact 90° the turn between scans may drift before the pair is flagged.
-    /// The cancellation is exact only at 90°: at a turn error D the un-cancelled scanner fraction
-    /// grows as sin(D), so 5° bounds the leak below 9% of the scanner error (a coupon placed by
-    /// hand against the flatbed edge lands within a degree or two).
-    /// 
-    public const double QuarterTurnToleranceDegrees = 5.0;
-
-    public TwoScanResult Combine(CalibrationResult scanA, CalibrationResult scanB)
-    {
-        ArgumentNullException.ThrowIfNull(scanA);
-        ArgumentNullException.ThrowIfNull(scanB);
-
-        double printerX = 0.5 * (scanA.XScalePercent + scanB.XScalePercent);
-        double printerY = 0.5 * (scanA.YScalePercent + scanB.YScalePercent);
-        double printerSkew = 0.5 * (scanA.SkewDegrees + scanB.SkewDegrees);
-
-        // Two independent estimates of the scanner's X-vs-Y bias (from the X pair and the Y pair); average them.
-        double scannerAniso = 0.5 * ((scanA.XScalePercent - scanB.XScalePercent)
-                                     + (scanB.YScalePercent - scanA.YScalePercent));
-        double scannerSkew = 0.5 * (scanA.SkewDegrees - scanB.SkewDegrees);
-
-        double turned = TurnBetween(scanA.Orientation.XAxisAngleDegrees, scanB.Orientation.XAxisAngleDegrees);
-        // The A/B skew algebra assumes both scans have the same handedness. If exactly one is
-        // mirror-flipped (the coupon was turned over between scans), the scanner's skew ADDS
-        // instead of cancelling while the diagnostic reads ~0 — so a mismatch invalidates the pair.
-        bool flipMismatch = scanA.Orientation.Flipped != scanB.Orientation.Flipped;
-        bool rotationValid = !flipMismatch && QuarterTurnError(turned) <= QuarterTurnToleranceDegrees;
-
-        var combined = new CalibrationResult(
-            printerX,
-            printerY,
-            printerSkew,
-            Math.Min(scanA.RingsDetected, scanB.RingsDetected),
-            0.5 * (scanA.MeasuredPxPerMmX + scanB.MeasuredPxPerMmX),
-            0.5 * (scanA.MeasuredPxPerMmY + scanB.MeasuredPxPerMmY),
-            Math.Max(scanA.RmsResidualPx, scanB.RmsResidualPx),
-            scanA.Rings,
-            scanA.Orientation);
-
-        return new TwoScanResult(
-            combined,
-            new ScannerDiagnostic(scannerAniso, scannerSkew),
-            scanA,
-            scanB,
-            turned,
-            rotationValid,
-            flipMismatch);
-    }
-
-    /// Signed-free turn from A's +X to B's +X, folded into [0, 360).
-    private double TurnBetween(double angleADegrees, double angleBDegrees)
-    {
-        double diff = (angleBDegrees - angleADegrees) % 360.0;
-        return diff < 0 ? diff + 360.0 : diff;
-    }
-
-    /// Distance (degrees) from a turn to the nearest quarter-turn (90° or 270°).
-    private double QuarterTurnError(double turnedDegrees) =>
-        Math.Min(Math.Abs(turnedDegrees - 90.0), Math.Abs(turnedDegrees - 270.0));
-}
diff --git a/src/ScanNTune.Core/CouponAnalysisException.cs b/src/ScanNTune.Core/CouponAnalysisException.cs
deleted file mode 100644
index 3721f00..0000000
--- a/src/ScanNTune.Core/CouponAnalysisException.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// Thrown when a scan is detected but cannot be resolved into a calibration (e.g. the orientation
-/// marker can't be found, or too few rings survived). Carries  — whatever
-/// the detector did find — so the UI can still show the user what was captured instead of only an error.
-/// 
-public sealed class CouponAnalysisException : Exception
-{
-    public CouponAnalysisException(string message, IReadOnlyList detectedRings)
-        : base(message)
-    {
-        DetectedRings = detectedRings;
-    }
-
-    public IReadOnlyList DetectedRings { get; }
-}
diff --git a/src/ScanNTune.Core/CouponAnalyzer.cs b/src/ScanNTune.Core/CouponAnalyzer.cs
deleted file mode 100644
index 6a994a6..0000000
--- a/src/ScanNTune.Core/CouponAnalyzer.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-using OpenCvSharp;
-using ScanNTune.Core.Detection;
-using ScanNTune.Core.Grids;
-using ScanNTune.Core.Solving;
-
-namespace ScanNTune.Core;
-
-/// 
-/// Orchestrates the pipeline: detect ring centres + part mask → locate the orientation fiducial
-/// → map rings to the nominal grid → fit the affine → convert scale/skew into a calibration
-/// result. Stages are injected so each is independently testable; the parameterless constructor
-/// wires the default implementations.
-/// 
-public sealed class CouponAnalyzer : ICouponAnalyzer
-{
-    private readonly IRingDetector _detector;
-    private readonly IGridMapper _mapper;
-    private readonly IAffineSolver _solver;
-
-    public CouponAnalyzer(
-        IRingDetector detector,
-        IGridMapper mapper,
-        IAffineSolver solver)
-    {
-        _detector = detector ?? throw new ArgumentNullException(nameof(detector));
-        _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
-        _solver = solver ?? throw new ArgumentNullException(nameof(solver));
-    }
-
-    public CouponAnalyzer()
-        : this(new RingDetector(), new GridMapper(), new AffineSolver())
-    {
-    }
-
-    public CalibrationResult Analyze(Mat image, AnalysisOptions options)
-    {
-        ArgumentNullException.ThrowIfNull(options);
-
-        IReadOnlyList rings = _detector.Detect(image);
-
-        GridMapping mapping;
-        try
-        {
-            mapping = _mapper.Map(rings, options.Coupon);
-        }
-        catch (Exception ex) when (ex is not CouponAnalysisException)
-        {
-            // Detection succeeded but the grid/marker step failed — surface what we found so the
-            // caller can show it rather than only reporting an error.
-            throw new CouponAnalysisException(ex.Message, rings);
-        }
-
-        AffineModel affine = _solver.Solve(mapping.Points);
-
-        double reference = options.PxPerMm
-            ?? Math.Sqrt(affine.ScaleXPxPerMm * affine.ScaleYPxPerMm);
-
-        double xScalePercent = (affine.ScaleXPxPerMm / reference - 1.0) * 100.0;
-        double yScalePercent = (affine.ScaleYPxPerMm / reference - 1.0) * 100.0;
-        double skewDegrees = affine.SkewDegrees;
-        double pxPerMmX = affine.ScaleXPxPerMm;
-        double pxPerMmY = affine.ScaleYPxPerMm;
-
-        // Orientation (rotation AND flip) is fully resolved from the two-solid marker: it gives
-        // the true physical axes, so the X/Y labels and the skew sign are already correct.
-        var orientation = new Orientation(
-            mapping.Flipped, mapping.OriginX, mapping.OriginY, mapping.XAxisX, mapping.XAxisY);
-
-        return new CalibrationResult(
-            xScalePercent,
-            yScalePercent,
-            skewDegrees,
-            rings.Count,
-            pxPerMmX,
-            pxPerMmY,
-            affine.RmsResidualPx,
-            rings,
-            orientation);
-    }
-
-    public CalibrationResult Analyze(string imagePath, AnalysisOptions options)
-    {
-        using Mat image = Cv2.ImRead(imagePath, ImreadModes.Color);
-        if (image.Empty())
-            throw new InvalidOperationException($"Could not read image: {imagePath}");
-        return Analyze(image, options);
-    }
-}
diff --git a/src/ScanNTune.Core/CouponSpec.cs b/src/ScanNTune.Core/CouponSpec.cs
deleted file mode 100644
index 02cebc2..0000000
--- a/src/ScanNTune.Core/CouponSpec.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// The nominal geometry of the calibration coupon, matching calibration_coupon.scad.
-/// All measurements are in millimetres.
-/// 
-public sealed record CouponSpec
-{
-    /// Centre-to-centre span of the outermost rings.
-    public double BaselineMm { get; init; } = 100.0;
-
-    /// Number of rings per side (grid is GridN x GridN).
-    public int GridN { get; init; } = 5;
-
-    public double RingOuterDiameterMm { get; init; } = 9.0;
-
-    public double RingWallMm { get; init; } = 2.0;
-
-    public double RingInnerDiameterMm => RingOuterDiameterMm - 2.0 * RingWallMm;
-
-    /// Centre-to-centre distance between neighbouring rings.
-    public double PitchMm => BaselineMm / (GridN - 1);
-}
diff --git a/src/ScanNTune.Core/DetectedRing.cs b/src/ScanNTune.Core/DetectedRing.cs
deleted file mode 100644
index 2c1112f..0000000
--- a/src/ScanNTune.Core/DetectedRing.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// A ring located in the scan. The centre is in pixel coordinates (sub-pixel via the
-/// contour centroid) and is the quantity used for scale/skew — it is immune to over/under
-/// extrusion. Radius and circularity are kept for diagnostics and filtering.
-/// 
-public readonly record struct DetectedRing(
-    double CenterX,
-    double CenterY,
-    double RadiusPx,
-    double Circularity);
diff --git a/src/ScanNTune.Core/Detection/IRingDetector.cs b/src/ScanNTune.Core/Detection/IRingDetector.cs
deleted file mode 100644
index 23bf924..0000000
--- a/src/ScanNTune.Core/Detection/IRingDetector.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.Core.Detection;
-
-/// Locates the calibration coupon's ring centres in a scanned image.
-public interface IRingDetector
-{
-    IReadOnlyList Detect(Mat image);
-}
diff --git a/src/ScanNTune.Core/Detection/RingDetector.cs b/src/ScanNTune.Core/Detection/RingDetector.cs
deleted file mode 100644
index 500f7b7..0000000
--- a/src/ScanNTune.Core/Detection/RingDetector.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.Core.Detection;
-
-/// 
-/// Default ring detector. Thresholds the part against the background (on the HSV value
-/// channel, so multi-colour toolpath renders and real scans both segment cleanly), finds the
-/// enclosed holes, and keeps the ring centres. Ring holes are separated from the much larger
-/// square lattice cells by  (a size cluster), so circularity is only
-/// a loose gate to drop slivers — real printed/scanned holes are rough (circularity ~0.2–0.8),
-/// so a strict threshold would reject them.
-///
-/// The centre is the binary area centroid (image moments M10/M00, M01/M00). A boundary circle fit
-/// (RANSAC + Taubin on the grayscale edge) was benchmarked as an alternative and rejected: it was
-/// no more accurate on the synthetic fixture and worse on real scans, because the wall's cast
-/// shadow puts a false edge right beside the true rim that no geometric fit can separate. The ~2 px
-/// real-scan residual is a print/scan noise floor; the lever that lowers it is averaging more
-/// independent scans, which the two-scan flow already does.
-/// 
-public sealed class RingDetector : IRingDetector
-{
-    private readonly double _minHoleAreaPx;
-    private readonly double _minCircularity;
-
-    public RingDetector(double minHoleAreaPx = 40.0, double minCircularity = 0.20)
-    {
-        _minHoleAreaPx = minHoleAreaPx;
-        _minCircularity = minCircularity;
-    }
-
-    public IReadOnlyList Detect(Mat image)
-    {
-        if (image is null || image.Empty())
-            throw new ArgumentException("Image is null or empty.", nameof(image));
-
-        using var value = ExtractValueChannel(image);
-        using var binary = new Mat();
-        Cv2.Threshold(value, binary, 0, 255, ThresholdTypes.Binary | ThresholdTypes.Otsu);
-
-        // Make the part white and the background black, whichever way the contrast falls.
-        if (BorderMean(binary) > 127.0)
-            Cv2.BitwiseNot(binary, binary);
-
-        using var kernel = Cv2.GetStructuringElement(MorphShapes.Ellipse, new Size(3, 3));
-        Cv2.MorphologyEx(binary, binary, MorphTypes.Close, kernel);
-
-        Cv2.FindContours(binary, out Point[][] contours, out HierarchyIndex[] hierarchy,
-            RetrievalModes.CComp, ContourApproximationModes.ApproxNone);
-
-        var candidates = new List();
-        for (int i = 0; i < contours.Length; i++)
-        {
-            if (hierarchy[i].Parent < 0)
-                continue; // only interior contours (holes) can be ring centres
-
-            Point[] contour = contours[i];
-            double area = Cv2.ContourArea(contour);
-            if (area < _minHoleAreaPx)
-                continue;
-
-            double perimeter = Cv2.ArcLength(contour, true);
-            if (perimeter <= 0)
-                continue;
-
-            double circularity = 4.0 * Math.PI * area / (perimeter * perimeter);
-            if (circularity < _minCircularity)
-                continue;
-
-            Moments moments = Cv2.Moments(contour);
-            if (moments.M00 == 0)
-                continue;
-
-            double cx = moments.M10 / moments.M00;
-            double cy = moments.M01 / moments.M00;
-            double radius = Math.Sqrt(area / Math.PI);
-            candidates.Add(new DetectedRing(cx, cy, radius, circularity));
-        }
-
-        return FilterByRadius(candidates);
-    }
-
-    /// Drop anything whose radius is far from the population median (stray holes).
-    private List FilterByRadius(List candidates)
-    {
-        if (candidates.Count == 0)
-            return candidates;
-
-        double median = Median(candidates.Select(c => c.RadiusPx));
-        return candidates
-            .Where(c => c.RadiusPx >= median * 0.5 && c.RadiusPx <= median * 1.8)
-            .ToList();
-    }
-
-    private Mat ExtractValueChannel(Mat image)
-    {
-        if (image.Channels() == 1)
-            return image.Clone();
-
-        using var hsv = new Mat();
-        Cv2.CvtColor(image, hsv, ColorConversionCodes.BGR2HSV);
-        Mat[] channels = Cv2.Split(hsv);
-        try
-        {
-            return channels[2].Clone(); // V = max(B,G,R)
-        }
-        finally
-        {
-            foreach (Mat channel in channels)
-                channel.Dispose();
-        }
-    }
-
-    private double BorderMean(Mat binary)
-    {
-        using Mat top = binary.Row(0);
-        using Mat bottom = binary.Row(binary.Rows - 1);
-        using Mat left = binary.Col(0);
-        using Mat right = binary.Col(binary.Cols - 1);
-        return (Cv2.Mean(top).Val0 + Cv2.Mean(bottom).Val0 +
-                Cv2.Mean(left).Val0 + Cv2.Mean(right).Val0) / 4.0;
-    }
-
-    private double Median(IEnumerable values)
-    {
-        var sorted = values.OrderBy(v => v).ToList();
-        int n = sorted.Count;
-        if (n == 0)
-            return 0;
-        return n % 2 == 1 ? sorted[n / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0;
-    }
-}
diff --git a/src/ScanNTune.Core/GridCorrespondence.cs b/src/ScanNTune.Core/GridCorrespondence.cs
deleted file mode 100644
index 716a931..0000000
--- a/src/ScanNTune.Core/GridCorrespondence.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// One ring matched to its place in the nominal grid: the integer (col,row) index, the
-/// nominal millimetre position it should occupy, and where it was actually measured (px).
-/// Col runs along the printer's +X axis, row along +Y.
-/// 
-public readonly record struct GridCorrespondence(
-    int Col,
-    int Row,
-    double NominalXmm,
-    double NominalYmm,
-    double MeasuredXpx,
-    double MeasuredYpx);
diff --git a/src/ScanNTune.Core/Grids/GridMapper.cs b/src/ScanNTune.Core/Grids/GridMapper.cs
deleted file mode 100644
index 6e2e772..0000000
--- a/src/ScanNTune.Core/Grids/GridMapper.cs
+++ /dev/null
@@ -1,224 +0,0 @@
-namespace ScanNTune.Core.Grids;
-
-/// 
-/// Default grid mapper. Estimates the grid axes/pitch from nearest-neighbour vectors, indexes
-/// the rings, then resolves orientation from the two-solid marker: the coupon's origin-corner
-/// ring AND its neighbour are printed solid (no hole), so they show up as two adjacent grid
-/// vertices with no detected ring. origin→neighbour is the coupon's +X, which pins orientation
-/// at ANY rotation and flip. The marker is required — if it can't be located the scan is rejected
-/// (there is no rotation-only fallback).
-/// 
-public sealed class GridMapper : IGridMapper
-{
-    public GridMapping Map(IReadOnlyList rings, CouponSpec spec)
-    {
-        ArgumentNullException.ThrowIfNull(rings);
-        ArgumentNullException.ThrowIfNull(spec);
-        if (rings.Count < 4)
-            throw new InvalidOperationException($"Need at least 4 rings to fit a grid, found {rings.Count}.");
-
-        var points = rings.Select(r => (x: r.CenterX, y: r.CenterY)).ToArray();
-        int n = points.Length;
-        Geometry geo = EstimateGeometry(points);
-        if (geo.PitchPx <= 0)
-            throw new InvalidOperationException("Could not estimate a positive grid pitch.");
-
-        // theta is folded into (-45°,45°], so colHat points +x and rowHat points +y (image-y down).
-        (double x, double y) colHat = geo.U;
-        (double x, double y) rowHat = geo.V;
-
-        var col = new int[n];
-        var row = new int[n];
-        for (int i = 0; i < n; i++)
-        {
-            double rx = points[i].x - geo.CentroidX;
-            double ry = points[i].y - geo.CentroidY;
-            col[i] = (int)Math.Round((rx * colHat.x + ry * colHat.y) / geo.PitchPx);
-            row[i] = (int)Math.Round((rx * rowHat.x + ry * rowHat.y) / geo.PitchPx);
-        }
-
-        int minCol = col.Min(), minRow = row.Min();
-        for (int i = 0; i < n; i++)
-        {
-            col[i] -= minCol;
-            row[i] -= minRow;
-        }
-        int maxCol = col.Max(), maxRow = row.Max();
-
-        var occupied = new HashSet<(int, int)>();
-        for (int i = 0; i < n; i++)
-            occupied.Add((col[i], row[i]));
-
-        // The two solid marker vertices are always missing; tolerate at most ONE stray missed hole
-        // on top. Beyond that the marker search can silently land on the wrong corner (a second
-        // corner+neighbour pair of misses looks exactly like the marker), so reject loudly instead.
-        // Count against the coupon's SPECIFIED grid, not the detected extent: a fully missed outer
-        // row shrinks the extent and would otherwise hide its own misses from this check.
-        int missing = spec.GridN * spec.GridN - occupied.Count;
-        if (missing > 3)
-            throw new InvalidOperationException(
-                $"{missing} grid positions are missing a detected ring; only the two solid marker " +
-                "rings plus one stray miss are tolerated. Check the scan quality and contrast.");
-
-        int markerCandidates = FindMarker(occupied, maxCol, maxRow, out (int c, int r) origin, out (int dc, int dr) toNeighbour);
-        if (markerCandidates == 0)
-            throw new InvalidOperationException(
-                "Could not locate the two solid orientation rings (an origin corner plus its neighbour). " +
-                "Check the scan quality and that the coupon carries the orientation marker.");
-        if (markerCandidates > 1)
-            throw new InvalidOperationException(
-                "The orientation marker is ambiguous: more than one corner has a missing neighbour, " +
-                "so the +X direction cannot be determined (a hole next to a corner may have gone " +
-                "undetected). Rescan with better contrast.");
-
-        (double x, double y) g00 = OriginOfIndexSpace(points, col, row, colHat, rowHat, geo.PitchPx);
-        (double x, double y) originPx =
-            (g00.x + origin.c * geo.PitchPx * colHat.x + origin.r * geo.PitchPx * rowHat.x,
-             g00.y + origin.c * geo.PitchPx * colHat.y + origin.r * geo.PitchPx * rowHat.y);
-
-        (double x, double y) xHat = (toNeighbour.dc * colHat.x + toNeighbour.dr * rowHat.x,
-                                     toNeighbour.dc * colHat.y + toNeighbour.dr * rowHat.y);
-        (double x, double y) perp = Math.Abs(xHat.x * colHat.x + xHat.y * colHat.y) > 0.5 ? rowHat : colHat;
-        if (perp.x * (geo.CentroidX - originPx.x) + perp.y * (geo.CentroidY - originPx.y) < 0)
-            perp = (-perp.x, -perp.y);
-        (double x, double y) yHat = perp;
-
-        // Flip (informational): the marker's +X agrees with the rotation-only guess for this
-        // corner on a normal scan, and points along its perpendicular (a swap) when mirror-flipped.
-        (var xHatCorner, var yHatCorner) = CornerRuleAxes(origin, maxCol, maxRow, colHat, rowHat);
-        bool flipped = Math.Abs(xHat.x * yHatCorner.x + xHat.y * yHatCorner.y) >
-                       Math.Abs(xHat.x * xHatCorner.x + xHat.y * xHatCorner.y);
-
-        double pitchMm = spec.PitchMm;
-        var mapped = new List(n);
-        for (int i = 0; i < n; i++)
-        {
-            double dx = points[i].x - originPx.x;
-            double dy = points[i].y - originPx.y;
-            int xi = (int)Math.Round((dx * xHat.x + dy * xHat.y) / geo.PitchPx);
-            int yi = (int)Math.Round((dx * yHat.x + dy * yHat.y) / geo.PitchPx);
-            mapped.Add(new GridCorrespondence(xi, yi, xi * pitchMm, yi * pitchMm, points[i].x, points[i].y));
-        }
-
-        return new GridMapping(mapped, originPx.x, originPx.y, xHat.x, xHat.y, flipped);
-    }
-
-    /// 
-    /// The two solid rings are two missing grid vertices: a corner and one edge-neighbour.
-    /// Counts every such (corner, neighbour) pair and reports the last one found; the marker is
-    /// only trustworthy when the count is exactly 1. A count above 1 is genuinely ambiguous —
-    /// e.g. a stray missed hole adjacent to the marker corner gives that corner two missing
-    /// neighbours and there is no way to tell which one is the printed +X.
-    /// 
-    private int FindMarker(HashSet<(int, int)> occupied, int maxCol, int maxRow,
-        out (int c, int r) origin, out (int dc, int dr) toNeighbour)
-    {
-        origin = default;
-        toNeighbour = default;
-
-        (int dc, int dr)[] steps = [(1, 0), (-1, 0), (0, 1), (0, -1)];
-        int found = 0;
-        for (int c = 0; c <= maxCol; c++)
-        {
-            for (int r = 0; r <= maxRow; r++)
-            {
-                if (occupied.Contains((c, r)) || !IsCorner((c, r), maxCol, maxRow))
-                    continue;
-
-                foreach ((int dc, int dr) in steps)
-                {
-                    int nc = c + dc, nr = r + dr;
-                    if (nc < 0 || nc > maxCol || nr < 0 || nr > maxRow)
-                        continue;
-                    if (occupied.Contains((nc, nr)))
-                        continue;
-
-                    found++;
-                    origin = (c, r);
-                    toNeighbour = (dc, dr);
-                }
-            }
-        }
-
-        return found;
-    }
-
-    private bool IsCorner((int c, int r) v, int maxCol, int maxRow) =>
-        (v.c == 0 || v.c == maxCol) && (v.r == 0 || v.r == maxRow);
-
-    /// Rotation-only axis assignment from a corner (used only to flag a mirror-flip).
-    private ((double x, double y) xHat, (double x, double y) yHat) CornerRuleAxes(
-        (int c, int r) corner, int maxCol, int maxRow, (double x, double y) colHat, (double x, double y) rowHat)
-    {
-        (double x, double y) Neg((double x, double y) v) => (-v.x, -v.y);
-        if (corner == (0, maxRow)) return (colHat, Neg(rowHat));
-        if (corner == (0, 0)) return (rowHat, colHat);
-        if (corner == (maxCol, 0)) return (Neg(colHat), rowHat);
-        return (Neg(rowHat), Neg(colHat)); // (maxCol, maxRow)
-    }
-
-    private (double x, double y) OriginOfIndexSpace(
-        (double x, double y)[] points, int[] col, int[] row,
-        (double x, double y) colHat, (double x, double y) rowHat, double pitchPx)
-    {
-        double ox = 0, oy = 0;
-        for (int i = 0; i < points.Length; i++)
-        {
-            ox += points[i].x - (col[i] * pitchPx * colHat.x + row[i] * pitchPx * rowHat.x);
-            oy += points[i].y - (col[i] * pitchPx * colHat.y + row[i] * pitchPx * rowHat.y);
-        }
-        return (ox / points.Length, oy / points.Length);
-    }
-
-    private Geometry EstimateGeometry((double x, double y)[] points)
-    {
-        int n = points.Length;
-        double sum4Cos = 0, sum4Sin = 0;
-        var neighbourDistances = new List(n);
-
-        for (int i = 0; i < n; i++)
-        {
-            double best = double.MaxValue;
-            int bestJ = -1;
-            for (int j = 0; j < n; j++)
-            {
-                if (i == j)
-                    continue;
-                double dx = points[j].x - points[i].x;
-                double dy = points[j].y - points[i].y;
-                double d2 = dx * dx + dy * dy;
-                if (d2 < best)
-                {
-                    best = d2;
-                    bestJ = j;
-                }
-            }
-
-            double vx = points[bestJ].x - points[i].x;
-            double vy = points[bestJ].y - points[i].y;
-            neighbourDistances.Add(Math.Sqrt(vx * vx + vy * vy));
-            double angle = Math.Atan2(vy, vx);
-            sum4Cos += Math.Cos(4 * angle); // 4x maps the 90° grid symmetry onto a full circle
-            sum4Sin += Math.Sin(4 * angle);
-        }
-
-        double theta = Math.Atan2(sum4Sin, sum4Cos) / 4.0; // in (-45°, 45°]
-        var u = (x: Math.Cos(theta), y: Math.Sin(theta));
-        var v = (x: -Math.Sin(theta), y: Math.Cos(theta));
-        double cx = points.Average(p => p.x);
-        double cy = points.Average(p => p.y);
-        return new Geometry(u, v, Median(neighbourDistances), cx, cy);
-    }
-
-    private double Median(IReadOnlyList values)
-    {
-        var sorted = values.OrderBy(v => v).ToList();
-        int m = sorted.Count;
-        if (m == 0)
-            return 0;
-        return m % 2 == 1 ? sorted[m / 2] : (sorted[m / 2 - 1] + sorted[m / 2]) / 2.0;
-    }
-
-    private readonly record struct Geometry(
-        (double x, double y) U, (double x, double y) V, double PitchPx, double CentroidX, double CentroidY);
-}
diff --git a/src/ScanNTune.Core/Grids/GridMapping.cs b/src/ScanNTune.Core/Grids/GridMapping.cs
deleted file mode 100644
index b0890c8..0000000
--- a/src/ScanNTune.Core/Grids/GridMapping.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace ScanNTune.Core.Grids;
-
-/// 
-/// The grid mapper's output: the indexed correspondences plus the pose it resolved from the
-/// two-solid orientation marker (origin and the +X axis in pixel space). 
-/// is true when the marker showed the scan is mirror-flipped (already accounted for).
-/// 
-public sealed record GridMapping(
-    IReadOnlyList Points,
-    double OriginX,
-    double OriginY,
-    double XAxisX,
-    double XAxisY,
-    bool Flipped);
diff --git a/src/ScanNTune.Core/Grids/IGridMapper.cs b/src/ScanNTune.Core/Grids/IGridMapper.cs
deleted file mode 100644
index 901ae99..0000000
--- a/src/ScanNTune.Core/Grids/IGridMapper.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace ScanNTune.Core.Grids;
-
-/// 
-/// Assigns each detected ring its (col,row) index in the nominal grid and resolves the coupon's
-/// orientation from the solid origin fiducial, so results are correct at any scan rotation.
-/// 
-public interface IGridMapper
-{
-    GridMapping Map(IReadOnlyList rings, CouponSpec spec);
-}
diff --git a/src/ScanNTune.Core/ICouponAnalyzer.cs b/src/ScanNTune.Core/ICouponAnalyzer.cs
deleted file mode 100644
index 38d5bfb..0000000
--- a/src/ScanNTune.Core/ICouponAnalyzer.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.Core;
-
-/// Runs the full scan → calibration pipeline.
-public interface ICouponAnalyzer
-{
-    CalibrationResult Analyze(Mat image, AnalysisOptions options);
-
-    CalibrationResult Analyze(string imagePath, AnalysisOptions options);
-}
diff --git a/src/ScanNTune.Core/Orientation.cs b/src/ScanNTune.Core/Orientation.cs
deleted file mode 100644
index 3e4b0c3..0000000
--- a/src/ScanNTune.Core/Orientation.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// The coupon's pose in the image: where the origin fiducial sits and which way the printer's
-/// +X axis points (unit vector, pixel space, image-y downward).  is true
-/// when the coupon was scanned mirror-flipped (the two-solid marker resolves it automatically).
-/// 
-public sealed record Orientation(
-    bool Flipped,
-    double OriginX,
-    double OriginY,
-    double XAxisX,
-    double XAxisY)
-{
-    /// Angle of the +X axis in image degrees (0 = right, 90 = down).
-    public double XAxisAngleDegrees => Math.Atan2(XAxisY, XAxisX) * 180.0 / Math.PI;
-}
diff --git a/src/ScanNTune.Core/Output/Correction.cs b/src/ScanNTune.Core/Output/Correction.cs
deleted file mode 100644
index 36b7b7c..0000000
--- a/src/ScanNTune.Core/Output/Correction.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-namespace ScanNTune.Core.Output;
-
-/// 
-/// A ready-to-apply correction: the snippet to copy and a note on where it goes. Some flavours (Klipper
-/// skew) split into two separately-copyable snippets — the console commands and a start-g-code line — each
-/// with its own caption. When  is null there is just the one snippet.
-/// 
-public sealed record Correction(
-    string Code,
-    string Hint,
-    string? PrimaryCaption = null,
-    string? SecondaryCaption = null,
-    string? SecondaryCode = null);
diff --git a/src/ScanNTune.Core/Output/CorrectionFormatter.cs b/src/ScanNTune.Core/Output/CorrectionFormatter.cs
deleted file mode 100644
index 9b16a77..0000000
--- a/src/ScanNTune.Core/Output/CorrectionFormatter.cs
+++ /dev/null
@@ -1,125 +0,0 @@
-using System.Globalization;
-
-namespace ScanNTune.Core.Output;
-
-/// 
-/// Default correction formatter. Same maths as the Vector 3D "Califlower" calculator, but
-/// exposed per-flavour so each can be shown on its own: shrinkage = (1 + error)·100, part
-/// scale = (1 - error)·100, steps/mm scale by (1 - error), rotation distance by (1 + error),
-/// Marlin XY_SKEW_FACTOR = tan(skew), Klipper SET_SKEW from the baseline triangle.
-/// 
-public sealed class CorrectionFormatter : ICorrectionFormatter
-{
-    public const string Klipper = "Klipper";
-    public const string Marlin = "Marlin";
-    public const string RepRap = "RepRapFirmware";
-
-    public const string Shrinkage = "Shrinkage %";
-    public const string StepsPerMm = "Steps/mm";
-    public const string RotationDistance = "Rotation distance";
-    public const string Scale = "Scale %";
-
-    private readonly CultureInfo _inv = CultureInfo.InvariantCulture;
-
-    public IReadOnlyList SkewFlavours { get; } = [Klipper, Marlin, RepRap];
-
-    public IReadOnlyList SizeFlavours { get; } = [Shrinkage, StepsPerMm, RotationDistance, Scale];
-
-    public string? CurrentValueLabel(string sizeFlavour) => sizeFlavour switch
-    {
-        StepsPerMm => "current steps/mm",
-        RotationDistance => "current rot. dist.",
-        _ => null,
-    };
-
-    public Correction Skew(string flavour, double skewDegrees, CouponSpec coupon)
-    {
-        ArgumentNullException.ThrowIfNull(coupon);
-
-        // skewDegrees is the measured corner-angle error (angle − 90°). The shear the firmwares
-        // model, x' = x + tan·y, CLOSES the corner, so its coefficient is the negation of the
-        // angle error. All three emissions below are stated in terms of that shear coefficient
-        // and are verified against the firmware sources (klippy skew_correction.py applies
-        // x − y·factor; Marlin planner.h subtracts; RRF Move.cpp adds).
-        double tan = Math.Tan(-skewDegrees * Math.PI / 180.0);
-        if (!double.IsFinite(tan) || Math.Abs(skewDegrees) >= 45.0)
-            return new Correction("skew out of range, check the scan", "A real coupon skews well under 1°; this suggests a detection problem.");
-
-        switch (flavour)
-        {
-            case Marlin:
-                return new Correction(
-                    string.Format(_inv, "M852 I{0:0.000000}\nM500", tan),
-                    string.Format(_inv, "Send via console; M500 saves it. Or set #define XY_SKEW_FACTOR {0:0.000000} in Configuration.h.", tan));
-
-            case RepRap:
-                // RRF's user-to-machine transform ADDS tanXY·Y (Move.cpp AxisTransform), the
-                // opposite of Marlin's planner which subtracts — so RRF needs the negated factor.
-                return new Correction(
-                    string.Format(_inv, "M556 S100 X{0:0.000}", -100.0 * tan),
-                    "Add to config.g.");
-
-            default: // Klipper
-                double l = coupon.BaselineMm;
-                double ac = l * Math.Sqrt((1.0 + tan) * (1.0 + tan) + 1.0);
-                double bd = l * Math.Sqrt((tan - 1.0) * (tan - 1.0) + 1.0);
-                double ad = l * Math.Sqrt(tan * tan + 1.0);
-                return new Correction(
-                    string.Format(_inv, "SET_SKEW XY={0:0.###},{1:0.###},{2:0.###}\nSKEW_PROFILE SAVE=ScanNTune\nSAVE_CONFIG", ac, bd, ad),
-                    string.Empty,
-                    PrimaryCaption: "Paste into the Klipper console:",
-                    SecondaryCaption: "Add this to your start g-code:",
-                    SecondaryCode: "SKEW_PROFILE LOAD=ScanNTune");
-        }
-    }
-
-    public Correction Size(string flavour, double xScalePercent, double yScalePercent, double? currentX, double? currentY)
-    {
-        // A real printer's dimensional error is well under 2%; a reading beyond a few percent means
-        // a wrong DPI (a 2x mismatch reads ±50-100%) or a broken detection. Refusing to synthesize
-        // firmware commands from it matters: at +100% the steps/mm branch would emit M92 X0.000.
-        if (!double.IsFinite(xScalePercent) || !double.IsFinite(yScalePercent)
-            || Math.Abs(xScalePercent) >= 10.0 || Math.Abs(yScalePercent) >= 10.0)
-            return new Correction(
-                "scale out of range, check the scan and DPI",
-                "A real printer errs well under 2%; this suggests the scan DPI doesn't match the calibration, or a detection problem.");
-
-        double xf = xScalePercent / 100.0;
-        double yf = yScalePercent / 100.0;
-        double avg = (xf + yf) / 2.0;
-
-        // The exact correction is the nominal/measured ratio: new = current / (1 + error). The
-        // first-order form current × (1 − error) leaves an error² residual, so the ratio is used
-        // throughout (shrinkage and rotation distance are already exact in their (1 + error) form).
-        switch (flavour)
-        {
-            case StepsPerMm:
-                if (currentX is { } sx && currentY is { } sy)
-                    return new Correction(
-                        string.Format(_inv, "M92 X{0:0.000} Y{1:0.000}\nM500", sx / (1.0 + xf), sy / (1.0 + yf)),
-                        "Send via console; M500 saves (Marlin). On Klipper use the Rotation distance flavour.");
-                return new Correction(
-                    "enter current steps/mm above",
-                    "New = current / (1 + error), per axis.");
-
-            case RotationDistance:
-                if (currentX is { } rx && currentY is { } ry)
-                    return new Correction(
-                        string.Format(_inv, "X {0:0.0000}   Y {1:0.0000}", (1.0 + xf) * rx, (1.0 + yf) * ry),
-                        "Set rotation_distance in printer.cfg (Klipper).");
-                return new Correction(
-                    "enter current rotation distance above",
-                    "New = current × (1 + error), per axis.");
-
-            case Scale:
-                return new Correction(
-                    string.Format(_inv, "X {0:0.00} %   Y {1:0.00} %", 100.0 / (1.0 + xf), 100.0 / (1.0 + yf)),
-                    "Scale the model per-axis in your slicer (X and Y can differ).");
-
-            default: // Shrinkage
-                return new Correction(
-                    string.Format(_inv, "XY shrinkage: {0:0.00} %", (1.0 + avg) * 100.0),
-                    "OrcaSlicer / SuperSlicer: Filament → Advanced → Shrinkage compensation (XY). Single value; use Steps/mm for per-axis.");
-        }
-    }
-}
diff --git a/src/ScanNTune.Core/Output/ICorrectionFormatter.cs b/src/ScanNTune.Core/Output/ICorrectionFormatter.cs
deleted file mode 100644
index 97a6cfa..0000000
--- a/src/ScanNTune.Core/Output/ICorrectionFormatter.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace ScanNTune.Core.Output;
-
-/// 
-/// Produces firmware/slicer corrections on demand for a chosen "flavour", so the UI can show
-/// just the one the user needs. Size flavours that need the printer's current values
-/// (steps/mm, rotation distance) recompute when those values change.
-/// 
-public interface ICorrectionFormatter
-{
-    IReadOnlyList SkewFlavours { get; }
-
-    IReadOnlyList SizeFlavours { get; }
-
-    /// 
-    /// The label for the "current values" inputs a size flavour needs (e.g. "current steps/mm"),
-    /// or null if the flavour needs none. Keeps flavour-specific UI text in the formatter.
-    /// 
-    string? CurrentValueLabel(string sizeFlavour);
-
-    Correction Skew(string flavour, double skewDegrees, CouponSpec coupon);
-
-    Correction Size(string flavour, double xScalePercent, double yScalePercent, double? currentX, double? currentY);
-}
diff --git a/src/ScanNTune.Core/Output/IOverlayRenderer.cs b/src/ScanNTune.Core/Output/IOverlayRenderer.cs
deleted file mode 100644
index 90e0735..0000000
--- a/src/ScanNTune.Core/Output/IOverlayRenderer.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.Core.Output;
-
-/// Draws the detected rings and resolved orientation over a scan.
-public interface IOverlayRenderer
-{
-    /// 
-    /// Draws the rings, origin and +X axis over a copy of the scan and crops to the content, returning the
-    /// annotated BGR image. Uses only OpenCV drawing (no image codec), so it works in the browser's wasm
-    /// OpenCV build where the caller turns the Mat into a bitmap directly rather than encoding a PNG.
-    /// 
-    Mat RenderOverlay(Mat image, CalibrationResult result);
-
-    /// Draws only the detected rings (no orientation), for a scan that failed to resolve.
-    Mat RenderDetectionOverlay(Mat image, IReadOnlyList rings);
-}
diff --git a/src/ScanNTune.Core/Output/OverlayRenderer.cs b/src/ScanNTune.Core/Output/OverlayRenderer.cs
deleted file mode 100644
index 0c7344e..0000000
--- a/src/ScanNTune.Core/Output/OverlayRenderer.cs
+++ /dev/null
@@ -1,137 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.Core.Output;
-
-/// 
-/// Default overlay renderer. Draws each detected ring (green plus a centre dot), and for a full result
-/// the resolved origin (red) and +X axis arrow (cyan), over a copy of the scan cropped to the detected
-/// coupon. Uses only OpenCV drawing (no image codec), so it runs in the browser's wasm build too.
-/// 
-public sealed class OverlayRenderer : IOverlayRenderer
-{
-    private readonly Scalar _ringColor = new(0, 255, 0);     // green (BGR)
-    private readonly Scalar _centerColor = new(0, 255, 255); // yellow
-    private readonly Scalar _originColor = new(0, 0, 255);   // red
-    private readonly Scalar _axisColor = new(255, 255, 0);   // cyan
-
-    public Mat RenderOverlay(Mat image, CalibrationResult result)
-    {
-        ArgumentNullException.ThrowIfNull(image);
-        ArgumentNullException.ThrowIfNull(result);
-
-        Mat canvas = ToBgr(image);
-        int thickness = Thickness(image);
-        DrawRings(canvas, result.Rings, thickness);
-
-        Orientation orientation = result.Orientation;
-        double axisLength = MedianRadius(result.Rings) * 6.0;
-        if (axisLength <= 0)
-            axisLength = Math.Max(image.Width, image.Height) * 0.15;
-
-        // Fixed-point (sub-pixel) coordinates so the markers land on the true fractional centre.
-        const int shift = 3, scale = 1 << shift;
-        Point origin = Fixed(orientation.OriginX, orientation.OriginY, scale);
-        Point axisEnd = Fixed(
-            orientation.OriginX + orientation.XAxisX * axisLength,
-            orientation.OriginY + orientation.XAxisY * axisLength, scale);
-
-        Cv2.Circle(canvas, origin, thickness * 3 * scale, _originColor, thickness, LineTypes.AntiAlias, shift);
-        Cv2.ArrowedLine(canvas, origin, axisEnd, _axisColor, thickness + 1, LineTypes.AntiAlias, shift, tipLength: 0.2);
-
-        return Crop(canvas, result.Rings, orientation);
-    }
-
-    public Mat RenderDetectionOverlay(Mat image, IReadOnlyList rings)
-    {
-        ArgumentNullException.ThrowIfNull(image);
-        ArgumentNullException.ThrowIfNull(rings);
-
-        Mat canvas = ToBgr(image);
-        DrawRings(canvas, rings, Thickness(image));
-        return Crop(canvas, rings, orientation: null);
-    }
-
-    private Mat ToBgr(Mat image)
-    {
-        var canvas = new Mat();
-        if (image.Channels() == 1)
-            Cv2.CvtColor(image, canvas, ColorConversionCodes.GRAY2BGR);
-        else
-            image.CopyTo(canvas);
-        return canvas;
-    }
-
-    private int Thickness(Mat image) => Math.Max(1, (int)Math.Round(Math.Max(image.Width, image.Height) / 500.0));
-
-    private void DrawRings(Mat canvas, IReadOnlyList rings, int thickness)
-    {
-        // Fixed-point (sub-pixel) coordinates so the ring outline and centre dot land on the true
-        // fractional centre rather than the nearest whole pixel.
-        const int shift = 3, scale = 1 << shift;
-        foreach (DetectedRing ring in rings)
-        {
-            Point center = Fixed(ring.CenterX, ring.CenterY, scale);
-            Cv2.Circle(canvas, center, (int)Math.Round(ring.RadiusPx * scale), _ringColor, thickness, LineTypes.AntiAlias, shift);
-            Cv2.Circle(canvas, center, (thickness + 1) * scale, _centerColor, -1, LineTypes.AntiAlias, shift);
-        }
-    }
-
-    private Point Fixed(double x, double y, int scale) =>
-        new((int)Math.Round(x * scale), (int)Math.Round(y * scale));
-
-    // Crops to the content and takes ownership of the canvas: returns either the canvas itself (nothing
-    // detected) or a new cropped Mat, disposing the original in the latter case. The caller owns the result.
-    private Mat Crop(Mat canvas, IReadOnlyList rings, Orientation? orientation)
-    {
-        Mat cropped = CropToContent(canvas, rings, orientation);
-        if (!ReferenceEquals(cropped, canvas))
-            canvas.Dispose();
-        return cropped;
-    }
-
-    /// 
-    /// Returns a crop of  tight around the detected rings (plus the +X
-    /// arrow when an orientation is given) with a small margin, or the canvas itself when nothing
-    /// was detected.
-    /// 
-    private Mat CropToContent(Mat canvas, IReadOnlyList rings, Orientation? orientation)
-    {
-        if (rings.Count == 0)
-            return canvas;
-
-        double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue;
-        foreach (DetectedRing ring in rings)
-        {
-            minX = Math.Min(minX, ring.CenterX - ring.RadiusPx);
-            maxX = Math.Max(maxX, ring.CenterX + ring.RadiusPx);
-            minY = Math.Min(minY, ring.CenterY - ring.RadiusPx);
-            maxY = Math.Max(maxY, ring.CenterY + ring.RadiusPx);
-        }
-
-        if (orientation is { } o)
-        {
-            double axisLength = MedianRadius(rings) * 6.0;
-            minX = Math.Min(minX, o.OriginX);
-            minY = Math.Min(minY, o.OriginY);
-            maxX = Math.Max(maxX, o.OriginX + o.XAxisX * axisLength);
-            maxY = Math.Max(maxY, o.OriginY + o.XAxisY * axisLength);
-        }
-
-        double margin = Math.Max(MedianRadius(rings) * 1.2, (maxX - minX) * 0.05);
-        int x0 = Math.Clamp((int)Math.Floor(minX - margin), 0, canvas.Width - 1);
-        int y0 = Math.Clamp((int)Math.Floor(minY - margin), 0, canvas.Height - 1);
-        int x1 = Math.Clamp((int)Math.Ceiling(maxX + margin), x0 + 1, canvas.Width);
-        int y1 = Math.Clamp((int)Math.Ceiling(maxY + margin), y0 + 1, canvas.Height);
-
-        return new Mat(canvas, new Rect(x0, y0, x1 - x0, y1 - y0)).Clone();
-    }
-
-    private double MedianRadius(IReadOnlyList rings)
-    {
-        if (rings.Count == 0)
-            return 0;
-        var sorted = rings.Select(r => r.RadiusPx).OrderBy(r => r).ToList();
-        int n = sorted.Count;
-        return n % 2 == 1 ? sorted[n / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0;
-    }
-}
diff --git a/src/ScanNTune.Core/ScanNTune.Core.csproj b/src/ScanNTune.Core/ScanNTune.Core.csproj
deleted file mode 100644
index 0627b3c..0000000
--- a/src/ScanNTune.Core/ScanNTune.Core.csproj
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-  
-    net10.0
-    enable
-    enable
-  
-
-  
-    
-    
-    
-    
-  
-
-
diff --git a/src/ScanNTune.Core/ScannerDiagnostic.cs b/src/ScanNTune.Core/ScannerDiagnostic.cs
deleted file mode 100644
index b6cf5a2..0000000
--- a/src/ScanNTune.Core/ScannerDiagnostic.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// The scanner's own systematic geometric error, recovered as the half-difference between two
-/// quarter-turn scans.  is how much more the scanner stretches one
-/// bed axis than the other (positive = the axis that was along the first scan's +X reads larger);
-///  is the scanner's own axis skew. Both are cancelled from the printer
-/// result — they are reported only as a health readout for the scanner.
-/// 
-public sealed record ScannerDiagnostic(double AnisotropyPercent, double SkewDegrees);
diff --git a/src/ScanNTune.Core/Solving/AffineSolver.cs b/src/ScanNTune.Core/Solving/AffineSolver.cs
deleted file mode 100644
index 26db745..0000000
--- a/src/ScanNTune.Core/Solving/AffineSolver.cs
+++ /dev/null
@@ -1,154 +0,0 @@
-using MathNet.Numerics.LinearAlgebra;
-
-namespace ScanNTune.Core.Solving;
-
-/// 
-/// Solves the over-determined system mapping nominal millimetres to measured pixels:
-///   px = a·mx + b·my + tx
-///   py = c·mx + d·my + ty
-/// The X and Y rows share the same per-point weights, so two 3-parameter fits suffice. The 2x2
-/// linear part is then decomposed into per-axis scale and the skew (departure from 90°).
-///
-/// The fit is robust by default: after an initial least-squares pass it re-weights each ring by a
-/// Huber function of its residual and refits a few times, so a hole whose centre was corrupted by
-/// stringing/shadow (several px off) is down-weighted instead of dragging the whole fit. With clean
-/// data no point is down-weighted, so it reduces to ordinary least squares.
-/// 
-public sealed class AffineSolver : IAffineSolver
-{
-    private readonly bool _robust;
-    private readonly double _huberTune;
-    private readonly int _iterations;
-
-    // The residuals weighted here are 2D norms, so the tuning constant is set on the Rayleigh
-    // distribution those norms follow under isotropic Gaussian noise: sqrt(2·ln 20) ≈ 2.4477 is
-    // the Rayleigh 95th percentile (in per-axis sigma units), so ~5% of clean points are
-    // down-weighted — the 2D analogue of Huber's 1D 1.345 at ~95% efficiency. A distribution
-    // property, not a value fitted to any scan.
-    public AffineSolver(bool robust = true, double huberTune = 2.4477, int iterations = 4)
-    {
-        _robust = robust;
-        _huberTune = huberTune;
-        _iterations = iterations;
-    }
-
-    public AffineModel Solve(IReadOnlyList correspondences)
-    {
-        ArgumentNullException.ThrowIfNull(correspondences);
-        int n = correspondences.Count;
-        if (n < 3)
-            throw new InvalidOperationException($"Need at least 3 correspondences, got {n}.");
-
-        var design = Matrix.Build.Dense(n, 3);
-        var px = Vector.Build.Dense(n);
-        var py = Vector.Build.Dense(n);
-        for (int i = 0; i < n; i++)
-        {
-            GridCorrespondence c = correspondences[i];
-            design[i, 0] = c.NominalXmm;
-            design[i, 1] = c.NominalYmm;
-            design[i, 2] = 1.0;
-            px[i] = c.MeasuredXpx;
-            py[i] = c.MeasuredYpx;
-        }
-
-        var weights = Vector.Build.Dense(n, 1.0);
-        Vector cx = WeightedSolve(design, px, weights);
-        Vector cy = WeightedSolve(design, py, weights);
-
-        if (_robust)
-        {
-            for (int iter = 0; iter < _iterations; iter++)
-            {
-                if (!UpdateWeights(design, px, py, cx, cy, weights))
-                    break; // residuals are uniform (no outliers) — nothing to down-weight
-                cx = WeightedSolve(design, px, weights);
-                cy = WeightedSolve(design, py, weights);
-            }
-        }
-
-        double a = cx[0], b = cx[1], tx = cx[2];
-        double c2 = cy[0], d = cy[1], ty = cy[2];
-
-        double scaleX = Math.Sqrt(a * a + c2 * c2);
-        double scaleY = Math.Sqrt(b * b + d * d);
-
-        // Skew is reported as the measured ERROR, like the scale figures: the X/Y corner angle
-        // minus its nominal 90°. Positive = the corner opened past square, negative = it closed
-        // (the part sheared x' = x + t·y). The firmware shear factor is the NEGATION of this
-        // error — that conversion lives in CorrectionFormatter, at the firmware boundary. The dot
-        // product is invariant under rotation and reflection, so the sign holds at any pose,
-        // mirrored or not.
-        double cosBetween = (a * b + c2 * d) / (scaleX * scaleY);
-        cosBetween = Math.Clamp(cosBetween, -1.0, 1.0);
-        double skewDegrees = Math.Acos(cosBetween) * 180.0 / Math.PI - 90.0;
-
-        // Report the UNWEIGHTED RMS over every hole, not the weighted (inlier-only) residual: this
-        // number is the "is the part actually a uniform affine deformation?" signal. A non-affine
-        // defect (gantry warp, thermal bow) shows up only here, so down-weighting the very holes
-        // that reveal it would hide it. The robust weights above still drive the parameter fit; the
-        // reported residual just measures every hole honestly against that fit. On clean data all
-        // weights are 1, so this equals the ordinary least-squares residual.
-        double sumSq = 0;
-        for (int i = 0; i < n; i++)
-        {
-            GridCorrespondence p = correspondences[i];
-            double ex = a * p.NominalXmm + b * p.NominalYmm + tx - p.MeasuredXpx;
-            double ey = c2 * p.NominalXmm + d * p.NominalYmm + ty - p.MeasuredYpx;
-            sumSq += ex * ex + ey * ey;
-        }
-        double rms = Math.Sqrt(sumSq / n);
-
-        return new AffineModel(scaleX, scaleY, skewDegrees, rms, n);
-    }
-
-    private Vector WeightedSolve(Matrix design, Vector target, Vector weights)
-    {
-        int n = design.RowCount;
-        var wDesign = Matrix.Build.Dense(n, design.ColumnCount);
-        var wTarget = Vector.Build.Dense(n);
-        for (int i = 0; i < n; i++)
-        {
-            double s = Math.Sqrt(weights[i]);
-            wDesign[i, 0] = design[i, 0] * s;
-            wDesign[i, 1] = design[i, 1] * s;
-            wDesign[i, 2] = design[i, 2] * s;
-            wTarget[i] = target[i] * s;
-        }
-        return wDesign.QR().Solve(wTarget);
-    }
-
-    /// 
-    /// Recomputes Huber weights from the current residuals. Returns false (leaving weights unchanged)
-    /// when the residual scale is ~0, i.e. a clean fit with nothing to down-weight.
-    /// 
-    private bool UpdateWeights(Matrix design, Vector px, Vector py,
-        Vector cx, Vector cy, Vector weights)
-    {
-        int n = design.RowCount;
-        var residuals = new double[n];
-        for (int i = 0; i < n; i++)
-        {
-            double ex = design[i, 0] * cx[0] + design[i, 1] * cx[1] + cx[2] - px[i];
-            double ey = design[i, 0] * cy[0] + design[i, 1] * cy[1] + cy[2] - py[i];
-            residuals[i] = Math.Sqrt(ex * ex + ey * ey);
-        }
-
-        // Robust scale from the median of the 2D residual norms. Under isotropic Gaussian noise
-        // the norm is Rayleigh-distributed with median sigma·sqrt(2·ln 2), so dividing by that
-        // constant makes the estimate consistent for the per-axis sigma (the 1D MAD constant
-        // 1.4826 would overestimate it by ~1.75x and blunt the down-weighting). Average the two
-        // central order statistics for even n so the median is unbiased regardless of hole count.
-        var sorted = (double[])residuals.Clone();
-        Array.Sort(sorted);
-        double median = n % 2 == 1 ? sorted[n / 2] : 0.5 * (sorted[n / 2 - 1] + sorted[n / 2]);
-        double sigma = median / Math.Sqrt(2.0 * Math.Log(2.0));
-        if (sigma < 1e-6)
-            return false;
-
-        double threshold = _huberTune * sigma;
-        for (int i = 0; i < n; i++)
-            weights[i] = residuals[i] <= threshold ? 1.0 : threshold / residuals[i];
-        return true;
-    }
-}
diff --git a/src/ScanNTune.Core/Solving/IAffineSolver.cs b/src/ScanNTune.Core/Solving/IAffineSolver.cs
deleted file mode 100644
index 71a58c8..0000000
--- a/src/ScanNTune.Core/Solving/IAffineSolver.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace ScanNTune.Core.Solving;
-
-/// Least-squares fit of the nominal-mm → measured-px affine map.
-public interface IAffineSolver
-{
-    AffineModel Solve(IReadOnlyList correspondences);
-}
diff --git a/src/ScanNTune.Core/Storage/JsonKeyValueStore.cs b/src/ScanNTune.Core/Storage/JsonKeyValueStore.cs
deleted file mode 100644
index 18eb9c6..0000000
--- a/src/ScanNTune.Core/Storage/JsonKeyValueStore.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-using System.Collections.Concurrent;
-using System.Text.Json;
-using Microsoft.Extensions.Logging;
-
-namespace ScanNTune.Core.Storage;
-
-public interface IKeyValueStore
-{
-    T? GetValue(string key, T? defaultValue = default);
-
-    void SetValue(string key, T value);
-}
-
-/// 
-/// A tiny JSON-backed key/value store: one Settings.json under the given directory, loaded lazily and
-/// rewritten on every set. An unreadable or corrupt file is logged and treated as empty (a fresh start) rather
-/// than throwing; a failed write is logged and rethrown so the caller can decide how to recover.
-/// 
-public sealed class JsonKeyValueStore : IKeyValueStore
-{
-    private readonly Lazy> _data;
-    private readonly string _directoryPath;
-    private readonly string _settingsFilePath;
-    private readonly ILogger? _logger;
-
-    public JsonKeyValueStore(string path, ILogger? logger = null)
-    {
-        _directoryPath = path;
-        _settingsFilePath = Path.Combine(_directoryPath, "Settings.json");
-        _logger = logger;
-        _data = new Lazy>(ValueFactory);
-    }
-
-    private ConcurrentDictionary ValueFactory()
-    {
-        try
-        {
-            if (!Directory.Exists(_directoryPath))
-                Directory.CreateDirectory(_directoryPath);
-
-            return File.Exists(_settingsFilePath)
-                ? JsonSerializer.Deserialize>(File.ReadAllText(_settingsFilePath)) ?? new()
-                : new();
-        }
-        catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
-        {
-            _logger?.LogWarning(ex, "Could not read settings from {Path}; starting from an empty store.", _settingsFilePath);
-            return new();
-        }
-    }
-
-    public T? GetValue(string key, T? defaultValue = default)
-    {
-        if (!_data.Value.TryGetValue(key, out var value))
-            return defaultValue ?? default;
-
-        if (value is JsonElement element)
-            return element.Deserialize();
-
-        return (T?)value;
-    }
-
-    public void SetValue(string key, T value)
-    {
-        _data.Value[key] = value;
-        try
-        {
-            File.WriteAllText(_settingsFilePath, JsonSerializer.Serialize(_data.Value, new JsonSerializerOptions { WriteIndented = true }));
-        }
-        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
-        {
-            _logger?.LogWarning(ex, "Could not write settings to {Path}.", _settingsFilePath);
-            throw;
-        }
-    }
-}
diff --git a/src/ScanNTune.Core/TwoScanResult.cs b/src/ScanNTune.Core/TwoScanResult.cs
deleted file mode 100644
index f02e3c3..0000000
--- a/src/ScanNTune.Core/TwoScanResult.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-namespace ScanNTune.Core;
-
-/// 
-/// The result of combining two quarter-turn scans of the same coupon.  holds
-/// the printer's X/Y scale and skew with the scanner's own anisotropy and skew averaged out (the
-/// value the user should act on);  is the scanner's error, recovered as a free
-/// diagnostic.  and  are the untouched per-scan results (for
-/// overlays and drill-down).  is how far the coupon actually
-/// turned between the two scans — it should be ~90°;  is false when
-/// it is not, which means the scanner error could not cancel and the combined figures cannot be
-/// trusted.  is the specific case where one scan is mirror-flipped
-/// relative to the other (the coupon was turned over between scans): the skew cancellation is
-/// broken even though the turn itself may read ~90°.
-/// 
-public sealed record TwoScanResult(
-    CalibrationResult Combined,
-    ScannerDiagnostic Scanner,
-    CalibrationResult ScanA,
-    CalibrationResult ScanB,
-    double RelativeRotationDegrees,
-    bool RotationLooksValid,
-    bool FlipMismatch = false);
diff --git a/src/ScanNTune.Core/Updates/IAppUpdater.cs b/src/ScanNTune.Core/Updates/IAppUpdater.cs
deleted file mode 100644
index e660d3d..0000000
--- a/src/ScanNTune.Core/Updates/IAppUpdater.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace ScanNTune.Core.Updates;
-
-/// 
-/// Background auto-update port. The implementation checks a release feed, downloads a pending update,
-/// and stages it to apply on exit — the UI stays headless-testable by depending only on this.
-/// 
-public interface IAppUpdater
-{
-    Task CheckForUpdateAsync();
-
-    Task DownloadUpdateAsync();
-
-    void ApplyUpdateOnExit();
-}
diff --git a/src/ScanNTune.Core/Updates/UpdateCheck.cs b/src/ScanNTune.Core/Updates/UpdateCheck.cs
deleted file mode 100644
index c8ba78c..0000000
--- a/src/ScanNTune.Core/Updates/UpdateCheck.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-using Microsoft.Extensions.Logging;
-using ScanNTune.Core.Storage;
-
-namespace ScanNTune.Core.Updates;
-
-/// 
-/// Runs the background update flow once at startup: check → download → stage for next restart. The updater
-/// is built through a factory so a construction fault is caught by the same try as the rest. Every step and
-/// every failure is logged (the logger is required, never null), and any exception is swallowed so
-/// a flaky network or offline machine never blocks the app from opening. All awaits use
-/// ConfigureAwait(false) so the download continuations stay off the UI thread.
-/// 
-public sealed class UpdateCheck
-{
-    // GitHub throttles unauthenticated release-feed calls to 60/hour per IP; checking at most once an hour keeps
-    // us far under that no matter how often the app is launched.
-    private const double MinIntervalHours = 1;
-    private const string LastCheckedKey = "update.lastCheckedUtc";
-
-    private readonly Func _updaterFactory;
-    private readonly IKeyValueStore _store;
-    private readonly ILogger _logger;
-
-    public UpdateCheck(Func updaterFactory, IKeyValueStore store, ILogger logger)
-    {
-        _updaterFactory = updaterFactory;
-        _store = store;
-        _logger = logger;
-    }
-
-    public async Task RunAsync()
-    {
-        try
-        {
-            DateTimeOffset now = DateTimeOffset.UtcNow;
-            DateTimeOffset lastChecked = _store.GetValue(LastCheckedKey);
-            if (now - lastChecked < TimeSpan.FromHours(MinIntervalHours))
-            {
-                _logger.LogInformation("Skipping update check; last ran {LastChecked:o}, within the {Hours}h minimum interval.",
-                    lastChecked, MinIntervalHours);
-                return UpdateOutcome.UpToDate;
-            }
-
-            var updater = _updaterFactory();
-
-            _logger.LogInformation("Checking for updates…");
-            // Record the attempt before the network call so a failure (e.g. a 403 rate-limit) still waits the full
-            // hour rather than retrying on the next launch.
-            _store.SetValue(LastCheckedKey, now);
-
-            if (!await updater.CheckForUpdateAsync().ConfigureAwait(false))
-            {
-                _logger.LogInformation("No update available.");
-                return UpdateOutcome.UpToDate;
-            }
-
-            _logger.LogInformation("Update found; downloading…");
-            await updater.DownloadUpdateAsync().ConfigureAwait(false);
-
-            updater.ApplyUpdateOnExit();
-            _logger.LogInformation("Update downloaded and staged; it applies on the next restart.");
-            return UpdateOutcome.UpdateStaged;
-        }
-        catch (Exception exception)
-        {
-            _logger.LogError(exception, "Background update check failed.");
-            return UpdateOutcome.Failed;
-        }
-    }
-}
diff --git a/src/ScanNTune.Core/Updates/UpdateOutcome.cs b/src/ScanNTune.Core/Updates/UpdateOutcome.cs
deleted file mode 100644
index 077eb53..0000000
--- a/src/ScanNTune.Core/Updates/UpdateOutcome.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace ScanNTune.Core.Updates;
-
-/// Result of a background  run, so the UI can surface a "restart to update" cue.
-public enum UpdateOutcome
-{
-    /// Not an installed build (dev run), or no newer version was available.
-    UpToDate,
-
-    /// A newer version was downloaded and staged; it applies on the next restart.
-    UpdateStaged,
-
-    /// The check or download failed; details were logged.
-    Failed,
-}
diff --git a/src/ScanNTune.Tests/AffineSolverTests.cs b/src/ScanNTune.Tests/AffineSolverTests.cs
deleted file mode 100644
index 02bf9aa..0000000
--- a/src/ScanNTune.Tests/AffineSolverTests.cs
+++ /dev/null
@@ -1,114 +0,0 @@
-using ScanNTune.Core;
-using ScanNTune.Core.Solving;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// The affine fit must resist a single badly-detected ring (centre corrupted by stringing/shadow)
-/// without letting it drag the scale/skew, while leaving clean data exactly as ordinary least
-/// squares would.
-/// 
-[TestFixture]
-public class AffineSolverTests
-{
-    private const double Kx = 10.1;   // true px/mm along X
-    private const double Ky = 9.9;    // true px/mm along Y
-    private const double Pitch = 25.0;
-
-    [Test]
-    public void RobustFitResistsOneOutlier()
-    {
-        List pts = PerfectGrid();
-        // Corrupt one ring's centre by ~30 px, as a real stringing/shadow artefact would.
-        GridCorrespondence bad = pts[7];
-        pts[7] = bad with { MeasuredXpx = bad.MeasuredXpx + 30.0, MeasuredYpx = bad.MeasuredYpx + 25.0 };
-
-        AffineModel robust = new AffineSolver().Solve(pts);
-        AffineModel plain = new AffineSolver(robust: false).Solve(pts);
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(robust.ScaleXPxPerMm, Is.EqualTo(Kx).Within(0.03), "robust recovers X scale");
-            Assert.That(robust.ScaleYPxPerMm, Is.EqualTo(Ky).Within(0.03), "robust recovers Y scale");
-            Assert.That(robust.SkewDegrees, Is.EqualTo(0.0).Within(0.05), "robust recovers zero skew");
-            // The plain fit is measurably dragged by the outlier; the robust fit is much closer.
-            Assert.That(Math.Abs(plain.ScaleXPxPerMm - Kx),
-                Is.GreaterThan(Math.Abs(robust.ScaleXPxPerMm - Kx) + 0.02), "robust must beat plain LS here");
-        });
-    }
-
-    [Test]
-    public void RobustFitResistsHighLeverageCornerOutlier()
-    {
-        List pts = PerfectGrid();
-        // The far corner (4,4) — the last point added — has the highest leverage on an affine fit,
-        // so a corrupted centre there drags scale/skew the most: the hardest case for the robust fit.
-        GridCorrespondence bad = pts[24];
-        pts[24] = bad with { MeasuredXpx = bad.MeasuredXpx - 28.0, MeasuredYpx = bad.MeasuredYpx + 26.0 };
-
-        AffineModel robust = new AffineSolver().Solve(pts);
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(robust.ScaleXPxPerMm, Is.EqualTo(Kx).Within(0.03), "robust recovers X scale despite corner outlier");
-            Assert.That(robust.ScaleYPxPerMm, Is.EqualTo(Ky).Within(0.03), "robust recovers Y scale despite corner outlier");
-            Assert.That(robust.SkewDegrees, Is.EqualTo(0.0).Within(0.05), "robust recovers zero skew despite corner outlier");
-        });
-    }
-
-    [Test]
-    public void RobustReweightingLeavesNearCleanDataCloseToPlain()
-    {
-        // Small deterministic sub-pixel scatter on every hole and NO gross outlier. Residuals are
-        // nonzero, so this actually drives the IRLS reweighting loop (the sigma~0 early-out does not
-        // fire); with no true outlier the robust fit must stay essentially on top of plain LS.
-        List pts = PerfectGrid();
-        for (int k = 0; k < pts.Count; k++)
-        {
-            GridCorrespondence p = pts[k];
-            double dx = 0.4 * (((k * 7) % 5) - 2);   // deterministic, within [-0.8, +0.8] px
-            double dy = 0.4 * (((k * 3) % 5) - 2);
-            pts[k] = p with { MeasuredXpx = p.MeasuredXpx + dx, MeasuredYpx = p.MeasuredYpx + dy };
-        }
-
-        AffineModel robust = new AffineSolver().Solve(pts);
-        AffineModel plain = new AffineSolver(robust: false).Solve(pts);
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(robust.ScaleXPxPerMm, Is.EqualTo(plain.ScaleXPxPerMm).Within(0.05), "robust ~ plain when no gross outlier");
-            Assert.That(robust.ScaleYPxPerMm, Is.EqualTo(plain.ScaleYPxPerMm).Within(0.05));
-            Assert.That(robust.SkewDegrees, Is.EqualTo(plain.SkewDegrees).Within(0.05));
-        });
-    }
-
-    [Test]
-    public void CleanDataMatchesPlainLeastSquares()
-    {
-        List pts = PerfectGrid();
-
-        AffineModel robust = new AffineSolver().Solve(pts);
-        AffineModel plain = new AffineSolver(robust: false).Solve(pts);
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(robust.ScaleXPxPerMm, Is.EqualTo(plain.ScaleXPxPerMm).Within(1e-9));
-            Assert.That(robust.ScaleYPxPerMm, Is.EqualTo(plain.ScaleYPxPerMm).Within(1e-9));
-            Assert.That(robust.SkewDegrees, Is.EqualTo(plain.SkewDegrees).Within(1e-9));
-        });
-    }
-
-    private List PerfectGrid()
-    {
-        var pts = new List();
-        for (int i = 0; i < 5; i++)
-        {
-            for (int j = 0; j < 5; j++)
-            {
-                double nx = i * Pitch, ny = j * Pitch;
-                pts.Add(new GridCorrespondence(i, j, nx, ny, nx * Kx, ny * Ky));
-            }
-        }
-        return pts;
-    }
-}
diff --git a/src/ScanNTune.Tests/CalibrationStoreTests.cs b/src/ScanNTune.Tests/CalibrationStoreTests.cs
deleted file mode 100644
index cb34f40..0000000
--- a/src/ScanNTune.Tests/CalibrationStoreTests.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using ScanNTune.Core.Calibration;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// The stored calibration must round-trip through JSON unchanged, treat a missing/empty file as
-/// "not calibrated", and apply the scanner's scale error correctly at any DPI.
-/// 
-[TestFixture]
-public class CalibrationStoreTests
-{
-    [Test]
-    public void SaveThenLoad_RoundTrips()
-    {
-        string path = Path.GetTempFileName(); // exists but empty → first Load is "uncalibrated"
-        try
-        {
-            var store = new JsonCalibrationStore(path);
-            Assert.That(store.Load(), Is.Null, "an empty/corrupt file reads as not calibrated");
-
-            var cal = new ScannerCalibration(
-                PxPerMm: 23.5969, Dpi: 600, ReferenceMm: 85.5, MeasuredWidthPx: 2017.5,
-                StraightnessPx: 0.297, ParallelismDegrees: 0.0018,
-                CalibratedUtc: new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc));
-            store.Save(cal);
-
-            ScannerCalibration? loaded = store.Load();
-            Assert.That(loaded, Is.Not.Null);
-            Assert.Multiple(() =>
-            {
-                Assert.That(loaded!.PxPerMm, Is.EqualTo(cal.PxPerMm).Within(1e-9));
-                Assert.That(loaded.Dpi, Is.EqualTo(cal.Dpi).Within(1e-9));
-                Assert.That(loaded.ReferenceMm, Is.EqualTo(cal.ReferenceMm).Within(1e-9));
-                Assert.That(loaded.MeasuredWidthPx, Is.EqualTo(cal.MeasuredWidthPx).Within(1e-9));
-                Assert.That(loaded.CalibratedUtc, Is.EqualTo(cal.CalibratedUtc));
-            });
-
-            store.Clear();
-            Assert.That(store.Load(), Is.Null, "cleared calibration is gone");
-        }
-        finally
-        {
-            if (File.Exists(path))
-                File.Delete(path);
-        }
-    }
-
-    [Test]
-    public void PxPerMmAtDpi_AppliesScannerErrorAtAnyDpi()
-    {
-        var cal = new ScannerCalibration(23.5969, 600, 85.5, 2017.5, 0.3, 0.002, DateTime.UtcNow);
-        double factor = 23.5969 / (600.0 / 25.4);
-        Assert.Multiple(() =>
-        {
-            Assert.That(cal.CorrectionFactor, Is.EqualTo(factor).Within(1e-12));
-            Assert.That(cal.PxPerMmAtDpi(600), Is.EqualTo(23.5969).Within(1e-9), "at its own DPI it returns the measured px/mm");
-            Assert.That(cal.PxPerMmAtDpi(1200), Is.EqualTo((1200.0 / 25.4) * factor).Within(1e-9));
-            Assert.That(cal.EffectiveDpi, Is.EqualTo(23.5969 * 25.4).Within(1e-9));
-        });
-    }
-}
diff --git a/src/ScanNTune.Tests/CardEdgeMeasurerTests.cs b/src/ScanNTune.Tests/CardEdgeMeasurerTests.cs
deleted file mode 100644
index ceedb4d..0000000
--- a/src/ScanNTune.Tests/CardEdgeMeasurerTests.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using OpenCvSharp;
-using ScanNTune.Core.Calibration;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// The card measurer must recover the scan's true px/mm from a card's long side regardless of the
-/// card's colour (dark on light or light on dark), its orientation, or a small rotation — because it
-/// fits the whole straight edge. These are portable: synthetic card images, no fixture needed.
-/// 
-[TestFixture]
-public class CardEdgeMeasurerTests
-{
-    private const double LongMm = 85.6;
-    private const double Dpi = 254.0;         // → exactly 10 px/mm nominal
-    private const double LongPx = 856.0;      // 85.6 mm × 10 px/mm
-    private const double ShortPx = 540.0;
-
-    [Test]
-    public void DarkCardOnWhite_RecoversPxPerMm()
-        => AssertRecovers(Synthetic(bg: 255, card: 60, portrait: false, rotationDeg: 0));
-
-    [Test]
-    public void PaleCardOnDarkBacking_RecoversPxPerMm()
-        => AssertRecovers(Synthetic(bg: 40, card: 235, portrait: false, rotationDeg: 0));
-
-    [Test]
-    public void PortraitCard_RecoversPxPerMm()
-        => AssertRecovers(Synthetic(bg: 255, card: 60, portrait: true, rotationDeg: 0));
-
-    [Test]
-    public void SlightlyRotatedCard_RecoversPxPerMm()
-    {
-        using Mat img = Synthetic(bg: 255, card: 60, portrait: false, rotationDeg: 3.0);
-        ScaleReferenceResult r = new CardEdgeMeasurer().Measure(img, LongMm, Dpi);
-        Assert.Multiple(() =>
-        {
-            Assert.That(r.Success, Is.True);
-            Assert.That(r.PxPerMm, Is.EqualTo(10.0).Within(0.05), "perpendicular width is rotation-invariant");
-            Assert.That(r.ParallelismDegrees, Is.LessThan(0.2));
-        });
-    }
-
-    [Test]
-    public void BlankScan_FailsGracefully()
-    {
-        using var img = new Mat(400, 400, MatType.CV_8UC1, new Scalar(255));
-        ScaleReferenceResult r = new CardEdgeMeasurer().Measure(img, LongMm, Dpi);
-        Assert.Multiple(() =>
-        {
-            Assert.That(r.Success, Is.False);
-            Assert.That(r.Message, Is.Not.Null.And.Not.Empty);
-        });
-    }
-
-    [Test]
-    public void RealCardScan_Reports()
-    {
-        string path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestFiles", "Cardscan.png");
-        if (!File.Exists(path)) { Assert.Ignore("real scan not present"); return; }
-        using Mat img = Cv2.ImRead(path, ImreadModes.Color);
-        ScaleReferenceResult r = new CardEdgeMeasurer().Measure(img, 85.5, 600);
-        TestContext.Out.WriteLine(
-            $"success={r.Success} px/mm={r.PxPerMm:0.0000} width={r.MeasuredWidthPx:0.0}px " +
-            $"detected={r.DetectedMm:0.00}mm straight={r.StraightnessPx:0.000}px parallel={r.ParallelismDegrees:0.0000}° n={r.EdgePointCount}");
-        Assert.Multiple(() =>
-        {
-            Assert.That(r.Success, Is.True);
-            Assert.That(r.PxPerMm, Is.EqualTo(23.60).Within(0.2), "matches the validated ~23.60 px/mm");
-        });
-    }
-
-    private void AssertRecovers(Mat img)
-    {
-        using (img)
-        {
-            ScaleReferenceResult r = new CardEdgeMeasurer().Measure(img, LongMm, Dpi);
-            Assert.Multiple(() =>
-            {
-                Assert.That(r.Success, Is.True);
-                Assert.That(r.PxPerMm, Is.EqualTo(10.0).Within(0.05), "px/mm from the long side");
-                Assert.That(r.DetectedMm, Is.EqualTo(LongMm).Within(0.5), "detected mm at the nominal DPI");
-                Assert.That(r.StraightnessPx, Is.LessThan(0.5), "clean synthetic edges fit straight");
-                Assert.That(r.ParallelismDegrees, Is.LessThan(0.2));
-            });
-        }
-    }
-
-    /// A filled card rectangle (long side = 856 px) on a plain background, optionally
-    /// portrait or rotated about its centre.
-    private Mat Synthetic(int bg, int card, bool portrait, double rotationDeg)
-    {
-        double w = portrait ? ShortPx : LongPx;
-        double h = portrait ? LongPx : ShortPx;
-        var image = new Mat(1120, 1220, MatType.CV_8UC1, new Scalar(bg));
-        double cx = image.Width / 2.0, cy = image.Height / 2.0;
-        double hw = w / 2.0, hh = h / 2.0;
-        double a = rotationDeg * Math.PI / 180.0;
-        double ca = Math.Cos(a), sa = Math.Sin(a);
-        var corners = new[]
-        {
-            (-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh),
-        };
-        var pts = new Point[corners.Length];
-        for (int i = 0; i < corners.Length; i++)
-        {
-            (double dx, double dy) = corners[i];
-            pts[i] = new Point(
-                (int)Math.Round(cx + dx * ca - dy * sa),
-                (int)Math.Round(cy + dx * sa + dy * ca));
-        }
-        Cv2.FillConvexPoly(image, pts, new Scalar(card), LineTypes.Link4);
-        return image;
-    }
-}
diff --git a/src/ScanNTune.Tests/CombinerGuardTests.cs b/src/ScanNTune.Tests/CombinerGuardTests.cs
deleted file mode 100644
index 9c245b7..0000000
--- a/src/ScanNTune.Tests/CombinerGuardTests.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-using ScanNTune.Core;
-using ScanNTune.Core.Combining;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// The two-scan cancellation is only exact for a quarter-turn of the SAME face: the un-cancelled
-/// scanner leakage grows as sin(turn error), and a mirror-flip between the scans makes the scanner
-/// skew ADD instead of cancel (a shear is a spin-2 quantity; reflecting it negates its cross
-/// component, undoing the quarter-turn sign flip) while the scanner diagnostic reads ~0. Both
-/// conditions must therefore invalidate the pair.
-/// 
-[TestFixture]
-public class CombinerGuardTests
-{
-    private readonly ScannerCancellingCombiner _combiner = new();
-
-    [Test]
-    public void FlipMismatchInvalidatesThePair()
-    {
-        // Coupon scanned face-up, then flipped over for the quarter-turned second scan: the
-        // turn still reads ~90° but the skew cancellation is broken.
-        TwoScanResult r = _combiner.Combine(Scan(0.0, flipped: false), Scan(90.0, flipped: true));
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(r.FlipMismatch, Is.True, "opposite Flipped states must be flagged");
-            Assert.That(r.RotationLooksValid, Is.False, "a flip-mismatched pair cannot be trusted");
-        });
-    }
-
-    [Test]
-    public void SameFlipStateOnBothScansIsAccepted()
-    {
-        // Both face-down is as valid as both face-up — only a MISMATCH breaks the algebra.
-        TwoScanResult r = _combiner.Combine(Scan(0.0, flipped: true), Scan(90.0, flipped: true));
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(r.FlipMismatch, Is.False);
-            Assert.That(r.RotationLooksValid, Is.True);
-        });
-    }
-
-    [TestCase(70.0)]   // 20° off a quarter-turn: leaks up to sin(20°) ≈ 34% of the scanner error
-    [TestCase(110.0)]
-    [TestCase(250.0)]
-    public void FarOffQuarterTurnIsInvalid(double turnDegrees)
-    {
-        TwoScanResult r = _combiner.Combine(Scan(0.0, flipped: false), Scan(turnDegrees, flipped: false));
-        Assert.That(r.RotationLooksValid, Is.False,
-            $"a {turnDegrees}° turn leaks un-cancelled scanner error comparable to the signal");
-    }
-
-    [TestCase(87.0)]   // real placements land within a few degrees (the test scans hit 89.8/269.8)
-    [TestCase(93.0)]
-    [TestCase(273.0)]
-    public void NearQuarterTurnIsValid(double turnDegrees)
-    {
-        TwoScanResult r = _combiner.Combine(Scan(0.0, flipped: false), Scan(turnDegrees, flipped: false));
-        Assert.That(r.RotationLooksValid, Is.True);
-    }
-
-    private CalibrationResult Scan(double xAxisAngleDegrees, bool flipped)
-    {
-        double rad = xAxisAngleDegrees * Math.PI / 180.0;
-        return new CalibrationResult(
-            0.0, 0.0, 0.0, 23, 23.6, 23.6, 0.5,
-            [],
-            new Orientation(flipped, 0.0, 0.0, Math.Cos(rad), Math.Sin(rad)));
-    }
-}
diff --git a/src/ScanNTune.Tests/CorrectionMathTests.cs b/src/ScanNTune.Tests/CorrectionMathTests.cs
deleted file mode 100644
index bd14c87..0000000
--- a/src/ScanNTune.Tests/CorrectionMathTests.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using System.Globalization;
-using ScanNTune.Core.Output;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// Pins the size-correction formulas to their exact published forms and guards against implausible
-/// inputs. The exact correction is the nominal/measured ratio (new = current / (1 + error)), the
-/// standard steps-per-mm and slicer-scale procedure; a first-order (1 − error) approximation leaves
-/// a residual of error² that the tool's own precision would resolve. The out-of-range guard mirrors
-/// the skew branch's: a real printer errs well under 2%, so a huge scale reading means a wrong DPI
-/// or a broken detection, and emitting firmware commands from it (e.g. M92 X0.000 at +100%) would
-/// actively damage a config.
-/// 
-[TestFixture]
-public class CorrectionMathTests
-{
-    private readonly CorrectionFormatter _formatter = new();
-
-    [TestCase(CorrectionFormatter.Shrinkage)]
-    [TestCase(CorrectionFormatter.StepsPerMm)]
-    [TestCase(CorrectionFormatter.RotationDistance)]
-    [TestCase(CorrectionFormatter.Scale)]
-    public void ImplausibleScaleIsGuardedNotEmitted(string flavour)
-    {
-        // +100% is what a coupon scanned at twice the calibrated DPI reads; no firmware command
-        // may be synthesized from it (StepsPerMm would emit "M92 X0.000\nM500").
-        Correction c = _formatter.Size(flavour, 100.0, 100.0, 80.0, 80.0);
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(c.Code, Does.Contain("out of range"));
-            Assert.That(c.Code, Does.Not.Contain("M92"));
-            Assert.That(c.Code, Does.Not.Contain("%"));
-        });
-    }
-
-    [Test]
-    public void PlausibleScalePassesTheGuard()
-    {
-        Correction c = _formatter.Size(CorrectionFormatter.Shrinkage, 1.5, 1.5, null, null);
-        Assert.That(c.Code, Does.Contain("%"));
-    }
-
-    [Test]
-    public void StepsPerMmUsesTheExactRatio()
-    {
-        // Part 2% oversize, current 80 steps/mm: exact new = 80 / 1.02 = 78.431 (not 80·0.98 = 78.4).
-        Correction c = _formatter.Size(CorrectionFormatter.StepsPerMm, 2.0, 2.0, 80.0, 80.0);
-        Assert.That(c.Code, Does.StartWith("M92 X78.431 Y78.431"));
-    }
-
-    [Test]
-    public void ScalePercentUsesTheExactRatio()
-    {
-        // Part 2% oversize: exact slicer scale = 100 / 1.02 = 98.04% (not 98.00) — a 100 mm part
-        // scaled to 98.00% prints 99.96 mm.
-        Correction c = _formatter.Size(CorrectionFormatter.Scale, 2.0, 2.0, null, null);
-        Assert.That(c.Code, Is.EqualTo("X 98.04 %   Y 98.04 %"));
-    }
-
-    [Test]
-    public void ShrinkageAndRotationDistanceStayExact()
-    {
-        // These two were already the exact published forms; pin them so they cannot drift.
-        Correction shrink = _formatter.Size(CorrectionFormatter.Shrinkage, 2.0, 2.0, null, null);
-        Correction rot = _formatter.Size(CorrectionFormatter.RotationDistance, 2.0, 2.0, 32.0, 32.0);
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(shrink.Code, Is.EqualTo("XY shrinkage: 102.00 %"));
-            Assert.That(rot.Code, Is.EqualTo("X 32.6400   Y 32.6400"));
-        });
-    }
-
-    [Test]
-    public void StepsPerMmHintDoesNotClaimKlipperSupport()
-    {
-        // Klipper implements neither M92 nor M500; its equivalent is the Rotation distance flavour.
-        Correction c = _formatter.Size(CorrectionFormatter.StepsPerMm, 1.0, 1.0, 80.0, 80.0);
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(c.Hint, Does.Not.Contain("Klipper steps"));
-            Assert.That(c.Hint, Does.Contain("Marlin"));
-            Assert.That(c.Hint, Does.Contain("Rotation distance"));
-        });
-    }
-}
diff --git a/src/ScanNTune.Tests/CouponAnalyzerEndToEndTests.cs b/src/ScanNTune.Tests/CouponAnalyzerEndToEndTests.cs
deleted file mode 100644
index 9e8b09a..0000000
--- a/src/ScanNTune.Tests/CouponAnalyzerEndToEndTests.cs
+++ /dev/null
@@ -1,82 +0,0 @@
-using ScanNTune.Core;
-using ScanNTune.Core.Detection;
-using ScanNTune.Core.Grids;
-using ScanNTune.Core.Solving;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// End-to-end check of the full pipeline against TestData_2solid.png — a render of the coupon's
-/// own STL (with the two-solid orientation marker), i.e. geometrically perfect (100% scale, zero
-/// skew). The render is reference-free, so the strongest assertions are the ones independent of
-/// absolute scale: zero skew and isotropy (X error ≈ Y error). Tolerances sit ~3x above the observed values
-/// (skew 0.001°, X/Y ±0.016%, RMS 0.09 px) to stay meaningful without being flaky.
-/// 
-[TestFixture]
-public class CouponAnalyzerEndToEndTests
-{
-    // 25 rings, but two are solid orientation rings (no hole), so only 23 are detectable.
-    private const int ExpectedRingHoles = 23;
-
-    private CalibrationResult _result = null!;
-
-    [OneTimeSetUp]
-    public void Analyze()
-    {
-        string path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestFiles", "TestData_2solid.png");
-        Assert.That(File.Exists(path), Is.True, $"Test image not found at {path}");
-
-        var analyzer = new CouponAnalyzer();
-        _result = analyzer.Analyze(path, new AnalysisOptions());
-
-        TestContext.Out.WriteLine($"Rings detected : {_result.RingsDetected}");
-        TestContext.Out.WriteLine($"X scale error  : {_result.XScalePercent:+0.0000;-0.0000} %");
-        TestContext.Out.WriteLine($"Y scale error  : {_result.YScalePercent:+0.0000;-0.0000} %");
-        TestContext.Out.WriteLine($"Skew           : {_result.SkewDegrees:+0.0000;-0.0000} deg");
-        TestContext.Out.WriteLine($"px/mm X, Y     : {_result.MeasuredPxPerMmX:0.000}, {_result.MeasuredPxPerMmY:0.000}");
-        TestContext.Out.WriteLine($"RMS residual   : {_result.RmsResidualPx:0.000} px");
-    }
-
-    [Test]
-    public void DetectsTheFullRingGrid()
-        => Assert.That(_result.RingsDetected, Is.EqualTo(ExpectedRingHoles));
-
-    [Test]
-    public void PerfectRenderHasZeroSkew()
-        => Assert.That(_result.SkewDegrees, Is.EqualTo(0.0).Within(0.05));
-
-    [Test]
-    public void PerfectRenderIsIsotropic()
-        => Assert.That(_result.XScalePercent - _result.YScalePercent, Is.EqualTo(0.0).Within(0.10));
-
-    [Test]
-    public void ScaleErrorsAreNearZero()
-    {
-        Assert.That(_result.XScalePercent, Is.EqualTo(0.0).Within(0.10));
-        Assert.That(_result.YScalePercent, Is.EqualTo(0.0).Within(0.10));
-    }
-
-    [Test]
-    public void AffineFitIsTight()
-        => Assert.That(_result.RmsResidualPx, Is.LessThan(0.5));
-
-    /// 
-    /// The pipeline switched to the robust (Huber/IRLS) solver by default. On this near-clean
-    /// fixture that must not move the reported figures away from ordinary least squares — this pins
-    /// the production default so a future change to the robust parameters can't silently shift it.
-    /// 
-    [Test]
-    public void RobustDefaultAgreesWithPlainLeastSquaresOnFixture()
-    {
-        string path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestFiles", "TestData_2solid.png");
-        CalibrationResult plain = new CouponAnalyzer(new RingDetector(), new GridMapper(), new AffineSolver(robust: false))
-            .Analyze(path, new AnalysisOptions());
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(_result.XScalePercent, Is.EqualTo(plain.XScalePercent).Within(0.05));
-            Assert.That(_result.YScalePercent, Is.EqualTo(plain.YScalePercent).Within(0.05));
-            Assert.That(_result.SkewDegrees, Is.EqualTo(plain.SkewDegrees).Within(0.05));
-        });
-    }
-}
diff --git a/src/ScanNTune.Tests/CouponImageTransforms.cs b/src/ScanNTune.Tests/CouponImageTransforms.cs
deleted file mode 100644
index eb9d218..0000000
--- a/src/ScanNTune.Tests/CouponImageTransforms.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// Image transforms shared by the orientation tests. They warp the geometrically-perfect coupon
-/// render to fake a print imperfection (anisotropic  / skew ),
-/// then place it on the "scanner" (mirror- + 90° ). Each returns
-/// a fresh  the caller owns (wrap in using).
-/// 
-public sealed class CouponImageTransforms
-{
-    /// Anisotropic scale on X only (physical X ≠ Y), e.g. factor 1.02 = +2%.
-    public Mat StretchX(Mat src, double factor)
-    {
-        var dst = new Mat();
-        Cv2.Resize(src, dst, new Size((int)Math.Round(src.Width * factor), src.Height));
-        return dst;
-    }
-
-    /// Horizontal shear by the given angle (degrees) — fakes XY skew.
-    public Mat Shear(Mat src, double degrees)
-    {
-        double k = Math.Tan(degrees * Math.PI / 180.0);
-        int extra = (int)Math.Ceiling(Math.Abs(k) * src.Height) + 4;
-        using var transform = new Mat(2, 3, MatType.CV_64FC1);
-        transform.Set(0, 0, 1.0); transform.Set(0, 1, k); transform.Set(0, 2, 0.0);
-        transform.Set(1, 0, 0.0); transform.Set(1, 1, 1.0); transform.Set(1, 2, 0.0);
-        var dst = new Mat();
-        Cv2.WarpAffine(src, dst, transform, new Size(src.Width + extra, src.Height),
-            InterpolationFlags.Cubic, BorderTypes.Constant, Scalar.Black);
-        return dst;
-    }
-
-    /// Mirror-flip about the vertical axis (as if the coupon was scanned face-down).
-    public Mat FlipY(Mat src)
-    {
-        var dst = new Mat();
-        Cv2.Flip(src, dst, FlipMode.Y);
-        return dst;
-    }
-
-    /// Rotate 0/90/180/270° clockwise (how the coupon happened to sit on the glass).
-    public Mat Rotate(Mat src, int degrees)
-    {
-        var dst = new Mat();
-        switch (degrees)
-        {
-            case 90: Cv2.Rotate(src, dst, RotateFlags.Rotate90Clockwise); break;
-            case 180: Cv2.Rotate(src, dst, RotateFlags.Rotate180); break;
-            case 270: Cv2.Rotate(src, dst, RotateFlags.Rotate90Counterclockwise); break;
-            default: src.CopyTo(dst); break;
-        }
-        return dst;
-    }
-}
diff --git a/src/ScanNTune.Tests/DiagnosticFailureTests.cs b/src/ScanNTune.Tests/DiagnosticFailureTests.cs
deleted file mode 100644
index 90fdb50..0000000
--- a/src/ScanNTune.Tests/DiagnosticFailureTests.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using OpenCvSharp;
-using ScanNTune.Core;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// A scan the pipeline can't resolve must fail as a  that still
-/// carries whatever rings were detected — the UI relies on that to show the user what was captured
-/// instead of only an error.
-/// 
-[TestFixture]
-public class DiagnosticFailureTests
-{
-    [Test]
-    public void BlankScanThrowsCouponAnalysisExceptionCarryingDetectedRings()
-    {
-        using var blank = new Mat(600, 600, MatType.CV_8UC3, Scalar.White);
-
-        var analyzer = new CouponAnalyzer();
-        var ex = Assert.Throws(
-            () => analyzer.Analyze(blank, new AnalysisOptions()));
-
-        Assert.That(ex!.DetectedRings, Is.Not.Null, "detected rings must be available for the diagnostic overlay");
-    }
-}
diff --git a/src/ScanNTune.Tests/FlipInvarianceTests.cs b/src/ScanNTune.Tests/FlipInvarianceTests.cs
deleted file mode 100644
index 8a7cf18..0000000
--- a/src/ScanNTune.Tests/FlipInvarianceTests.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using OpenCvSharp;
-using ScanNTune.Core;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// The two-solid marker must give identical readings no matter how the coupon is placed —
-/// rotated AND/OR mirror-flipped — with no manual flag. An X-stretched copy makes X ≠ Y so a
-/// swap would show; a sheared copy checks the skew sign survives a flip.
-/// 
-[TestFixture]
-public class FlipInvarianceTests
-{
-    [TestCase(false, 0)]
-    [TestCase(false, 90)]
-    [TestCase(false, 270)]
-    [TestCase(true, 0)]
-    [TestCase(true, 90)]
-    [TestCase(true, 180)]
-    [TestCase(true, 270)]
-    public void LabelsSurviveFlipAndRotation(bool flip, int rotation)
-    {
-        CalibrationResult r = Analyze(flip, rotation, stretchX: true);
-
-        Assert.Multiple(() =>
-        {
-            // Physical X was stretched ~2%, so X error stays clearly positive and Y negative at
-            // every pose — if a flip swapped the axes these would invert.
-            Assert.That(r.XScalePercent, Is.GreaterThan(0.5), "X must stay X under flip/rotation");
-            Assert.That(r.YScalePercent, Is.LessThan(-0.5), "Y must stay Y under flip/rotation");
-        });
-    }
-
-    [Test]
-    public void SkewSignSurvivesFlip()
-    {
-        double normal = Analyze(flip: false, rotation: 0, shearDegrees: 1.0).SkewDegrees;
-        double flipped = Analyze(flip: true, rotation: 0, shearDegrees: 1.0).SkewDegrees;
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(Math.Abs(normal), Is.GreaterThan(0.5), "a 1° shear should read ~1°");
-            Assert.That(flipped, Is.EqualTo(normal).Within(0.2), "a flip must not change the skew sign");
-        });
-    }
-
-    private readonly CouponImageTransforms _img = new();
-
-    private CalibrationResult Analyze(bool flip, int rotation, bool stretchX = false, double shearDegrees = 0.0)
-    {
-        string path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestFiles", "TestData_2solid.png");
-        using Mat original = Cv2.ImRead(path, ImreadModes.Color);
-
-        // Physical print imperfection (anisotropy / skew), applied to the coupon itself.
-        using Mat shaped = stretchX ? _img.StretchX(original, 1.02)
-                         : shearDegrees != 0.0 ? _img.Shear(original, shearDegrees)
-                         : original.Clone();
-
-        // How it was placed on the scanner: maybe mirror-flipped, then some rotation.
-        using Mat flipped = flip ? _img.FlipY(shaped) : shaped.Clone();
-        using Mat placed = _img.Rotate(flipped, rotation);
-
-        return new CouponAnalyzer().Analyze(placed, new AnalysisOptions());
-    }
-}
diff --git a/src/ScanNTune.Tests/GridMapperToleranceTests.cs b/src/ScanNTune.Tests/GridMapperToleranceTests.cs
deleted file mode 100644
index 872e10c..0000000
--- a/src/ScanNTune.Tests/GridMapperToleranceTests.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using ScanNTune.Core;
-using ScanNTune.Core.Grids;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// Pins the grid mapper's documented miss tolerance: the two solid marker vertices plus AT MOST one
-/// stray missed hole. More strays make the marker search unreliable (a second corner+neighbour pair
-/// of misses silently relocates the origin), so they must be a loud rejection, and a stray adjacent
-/// to a corner is genuinely ambiguous (two candidate +X directions) and must also reject rather
-/// than guess.
-/// 
-[TestFixture]
-public class GridMapperToleranceTests
-{
-    private const double PitchPx = 100.0;
-
-    private readonly GridMapper _mapper = new();
-    private readonly CouponSpec _spec = new();
-
-    [Test]
-    public void OneStrayMissedHoleIsTolerated()
-    {
-        List rings = Grid((0, 0), (1, 0), (2, 2));
-        GridMapping mapping = _mapper.Map(rings, _spec);
-        Assert.That(mapping.Points, Has.Count.EqualTo(22));
-    }
-
-    [Test]
-    public void TwoStrayMissedHolesAreRejected()
-    {
-        // Marker + two strays = 4 missing vertices; beyond the documented tolerance the marker
-        // identification can silently land on the wrong corner, so this must throw.
-        List rings = Grid((0, 0), (1, 0), (2, 2), (3, 1));
-        Assert.That(() => _mapper.Map(rings, _spec),
-            Throws.InvalidOperationException.With.Message.Contains("missing"));
-    }
-
-    [Test]
-    public void AWholeMissingOuterRowIsRejected()
-    {
-        // Glare wiping out the last row shrinks the DETECTED extent to 5×4; the miss count must be
-        // taken against the specified grid so those five holes still count as missing.
-        List rings = Grid((0, 0), (1, 0), (0, 4), (1, 4), (2, 4), (3, 4), (4, 4));
-        Assert.That(() => _mapper.Map(rings, _spec),
-            Throws.InvalidOperationException.With.Message.Contains("missing"));
-    }
-
-    [Test]
-    public void StrayMissAdjacentToTheMarkerCornerIsRejectedAsAmbiguous()
-    {
-        // Marker (0,0)+(1,0) plus a stray miss at (0,1): the corner now has two missing
-        // neighbours and the +X direction cannot be determined — must reject, not guess.
-        List rings = Grid((0, 0), (1, 0), (0, 1));
-        Assert.That(() => _mapper.Map(rings, _spec),
-            Throws.InvalidOperationException.With.Message.Contains("ambiguous"));
-    }
-
-    /// A perfect 5×5 grid of detections at 100 px pitch, minus the given vertices.
-    private List Grid(params (int c, int r)[] missing)
-    {
-        var gone = new HashSet<(int, int)>(missing);
-        var rings = new List();
-        for (int c = 0; c < 5; c++)
-        {
-            for (int r = 0; r < 5; r++)
-            {
-                if (gone.Contains((c, r)))
-                    continue;
-                rings.Add(new DetectedRing(c * PitchPx, r * PitchPx, 20.0, 0.8));
-            }
-        }
-        return rings;
-    }
-}
diff --git a/src/ScanNTune.Tests/IconGeneratorTests.cs b/src/ScanNTune.Tests/IconGeneratorTests.cs
deleted file mode 100644
index bc16e01..0000000
--- a/src/ScanNTune.Tests/IconGeneratorTests.cs
+++ /dev/null
@@ -1,132 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// Renders the ScanNTune app icon (the coupon's two-solid orientation marker on a graphite tile)
-/// and packs a multi-resolution Windows .ico. Drawn with OpenCvSharp at a 1024 px supersample and
-/// downscaled with area interpolation so every size is cleanly anti-aliased. Kept 
-/// because it writes build assets into ScanNTune.App/Assets, not a unit assertion — run it on demand
-/// with: dotnet test --filter GenerateAppIcon.
-/// 
-[TestFixture]
-public class IconGeneratorTests
-{
-    // #22262d graphite tile, #ffffff rings, #38bdf8 cyan markers — expressed as OpenCV BGR.
-    private static readonly Scalar Graphite = new(0x2d, 0x26, 0x22);
-    private static readonly Scalar White = new(0xff, 0xff, 0xff);
-    private static readonly Scalar Cyan = new(0xf8, 0xbd, 0x38);
-
-    private static readonly int[] IconSizes = { 16, 24, 32, 48, 64, 128, 256 };
-
-    [Test, Explicit("Writes app-icon assets; run on demand.")]
-    public void GenerateAppIcon()
-    {
-        const int master = 1024;
-        using Mat tile = RenderTile(master);
-
-        string assets = Path.GetFullPath(Path.Combine(
-            TestContext.CurrentContext.TestDirectory, "..", "..", "..", "..", "ScanNTune.App", "Assets"));
-        Directory.CreateDirectory(assets);
-
-        var frames = new List();
-        foreach (int size in IconSizes)
-        {
-            using var scaled = new Mat();
-            Cv2.Resize(tile, scaled, new Size(size, size), 0, 0, InterpolationFlags.Area);
-            Cv2.ImEncode(".png", scaled, out byte[] png);
-            frames.Add(png);
-            if (size == 256)
-                File.WriteAllBytes(Path.Combine(assets, "icon-256.png"), png);
-        }
-
-        byte[] ico = PackIco(IconSizes, frames);
-        string icoPath = Path.Combine(assets, "ScanNTune.ico");
-        File.WriteAllBytes(icoPath, ico);
-
-        TestContext.Out.WriteLine($"Wrote {icoPath} ({ico.Length} bytes, {IconSizes.Length} frames)");
-        Assert.That(File.Exists(icoPath), Is.True);
-    }
-
-    /// Draw the graphite rounded tile with the 3x3 ring grid and the two solid cyan markers.
-    private Mat RenderTile(int s)
-    {
-        // Proportions taken from the 256 px master SVG (rx 0.225, ring r 0.1168, stroke 0.0332, grid 0.2/0.5/0.8).
-        int rx = (int)Math.Round(0.225 * s);
-        int r = (int)Math.Round(0.1168 * s);
-        int stroke = Math.Max(1, (int)Math.Round(0.0332 * s));
-        int[] g = { (int)Math.Round(0.20 * s), (int)Math.Round(0.50 * s), (int)Math.Round(0.80 * s) };
-
-        using var bgr = new Mat(s, s, MatType.CV_8UC3, Graphite);
-        using var alpha = RoundedRectMask(s, rx);
-
-        foreach (int cy in g)
-            foreach (int cx in g)
-            {
-                bool marker = cy == g[0] && (cx == g[0] || cx == g[1]); // top-left corner + its +X neighbour
-                if (marker)
-                    Cv2.Circle(bgr, new Point(cx, cy), r, Cyan, -1, LineTypes.AntiAlias);
-                else
-                    Cv2.Circle(bgr, new Point(cx, cy), r, White, stroke, LineTypes.AntiAlias);
-            }
-
-        Mat[] channels = Cv2.Split(bgr);
-        try
-        {
-            var bgra = new Mat();
-            Cv2.Merge(new[] { channels[0], channels[1], channels[2], alpha }, bgra);
-            return bgra;
-        }
-        finally
-        {
-            foreach (Mat c in channels)
-                c.Dispose();
-        }
-    }
-
-    /// Filled rounded-rectangle alpha mask: centre cross of rects plus four anti-aliased corner discs.
-    private Mat RoundedRectMask(int s, int rx)
-    {
-        var mask = new Mat(s, s, MatType.CV_8UC1, Scalar.All(0));
-        Cv2.Rectangle(mask, new Rect(rx, 0, s - 2 * rx, s), Scalar.All(255), -1);
-        Cv2.Rectangle(mask, new Rect(0, rx, s, s - 2 * rx), Scalar.All(255), -1);
-        foreach (var c in new[]
-                 {
-                     new Point(rx, rx), new Point(s - rx, rx),
-                     new Point(rx, s - rx), new Point(s - rx, s - rx),
-                 })
-            Cv2.Circle(mask, c, rx, Scalar.All(255), -1, LineTypes.AntiAlias);
-        return mask;
-    }
-
-    /// Assemble a PNG-compressed ICO (ICONDIR + ICONDIRENTRY[] + PNG blobs).
-    private byte[] PackIco(int[] sizes, List frames)
-    {
-        using var ms = new MemoryStream();
-        using var w = new BinaryWriter(ms);
-
-        w.Write((ushort)0);          // reserved
-        w.Write((ushort)1);          // type: icon
-        w.Write((ushort)sizes.Length);
-
-        int offset = 6 + 16 * sizes.Length;
-        for (int i = 0; i < sizes.Length; i++)
-        {
-            w.Write((byte)(sizes[i] >= 256 ? 0 : sizes[i])); // width  (0 == 256)
-            w.Write((byte)(sizes[i] >= 256 ? 0 : sizes[i])); // height (0 == 256)
-            w.Write((byte)0);        // palette colours
-            w.Write((byte)0);        // reserved
-            w.Write((ushort)1);      // colour planes
-            w.Write((ushort)32);     // bits per pixel
-            w.Write(frames[i].Length);
-            w.Write(offset);
-            offset += frames[i].Length;
-        }
-
-        foreach (byte[] frame in frames)
-            w.Write(frame);
-
-        w.Flush();
-        return ms.ToArray();
-    }
-}
diff --git a/src/ScanNTune.Tests/JsonKeyValueStoreTests.cs b/src/ScanNTune.Tests/JsonKeyValueStoreTests.cs
deleted file mode 100644
index 41845b3..0000000
--- a/src/ScanNTune.Tests/JsonKeyValueStoreTests.cs
+++ /dev/null
@@ -1,74 +0,0 @@
-using ScanNTune.Core.Storage;
-
-namespace ScanNTune.Tests;
-
-[TestFixture]
-public class JsonKeyValueStoreTests
-{
-    private string _tempDir = null!;
-    private string _settingsFile = null!;
-
-    [SetUp]
-    public void Setup()
-    {
-        _tempDir = Path.Combine(Path.GetTempPath(), "ScanNTuneTest_" + Guid.NewGuid());
-        _settingsFile = Path.Combine(_tempDir, "Settings.json");
-    }
-
-    [TearDown]
-    public void Teardown()
-    {
-        if (Directory.Exists(_tempDir))
-            Directory.Delete(_tempDir, true);
-    }
-
-    [Test]
-    public void GetValue_KeyIsMissing_ReturnsDefault()
-    {
-        var key = Guid.NewGuid().ToString();
-        var store = new JsonKeyValueStore(_tempDir);
-        var value = store.GetValue(key, 42);
-        Assert.That(value, Is.EqualTo(42));
-    }
-
-    [Test]
-    public void SetValue_SavesValue_And_GetValue_LoadsIt()
-    {
-        var store = new JsonKeyValueStore(_tempDir);
-
-        var key = Guid.NewGuid().ToString();
-        store.SetValue(key, "value");
-
-        var loaded = store.GetValue(key);
-
-        Assert.That(loaded, Is.EqualTo("value"));
-        Assert.That(File.Exists(_settingsFile), Is.True);
-    }
-
-    [Test]
-    public void Values_PersistAcrossInstances()
-    {
-        var key = Guid.NewGuid().ToString();
-
-        var store1 = new JsonKeyValueStore(_tempDir);
-        store1.SetValue(key, 5);
-
-        var store2 = new JsonKeyValueStore(_tempDir);
-        var loaded = store2.GetValue(key);
-
-        Assert.That(loaded, Is.EqualTo(5));
-    }
-
-    [Test]
-    public void SetValue_OverwritesExistingValue()
-    {
-        var store = new JsonKeyValueStore(_tempDir);
-        var key = Guid.NewGuid().ToString();
-
-        store.SetValue(key, 10);
-        store.SetValue(key, 20);
-
-        var loaded = store.GetValue(key);
-        Assert.That(loaded, Is.EqualTo(20));
-    }
-}
diff --git a/src/ScanNTune.Tests/RotationInvarianceTests.cs b/src/ScanNTune.Tests/RotationInvarianceTests.cs
deleted file mode 100644
index c36778d..0000000
--- a/src/ScanNTune.Tests/RotationInvarianceTests.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using OpenCvSharp;
-using ScanNTune.Core;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// Proves the pipeline resolves orientation from the fiducial at any scan rotation: the +X axis
-/// tracks the rotation, and X/Y labels never swap — including for an anisotropic (X-stretched)
-/// coupon, where the stretched physical axis must stay labelled X at every rotation.
-/// 
-[TestFixture]
-public class RotationInvarianceTests
-{
-    [TestCase(0, 0.0)]
-    [TestCase(90, 90.0)]
-    [TestCase(180, 180.0)]
-    [TestCase(270, 270.0)]
-    public void OrientationTracksRotation(int rotationDegrees, double expectedXAngle)
-    {
-        CalibrationResult result = Analyze(rotationDegrees);
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(result.RingsDetected, Is.EqualTo(23));
-            Assert.That(AngleDifference(result.Orientation.XAxisAngleDegrees, expectedXAngle),
-                Is.EqualTo(0.0).Within(2.0), "+X axis should track the rotation");
-        });
-    }
-
-    [Test]
-    public void LabellingIsRotationInvariantForAnisotropicCoupon()
-    {
-        CalibrationResult at0 = Analyze(0, stretchX: true);
-        CalibrationResult at90 = Analyze(90, stretchX: true);
-        CalibrationResult at270 = Analyze(270, stretchX: true);
-
-        Assert.Multiple(() =>
-        {
-            // Physical X was stretched ~2%, so X error is clearly positive at every rotation.
-            Assert.That(at0.XScalePercent, Is.GreaterThan(0.5));
-            Assert.That(at90.XScalePercent, Is.EqualTo(at0.XScalePercent).Within(0.15));
-            Assert.That(at270.XScalePercent, Is.EqualTo(at0.XScalePercent).Within(0.15));
-            Assert.That(at90.YScalePercent, Is.EqualTo(at0.YScalePercent).Within(0.15));
-        });
-    }
-
-    private readonly CouponImageTransforms _img = new();
-
-    private CalibrationResult Analyze(int rotationDegrees, bool stretchX = false)
-    {
-        string path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestFiles", "TestData_2solid.png");
-        using Mat original = Cv2.ImRead(path, ImreadModes.Color);
-        using Mat source = stretchX ? _img.StretchX(original, 1.02) : original.Clone();
-        using Mat rotated = _img.Rotate(source, rotationDegrees);
-        return new CouponAnalyzer().Analyze(rotated, new AnalysisOptions());
-    }
-
-    /// Signed smallest angle from b to a, in degrees (-180, 180].
-    private double AngleDifference(double a, double b) => ((a - b + 540.0) % 360.0) - 180.0;
-}
diff --git a/src/ScanNTune.Tests/ScanCombinerTests.cs b/src/ScanNTune.Tests/ScanCombinerTests.cs
deleted file mode 100644
index a58b3a4..0000000
--- a/src/ScanNTune.Tests/ScanCombinerTests.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-using OpenCvSharp;
-using ScanNTune.Core;
-using ScanNTune.Core.Combining;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// The two-scan combiner must remove the scanner's own anisotropy/skew. We fake a scanner that
-/// stretches its X axis by a fixed amount (applied to BOTH scans, in the bed frame, after the
-/// coupon is placed), scan the coupon once upright and once quarter-turned, and check that the
-/// combined printer anisotropy is ~0 (the fixture is geometrically perfect) even though each scan
-/// alone reads a large anisotropy — and that the scanner diagnostic recovers the stretch.
-/// 
-[TestFixture]
-public class ScanCombinerTests
-{
-    private const double ScannerXStretch = 1.03; // scanner reads +3% along its X axis
-
-    private readonly CouponImageTransforms _img = new();
-
-    [Test]
-    public void CombiningQuarterTurnScansCancelsScannerAnisotropy()
-    {
-        (CalibrationResult a, CalibrationResult b) = ScanPair();
-        TwoScanResult combined = new ScannerCancellingCombiner().Combine(a, b);
-
-        Assert.Multiple(() =>
-        {
-            // Each scan alone sees the scanner's ~3% bias as anisotropy...
-            Assert.That(Math.Abs(a.XScalePercent - a.YScalePercent), Is.GreaterThan(1.5),
-                "single scan should still carry the scanner's anisotropy");
-            // ...but the combined printer anisotropy is ~0 (the fixture is geometrically perfect).
-            double printerAniso = combined.Combined.XScalePercent - combined.Combined.YScalePercent;
-            Assert.That(printerAniso, Is.EqualTo(0.0).Within(0.5), "scanner anisotropy must cancel");
-            // The scanner's own bias is recovered as the diagnostic (~3%).
-            Assert.That(Math.Abs(combined.Scanner.AnisotropyPercent), Is.EqualTo(3.0).Within(0.6));
-            Assert.That(combined.RotationLooksValid, Is.True);
-        });
-    }
-
-    [Test]
-    public void SameOrientationTwiceIsFlaggedAsInvalid()
-    {
-        // Two scans with NO quarter-turn between them: the scanner error cannot cancel.
-        CalibrationResult a = Analyze(_img.StretchX(LoadFixture(), ScannerXStretch));
-        CalibrationResult b = Analyze(_img.StretchX(LoadFixture(), ScannerXStretch));
-        TwoScanResult combined = new ScannerCancellingCombiner().Combine(a, b);
-
-        Assert.That(combined.RotationLooksValid, Is.False,
-            "two same-pose scans are ~0° apart and must be flagged");
-    }
-
-    private (CalibrationResult a, CalibrationResult b) ScanPair()
-    {
-        using Mat original = LoadFixture();
-        // Scan A: coupon upright, then the scanner stretches its X axis.
-        using Mat aImg = _img.StretchX(original, ScannerXStretch);
-        // Scan B: coupon quarter-turned, then the SAME scanner stretch (fixed in the bed frame).
-        using Mat rotated = _img.Rotate(original, 90);
-        using Mat bImg = _img.StretchX(rotated, ScannerXStretch);
-        return (Analyze(aImg), Analyze(bImg));
-    }
-
-    private Mat LoadFixture()
-    {
-        string path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestFiles", "TestData_2solid.png");
-        return Cv2.ImRead(path, ImreadModes.Color);
-    }
-
-    private CalibrationResult Analyze(Mat image)
-    {
-        using (image)
-            return new CouponAnalyzer().Analyze(image, new AnalysisOptions());
-    }
-}
diff --git a/src/ScanNTune.Tests/ScanNTune.Tests.csproj b/src/ScanNTune.Tests/ScanNTune.Tests.csproj
deleted file mode 100644
index 9c670e6..0000000
--- a/src/ScanNTune.Tests/ScanNTune.Tests.csproj
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-  
-    net10.0
-    latest
-    enable
-    enable
-    false
-  
-
-  
-    
-    
-    
-    
-    
-    
-  
-
-  
-    
-  
-
-  
-    
-  
-
-  
-    
-  
-
-
diff --git a/src/ScanNTune.Tests/SkewSignConventionTests.cs b/src/ScanNTune.Tests/SkewSignConventionTests.cs
deleted file mode 100644
index e3a9bc5..0000000
--- a/src/ScanNTune.Tests/SkewSignConventionTests.cs
+++ /dev/null
@@ -1,147 +0,0 @@
-using System.Globalization;
-using OpenCvSharp;
-using ScanNTune.Core;
-using ScanNTune.Core.Output;
-using ScanNTune.Core.Solving;
-
-namespace ScanNTune.Tests;
-
-/// 
-/// Anchors the absolute sign of the skew convention end-to-end. 
-/// is the measured ERROR, like the scale figures: the X/Y corner angle minus 90°, so a part whose
-/// axes CLOSE below 90° (sheared x' = x + t·y) reads NEGATIVE, and an opened corner reads positive
-/// (matching the Califlower calculator's Measured column). The firmware shear factor is the
-/// negation of that error — the conversion lives in CorrectionFormatter, verified against the
-/// firmware sources (klippy skew_correction.py applies x − y·factor; Marlin planner.h subtracts;
-/// RRF Move.cpp ADDS). The flip/rotation tests elsewhere prove the sign is *consistent* across
-/// poses; these tests pin which sign a known shear direction produces, so a flipped convention can
-/// never ship again.
-/// 
-[TestFixture]
-public class SkewSignConventionTests
-{
-    private const double ShearDeg = 1.0;
-    private const double PxPerMm = 10.0;
-    private const double PitchMm = 25.0;
-
-    private readonly double _shearTan = Math.Tan(ShearDeg * Math.PI / 180.0);
-
-    [Test]
-    public void PlusXShearReadsNegative_InImageFrame()
-    {
-        // Physical shear x' = x + t·y closes the corner angle below 90°, so the measured angle
-        // error is NEGATIVE. Viewed in an image frame whose rows grow downward (py = C − y): the
-        // frame every scan and render arrives in.
-        AffineModel m = new AffineSolver().Solve(ShearedGrid(mirrorX: false));
-        Assert.That(m.SkewDegrees, Is.EqualTo(-ShearDeg).Within(0.001),
-            "x' = x + t·y closes the corner: the angle error is negative");
-    }
-
-    [Test]
-    public void PlusXShearReadsNegative_InMirroredImageFrame()
-    {
-        // The same physical shear scanned face-down (the image is additionally mirrored in x).
-        // The inter-axis angle is reflection-invariant, so the sign must not change.
-        AffineModel m = new AffineSolver().Solve(ShearedGrid(mirrorX: true));
-        Assert.That(m.SkewDegrees, Is.EqualTo(-ShearDeg).Within(0.001),
-            "a mirrored view of the same part must read the same skew");
-    }
-
-    [Test]
-    public void FixtureShearReadsPositive()
-    {
-        // The fixture render is a top view with physical +Y pointing UP the image, so an
-        // image-space shear x_img += k·y_img (rows grow DOWNWARD) is the physical shear
-        // x' = x − k·y, which OPENS the corner angle: a positive angle error end to end.
-        string path = Path.Combine(TestContext.CurrentContext.TestDirectory, "TestFiles", "TestData_2solid.png");
-        using Mat original = Cv2.ImRead(path, ImreadModes.Color);
-        using Mat sheared = new CouponImageTransforms().Shear(original, ShearDeg);
-
-        CalibrationResult r = new CouponAnalyzer().Analyze(sheared, new AnalysisOptions());
-        Assert.That(r.SkewDegrees, Is.EqualTo(ShearDeg).Within(0.1),
-            "a +1° image shear of the y-up render opens the corner: positive angle error");
-    }
-
-    [Test]
-    public void KlipperCorrectionCancelsTheMeasuredShear()
-    {
-        // A part sheared x' = x + t·y measures an angle error of −atan(t). Round trip: feed the
-        // emitted AC/BD/AD through Klipper's own calc_skew_factor (klippy/extras/skew_correction.py).
-        // Klipper applies x − y·factor to every move, so the factor must equal +t to cancel.
-        Correction c = new CorrectionFormatter().Skew(CorrectionFormatter.Klipper, -ShearDeg, new CouponSpec());
-        double[] xy = ParseCsv(c.Code, prefix: "SET_SKEW XY=");
-        double ac = xy[0], bd = xy[1], ad = xy[2];
-
-        double side = Math.Sqrt(2 * ac * ac + 2 * bd * bd - 4 * ad * ad) / 2.0;
-        double factor = Math.Tan(Math.PI / 2 - Math.Acos((ac * ac - side * side - ad * ad) / (2 * side * ad)));
-
-        Assert.Multiple(() =>
-        {
-            Assert.That(ac, Is.GreaterThan(bd), "an x' = x + t·y part must emit AC > BD");
-            Assert.That(factor, Is.EqualTo(_shearTan).Within(0.001),
-                "Klipper must recover +tan(shear) from the emitted lengths");
-        });
-    }
-
-    [Test]
-    public void MarlinEmitsPositiveFactorForPlusXShear()
-    {
-        // Marlin's planner SUBTRACTS the factor (planner.h skew(): sx = cx − cy·skew_factor.xy),
-        // so cancelling x' = x + t·y (measured angle error −atan(t)) needs a POSITIVE M852 I.
-        Correction marlin = new CorrectionFormatter().Skew(CorrectionFormatter.Marlin, -ShearDeg, new CouponSpec());
-        double marlinI = ParseAfterPrefix(marlin.Code, "M852 I");
-        Assert.That(marlinI, Is.EqualTo(_shearTan).Within(1e-6), "M852 I = +tan(shear)");
-    }
-
-    [Test]
-    public void RepRapEmitsNegativeFactorForPlusXShear()
-    {
-        // RepRapFirmware ADDS the factor on the user→machine transform (Move.cpp AxisTransform:
-        // xyzPoint[X] += tanXY·Y, tanXY = X/S from M556) — opposite of Marlin — so cancelling
-        // x' = x + t·y needs a NEGATIVE M556 X.
-        Correction rrf = new CorrectionFormatter().Skew(CorrectionFormatter.RepRap, -ShearDeg, new CouponSpec());
-        double rrfX = ParseAfterPrefix(rrf.Code, "M556 S100 X");
-        Assert.That(rrfX, Is.EqualTo(-100.0 * _shearTan).Within(1e-3), "M556 X = −100·tan(shear)");
-    }
-
-    /// 
-    /// A perfect 5x5 grid printed by a machine that shears x' = x + t·y, then imaged: y flipped
-    /// (image rows grow downward) and optionally x-mirrored (a face-down scan).
-    /// 
-    private List ShearedGrid(bool mirrorX)
-    {
-        const double extentMm = 4 * PitchMm;
-        var pts = new List();
-        for (int i = 0; i < 5; i++)
-        {
-            for (int j = 0; j < 5; j++)
-            {
-                double nx = i * PitchMm, ny = j * PitchMm;
-                double printedX = nx + _shearTan * ny;
-                double imgX = mirrorX ? extentMm - printedX : printedX;
-                double imgY = extentMm - ny;
-                pts.Add(new GridCorrespondence(i, j, nx, ny, imgX * PxPerMm, imgY * PxPerMm));
-            }
-        }
-        return pts;
-    }
-
-    private double[] ParseCsv(string code, string prefix)
-    {
-        string line = code.Split('\n')[0];
-        Assert.That(line, Does.StartWith(prefix));
-        double[] values = line.Substring(prefix.Length)
-            .Split(',')
-            .Select(s => double.Parse(s, CultureInfo.InvariantCulture))
-            .ToArray();
-        Assert.That(values, Has.Length.EqualTo(3), "SET_SKEW must carry AC,BD,AD");
-        return values;
-    }
-
-    private double ParseAfterPrefix(string code, string prefix)
-    {
-        string line = code.Split('\n')[0];
-        Assert.That(line, Does.StartWith(prefix));
-        return double.Parse(line.Substring(prefix.Length), CultureInfo.InvariantCulture);
-    }
-}
diff --git a/src/ScanNTune.Tests/TestFiles/Cardscan.png b/src/ScanNTune.Tests/TestFiles/Cardscan.png
deleted file mode 100644
index db366ac..0000000
Binary files a/src/ScanNTune.Tests/TestFiles/Cardscan.png and /dev/null differ
diff --git a/src/ScanNTune.Tests/TestFiles/Scan 0.png b/src/ScanNTune.Tests/TestFiles/Scan 0.png
deleted file mode 100644
index fa2dedc..0000000
Binary files a/src/ScanNTune.Tests/TestFiles/Scan 0.png and /dev/null differ
diff --git a/src/ScanNTune.Tests/TestFiles/Scan 90.png b/src/ScanNTune.Tests/TestFiles/Scan 90.png
deleted file mode 100644
index 589e6d1..0000000
Binary files a/src/ScanNTune.Tests/TestFiles/Scan 90.png and /dev/null differ
diff --git a/src/ScanNTune.UI/Assets/icon-256.png b/src/ScanNTune.UI/Assets/icon-256.png
deleted file mode 100644
index 5263e38..0000000
Binary files a/src/ScanNTune.UI/Assets/icon-256.png and /dev/null differ
diff --git a/src/ScanNTune.UI/Assets/icon.svg b/src/ScanNTune.UI/Assets/icon.svg
deleted file mode 100644
index fa977fa..0000000
--- a/src/ScanNTune.UI/Assets/icon.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-  
-  
-    
-    
-    
-  
-  
-  
-
diff --git a/src/ScanNTune.UI/Controls/CouponGlyph.axaml b/src/ScanNTune.UI/Controls/CouponGlyph.axaml
deleted file mode 100644
index 0174042..0000000
--- a/src/ScanNTune.UI/Controls/CouponGlyph.axaml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-    
-    
-        
-            
-
-            
-            
-
-            
-            
-            
-            
-            
-            
-            
-        
-    
-
diff --git a/src/ScanNTune.UI/Controls/CouponGlyph.axaml.cs b/src/ScanNTune.UI/Controls/CouponGlyph.axaml.cs
deleted file mode 100644
index fc81b78..0000000
--- a/src/ScanNTune.UI/Controls/CouponGlyph.axaml.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using Avalonia.Controls;
-using Avalonia.Markup.Xaml;
-
-namespace ScanNTune.UI.Controls;
-
-public partial class CouponGlyph : UserControl
-{
-    public CouponGlyph()
-    {
-        InitializeComponent();
-    }
-
-    private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
-}
diff --git a/src/ScanNTune.UI/Controls/ResponsiveStack.cs b/src/ScanNTune.UI/Controls/ResponsiveStack.cs
deleted file mode 100644
index 97eb807..0000000
--- a/src/ScanNTune.UI/Controls/ResponsiveStack.cs
+++ /dev/null
@@ -1,174 +0,0 @@
-using System;
-using Avalonia;
-using Avalonia.Controls;
-
-namespace ScanNTune.UI.Controls;
-
-/// 
-/// Lays its children in a row while it is at least  device-independent pixels
-/// wide, and folds them into a full-width stacked column once it is narrower. Children marked with the
-/// attached ResponsiveStack.Fill="True" flag share the leftover row width equally (the flexible
-/// tiles); unmarked children keep their desired width (a small connector or label). When stacked every
-/// child stretches to the panel width. This gives the pages one width-driven reflow (phone, or a narrow
-/// desktop window) without per-view code-behind. While stacked the panel carries the :stacked
-/// pseudo-class so styles can, for example, turn a connector arrow to point down.
-/// 
-public sealed class ResponsiveStack : Panel
-{
-    public static readonly StyledProperty BreakpointProperty =
-        AvaloniaProperty.Register(nameof(Breakpoint), 520);
-
-    public static readonly StyledProperty SpacingProperty =
-        AvaloniaProperty.Register(nameof(Spacing), 10);
-
-    /// 
-    /// Reverses child order in the wide (row) layout only, leaving the declared order to drive the stacked
-    /// column. This lets a page read info-then-pictures when stacked yet show pictures-then-info in a row.
-    /// 
-    public static readonly StyledProperty ReverseWhenWideProperty =
-        AvaloniaProperty.Register(nameof(ReverseWhenWide));
-
-    /// Marks a child as a flexible tile that shares the leftover row width equally.
-    public static readonly AttachedProperty FillProperty =
-        AvaloniaProperty.RegisterAttached("Fill");
-
-    private bool _stacked;
-
-    static ResponsiveStack()
-    {
-        AffectsMeasure(BreakpointProperty, SpacingProperty);
-        AffectsArrange(ReverseWhenWideProperty);
-    }
-
-    public double Breakpoint
-    {
-        get => GetValue(BreakpointProperty);
-        set => SetValue(BreakpointProperty, value);
-    }
-
-    public double Spacing
-    {
-        get => GetValue(SpacingProperty);
-        set => SetValue(SpacingProperty, value);
-    }
-
-    public bool ReverseWhenWide
-    {
-        get => GetValue(ReverseWhenWideProperty);
-        set => SetValue(ReverseWhenWideProperty, value);
-    }
-
-    public static bool GetFill(Control control) => control.GetValue(FillProperty);
-
-    public static void SetFill(Control control, bool value) => control.SetValue(FillProperty, value);
-
-    protected override Size MeasureOverride(Size availableSize)
-    {
-        var children = Children;
-        int count = children.Count;
-        if (count == 0)
-            return default;
-
-        double spacing = Spacing;
-        // An unconstrained width (measured inside something that gives infinite width) can't be split into a
-        // row, so treat it as stacked; in practice the pages constrain width (a scroll viewer's viewport).
-        bool horizontal = !double.IsInfinity(availableSize.Width) && availableSize.Width >= Breakpoint;
-        SetStacked(!horizontal);
-
-        if (!horizontal)
-        {
-            double width = 0, height = 0;
-            for (int i = 0; i < count; i++)
-            {
-                Control child = children[i];
-                child.Measure(new Size(availableSize.Width, double.PositiveInfinity));
-                width = Math.Max(width, child.DesiredSize.Width);
-                height += child.DesiredSize.Height;
-            }
-            height += spacing * (count - 1);
-            return new Size(double.IsInfinity(availableSize.Width) ? width : availableSize.Width, height);
-        }
-
-        double totalSpacing = spacing * (count - 1);
-        double fixedWidth = 0;
-        int fillCount = 0;
-        for (int i = 0; i < count; i++)
-        {
-            Control child = children[i];
-            if (GetFill(child))
-            {
-                fillCount++;
-                continue;
-            }
-            // Bounded width (not infinite) so a fixed-width non-Fill child with wrapping content reports the
-            // width it will actually occupy, leaving the rest for the Fill children.
-            child.Measure(new Size(availableSize.Width, availableSize.Height));
-            fixedWidth += child.DesiredSize.Width;
-        }
-
-        double each = fillCount > 0 ? Math.Max(0, availableSize.Width - totalSpacing - fixedWidth) / fillCount : 0;
-        double rowHeight = 0;
-        for (int i = 0; i < count; i++)
-        {
-            Control child = children[i];
-            if (GetFill(child))
-                child.Measure(new Size(each, availableSize.Height));
-            rowHeight = Math.Max(rowHeight, child.DesiredSize.Height);
-        }
-        return new Size(availableSize.Width, rowHeight);
-    }
-
-    protected override Size ArrangeOverride(Size finalSize)
-    {
-        var children = Children;
-        int count = children.Count;
-        if (count == 0)
-            return finalSize;
-
-        double spacing = Spacing;
-        if (_stacked)
-        {
-            double y = 0;
-            for (int i = 0; i < count; i++)
-            {
-                Control child = children[i];
-                double h = child.DesiredSize.Height;
-                child.Arrange(new Rect(0, y, finalSize.Width, h));
-                y += h + spacing;
-            }
-            return finalSize;
-        }
-
-        double totalSpacing = spacing * (count - 1);
-        double fixedWidth = 0;
-        int fillCount = 0;
-        for (int i = 0; i < count; i++)
-        {
-            Control child = children[i];
-            if (GetFill(child))
-                fillCount++;
-            else
-                fixedWidth += child.DesiredSize.Width;
-        }
-
-        double each = fillCount > 0 ? Math.Max(0, finalSize.Width - totalSpacing - fixedWidth) / fillCount : 0;
-        bool reverse = ReverseWhenWide;
-        double x = 0;
-        for (int i = 0; i < count; i++)
-        {
-            Control child = children[reverse ? count - 1 - i : i];
-            double w = GetFill(child) ? each : child.DesiredSize.Width;
-            child.Arrange(new Rect(x, 0, w, finalSize.Height));
-            x += w + spacing;
-        }
-        return finalSize;
-    }
-
-    private void SetStacked(bool stacked)
-    {
-        if (_stacked == stacked)
-            return;
-        _stacked = stacked;
-        PseudoClasses.Set(":stacked", stacked);
-    }
-}
diff --git a/src/ScanNTune.UI/DependencyInjection/DesignGraph.cs b/src/ScanNTune.UI/DependencyInjection/DesignGraph.cs
deleted file mode 100644
index 8e961aa..0000000
--- a/src/ScanNTune.UI/DependencyInjection/DesignGraph.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using Autofac;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-using ScanNTune.Core.Calibration;
-using ScanNTune.UI.Platform;
-
-namespace ScanNTune.UI.DependencyInjection;
-
-/// 
-/// A throwaway container for the XAML previewer: the shared  engine registrations
-/// plus silent logging and stub platform services, so design time builds the shell from the same graph the
-/// app uses at runtime instead of a hand-maintained parallel one. Design-time only; at runtime each head
-/// composes through its own module.
-/// 
-public sealed class DesignGraph
-{
-    private readonly IContainer _container;
-
-    public DesignGraph()
-    {
-        var builder = new ContainerBuilder();
-        builder.RegisterModule(new UiModule());
-        builder.RegisterInstance(NullLoggerFactory.Instance).SingleInstance();
-        builder.RegisterType().As().SingleInstance();
-        builder.RegisterType().As().SingleInstance();
-        builder.RegisterType().As().SingleInstance();
-        builder.RegisterType().As().SingleInstance();
-        builder.Register(c => new JsonCalibrationStore(null, c.Resolve>()))
-            .As().SingleInstance();
-        _container = builder.Build();
-    }
-
-    public T Get() where T : notnull => _container.Resolve();
-}
diff --git a/src/ScanNTune.UI/DependencyInjection/UiModule.cs b/src/ScanNTune.UI/DependencyInjection/UiModule.cs
deleted file mode 100644
index d552f87..0000000
--- a/src/ScanNTune.UI/DependencyInjection/UiModule.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using Autofac;
-using Microsoft.Extensions.Logging;
-using ScanNTune.Core;
-using ScanNTune.Core.Calibration;
-using ScanNTune.Core.Combining;
-using ScanNTune.Core.Output;
-using ScanNTune.UI.ViewModels;
-
-namespace ScanNTune.UI.DependencyInjection;
-
-/// 
-/// Registers the platform-neutral layer: the headless engine services, the shell view model, and an open
-/// generic  built from the head-supplied . Each head
-/// adds its own module for the platform services (imaging, coupon export, the calibration/settings stores,
-/// and the logger factory) and then resolves .
-/// 
-public sealed class UiModule : Module
-{
-    protected override void Load(ContainerBuilder builder)
-    {
-        // ILogger everywhere, backed by whatever ILoggerFactory the head registers.
-        builder.RegisterGeneric(typeof(Logger<>)).As(typeof(ILogger<>)).SingleInstance();
-
-        builder.RegisterType().As().SingleInstance();
-        builder.RegisterType().As().SingleInstance();
-        builder.RegisterType().As().SingleInstance();
-        builder.RegisterType().As().SingleInstance();
-        builder.RegisterType().As().SingleInstance();
-
-        builder.RegisterType().AsSelf().SingleInstance();
-    }
-}
diff --git a/src/ScanNTune.UI/Platform/DesignStubs.cs b/src/ScanNTune.UI/Platform/DesignStubs.cs
deleted file mode 100644
index 2a3341d..0000000
--- a/src/ScanNTune.UI/Platform/DesignStubs.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using System.Threading.Tasks;
-using OpenCvSharp;
-
-namespace ScanNTune.UI.Platform;
-
-// Design-time only: lets the XAML previewer instantiate MainWindowViewModel without a head's platform
-// services. Never registered in a real container.
-internal sealed class DesignImaging : IPlatformImaging
-{
-    public Mat DecodeBgr(byte[] data) => new();
-
-    public (int Width, int Height) GetImageSize(byte[] data) => (0, 0);
-}
-
-internal sealed class DesignCouponExporter : ICouponExporter
-{
-    public Task ExportAsync() => Task.CompletedTask;
-}
-
-internal sealed class DesignFilePicker : IFilePicker
-{
-    public Task PickImageAsync(string title) => Task.FromResult(null);
-}
-
-internal sealed class DesignDeviceInfo : IDeviceInfo
-{
-    public bool IsTouchPrimary => false;
-}
diff --git a/src/ScanNTune.UI/Platform/ICouponExporter.cs b/src/ScanNTune.UI/Platform/ICouponExporter.cs
deleted file mode 100644
index b135d5c..0000000
--- a/src/ScanNTune.UI/Platform/ICouponExporter.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using System.Threading.Tasks;
-
-namespace ScanNTune.UI.Platform;
-
-/// 
-/// Hands the user the printable calibration coupon STL. This is inherently platform-specific: the desktop
-/// head drops the bundled STL in a temp folder and opens the OS "Open with" chooser, while the browser head
-/// triggers a file download. The STL bytes themselves come from the shared avares://ScanNTune.UI asset.
-/// 
-public interface ICouponExporter
-{
-    Task ExportAsync();
-}
diff --git a/src/ScanNTune.UI/Platform/IDeviceInfo.cs b/src/ScanNTune.UI/Platform/IDeviceInfo.cs
deleted file mode 100644
index f1abd64..0000000
--- a/src/ScanNTune.UI/Platform/IDeviceInfo.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace ScanNTune.UI.Platform;
-
-/// 
-/// Head-supplied device facts the shared UI adapts to. Currently just whether the primary pointer is a touch
-/// screen (a phone or tablet). The numeric fields use it to turn text entry off there, so tapping one never
-/// raises a soft keyboard or leaves a caret the user cannot type into: mobile browsers cannot commit typed
-/// characters into an Avalonia TextBox, so the +/- steppers are the only input on touch devices.
-/// The desktop head reports false (a keyboard is always present); the browser head reads the CSS
-/// pointer media query.
-/// 
-public interface IDeviceInfo
-{
-    /// True when the device's primary pointer is a touch screen (phone or tablet).
-    bool IsTouchPrimary { get; }
-}
diff --git a/src/ScanNTune.UI/Platform/IFilePicker.cs b/src/ScanNTune.UI/Platform/IFilePicker.cs
deleted file mode 100644
index a7799a3..0000000
--- a/src/ScanNTune.UI/Platform/IFilePicker.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.Threading.Tasks;
-
-namespace ScanNTune.UI.Platform;
-
-/// An image the user picked: its display name and its still-encoded bytes.
-public sealed record PickedFile(string Name, byte[] Data);
-
-/// 
-/// Head-supplied image file picking. The desktop head opens the native OS dialog; the browser head shows a
-/// sheet with a real, directly-tapped <input type=file>. The browser needs its own input because
-/// iOS Safari only opens a file dialog from a genuine DOM gesture, and Avalonia's own picker awaits (its JS
-/// storage module) before the click, which iOS treats as losing the user activation (see AvaloniaUI/Avalonia
-/// #11041). Returns null when the user cancels.
-/// 
-public interface IFilePicker
-{
-    Task PickImageAsync(string title);
-}
diff --git a/src/ScanNTune.UI/Platform/IPlatformImaging.cs b/src/ScanNTune.UI/Platform/IPlatformImaging.cs
deleted file mode 100644
index 127ca3a..0000000
--- a/src/ScanNTune.UI/Platform/IPlatformImaging.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using OpenCvSharp;
-
-namespace ScanNTune.UI.Platform;
-
-/// 
-/// Head-supplied image decoding. Turning encoded bytes (PNG/JPEG/...) into a BGR  is
-/// platform-specific: the desktop head uses OpenCV's own codecs, while the WebAssembly head decodes with
-/// Skia because the wasm OpenCV build ships no image codecs. (Overlays need no codec: the drawn Mat is
-/// turned into a bitmap directly from its pixels, which works on both platforms.)
-/// 
-public interface IPlatformImaging
-{
-    /// Decode encoded image bytes into a 3-channel BGR Mat, the layout the engine expects.
-    Mat DecodeBgr(byte[] data);
-
-    /// 
-    /// Read only the pixel dimensions of encoded image bytes, cheaply (from the header where the codec allows),
-    /// so a downscaled preview thumbnail can still report the scan's true resolution.
-    /// 
-    (int Width, int Height) GetImageSize(byte[] data);
-}
diff --git a/src/ScanNTune.UI/Platform/StorageFileReader.cs b/src/ScanNTune.UI/Platform/StorageFileReader.cs
deleted file mode 100644
index a9a3117..0000000
--- a/src/ScanNTune.UI/Platform/StorageFileReader.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using System.IO;
-using System.Threading.Tasks;
-using Avalonia.Platform.Storage;
-
-namespace ScanNTune.UI.Platform;
-
-/// 
-/// Reads an Avalonia  to a byte[] through its storage stream, which works on the
-/// desktop (real files) and in the browser (where no local path exists). Shared by the pages that ingest a
-/// picked or dropped scan so the read path lives in one place (shared by the views and the desktop picker).
-/// 
-public sealed class StorageFileReader
-{
-    public async Task ReadAllBytesAsync(IStorageFile file)
-    {
-        await using Stream stream = await file.OpenReadAsync();
-        using var buffer = new MemoryStream();
-        // In the browser the file is read in slices across the JS boundary and each slice is marshalled on the
-        // UI thread. A moderate chunk keeps round-trips down while still yielding often enough that the slot's
-        // busy spinner keeps animating during a large read instead of freezing in long bursts. Harmless on desktop.
-        await stream.CopyToAsync(buffer, 1 << 18);
-        return buffer.ToArray();
-    }
-}
diff --git a/src/ScanNTune.UI/ScanNTune.UI.csproj b/src/ScanNTune.UI/ScanNTune.UI.csproj
deleted file mode 100644
index 979cc53..0000000
--- a/src/ScanNTune.UI/ScanNTune.UI.csproj
+++ /dev/null
@@ -1,31 +0,0 @@
-
-  
-    net10.0
-    enable
-    latest
-    true
-  
-
-  
-    
-    
-    
-  
-
-  
-    
-    
-    
-    
-    
-    
-    
-      None
-      All
-    
-  
-
-  
-    
-  
-
diff --git a/src/ScanNTune.UI/Themes/Colors.Graphite.axaml b/src/ScanNTune.UI/Themes/Colors.Graphite.axaml
deleted file mode 100644
index 60c6886..0000000
--- a/src/ScanNTune.UI/Themes/Colors.Graphite.axaml
+++ /dev/null
@@ -1,38 +0,0 @@
-
-    #313338
-    #2B2D31
-    #383A40
-    #5865F2
-    #4752C4
-    #3C45A5
-    #FFFFFF
-    #DBDEE1
-    #949BA4
-    #80848E
-    #3F4147
-    #5865F2
-    #35373C
-    #5865F2
-    #404249
-    #E0A458
-    #4BB37B
-
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-    
-
diff --git a/src/ScanNTune.UI/Themes/Skins/Skin.Classic.axaml b/src/ScanNTune.UI/Themes/Skins/Skin.Classic.axaml
deleted file mode 100644
index 9660bfe..0000000
--- a/src/ScanNTune.UI/Themes/Skins/Skin.Classic.axaml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-    
-        2
-        2
-        2
-        1
-        10,5
-        28
-        13
-        0 0 0 0 #00000000
-    
-
-    
-
diff --git a/src/ScanNTune.UI/Themes/Skins/Skin.Shared.axaml b/src/ScanNTune.UI/Themes/Skins/Skin.Shared.axaml
deleted file mode 100644
index f7adbf0..0000000
--- a/src/ScanNTune.UI/Themes/Skins/Skin.Shared.axaml
+++ /dev/null
@@ -1,185 +0,0 @@
-
-    
-
-    
-
-    
-    
-    
-    
-    
-    
-
-    
-    
-    
-
-    
-    
-    
-
-    
-
-    
-    
-    
-
-    
-    
-    
-
-    
-    
-    
-    
-    
-
-    
-    
-    
-    
-    
-    
-    
-
-    
-
diff --git a/src/ScanNTune.UI/ViewLocator.cs b/src/ScanNTune.UI/ViewLocator.cs
deleted file mode 100644
index 43ba36e..0000000
--- a/src/ScanNTune.UI/ViewLocator.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System;
-using System.Diagnostics.CodeAnalysis;
-using Avalonia.Controls;
-using Avalonia.Controls.Templates;
-using ScanNTune.UI.ViewModels;
-
-namespace ScanNTune.UI;
-
-/// 
-/// Given a view model, returns the corresponding view if possible.
-/// 
-[RequiresUnreferencedCode(
-    "Default implementation of ViewLocator involves reflection which may be trimmed away.",
-    Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
-public class ViewLocator : IDataTemplate
-{
-    public Control? Build(object? param)
-    {
-        if (param is null)
-            return null;
-        
-        var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
-        var type = Type.GetType(name);
-
-        if (type != null)
-        {
-            return (Control)Activator.CreateInstance(type)!;
-        }
-        
-        return new TextBlock { Text = "Not Found: " + name };
-    }
-
-    public bool Match(object? data)
-    {
-        return data is ViewModelBase;
-    }
-}
diff --git a/src/ScanNTune.UI/ViewModels/CalibrationPageViewModel.cs b/src/ScanNTune.UI/ViewModels/CalibrationPageViewModel.cs
deleted file mode 100644
index ef366ee..0000000
--- a/src/ScanNTune.UI/ViewModels/CalibrationPageViewModel.cs
+++ /dev/null
@@ -1,298 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-using System.Threading.Tasks;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using Microsoft.Extensions.Logging;
-using OpenCvSharp;
-using ScanNTune.Core.Calibration;
-using ScanNTune.UI.Platform;
-
-namespace ScanNTune.UI.ViewModels;
-
-/// 
-/// The one-time scanner-calibration page. The user enters the reference card's measured long side and
-/// the scan DPI, loads a scan of it, and the engine auto-detects the card edges to recover the true
-/// px/mm. Quality checks (edge straightness, parallelism, and detected-size vs entered-size) surface
-/// a bad scan or a mistyped value before it is saved. Saving persists the calibration and returns.
-/// 
-public partial class CalibrationPageViewModel : ViewModelBase
-{
-    private const double IsoLongMm = 85.60;
-    private const double IsoToleranceMm = 0.25;
-    private const double MaxSizeMismatchMm = 0.3;
-
-    private readonly IScaleReferenceMeasurer _measurer;
-    private readonly ICalibrationStore _store;
-    private readonly IPlatformImaging _imaging;
-    private readonly IFilePicker _filePicker;
-    private readonly IDeviceInfo _deviceInfo;
-    private readonly Action _onDone;
-    private readonly ILogger _logger;
-    private ScaleReferenceResult? _result;
-    private bool _initialized;
-
-    // Stepper-driven (NumericUpDown) rather than free text so the fields work on the Android browser, where
-    // the soft keyboard can't commit typed characters. Defaults to the ISO ID-1 nominal (rounded to 85.5) so a
-    // mobile user is not forced to step up from blank in 0.1 mm increments; they can still adjust to a
-    // calipered value. Nullable so the field can be cleared.
-    [ObservableProperty]
-    private decimal? _measuredMm = 85.5m;
-
-    [ObservableProperty]
-    private decimal? _dpi = 600m;
-
-    [ObservableProperty]
-    private bool _isDetecting;
-
-    [ObservableProperty]
-    private bool _isError;
-
-    [ObservableProperty]
-    private string _statusText = string.Empty;
-
-    [ObservableProperty]
-    private bool _hasResult;
-
-    [ObservableProperty]
-    private string _pxPerMmText = string.Empty;
-
-    [ObservableProperty]
-    private string _effectiveDpiText = string.Empty;
-
-    [ObservableProperty]
-    private string _percentText = string.Empty;
-
-    [ObservableProperty]
-    private string _edgeQualityText = string.Empty;
-
-    [ObservableProperty]
-    private string _sizeSentence = string.Empty;
-
-    [ObservableProperty]
-    private bool _sizeCheckOk;
-
-    [ObservableProperty]
-    private bool _saved;
-
-    public CalibrationPageViewModel(IScaleReferenceMeasurer measurer, ICalibrationStore store,
-        IPlatformImaging imaging, IFilePicker filePicker, IDeviceInfo deviceInfo, Action onDone,
-        ILogger logger)
-    {
-        _measurer = measurer;
-        _store = store;
-        _imaging = imaging;
-        _filePicker = filePicker;
-        _deviceInfo = deviceInfo;
-        _onDone = onDone;
-        _logger = logger;
-
-        // Open "Recalibrate" on the current calibration — pre-fill the card's size and DPI and show
-        // its detected result — instead of a blank form.
-        ScannerCalibration? existing = store.Load();
-        if (existing is not null)
-        {
-            _result = new ScaleReferenceResult(
-                Success: true,
-                PxPerMm: existing.PxPerMm,
-                MeasuredWidthPx: existing.MeasuredWidthPx,
-                DetectedMm: existing.Dpi > 0 ? existing.MeasuredWidthPx / (existing.Dpi / 25.4) : 0,
-                StraightnessPx: existing.StraightnessPx,
-                ParallelismDegrees: existing.ParallelismDegrees,
-                EdgePointCount: 0);
-            MeasuredMm = (decimal)existing.ReferenceMm;
-            Dpi = (decimal)existing.Dpi;
-            Recompute();
-            // Reflect the stored state only if the prefill still passes the input checks — a stored
-            // calibration outside today's bounds must not show a saved checkmark over hidden figures.
-            Saved = HasResult;
-        }
-        _initialized = true;
-    }
-
-    // The view initiates the pick (it must run from the tap), so it reaches the head's picker through here.
-    internal IFilePicker FilePicker => _filePicker;
-
-    // On a touch device the numeric fields drop text entry (the view binds this) so tapping one cannot raise a
-    // soft keyboard or leave an uneditable caret; the +/- steppers remain the input.
-    public bool IsTouchDevice => _deviceInfo.IsTouchPrimary;
-
-    public bool HasStatus => !string.IsNullOrEmpty(StatusText);
-
-    public bool CanUpload => TryInputs(out _, out _);
-
-    public bool IsoSanityWarn =>
-        MeasuredMm is { } m && (double)m > 0 && Math.Abs((double)m - IsoLongMm) > IsoToleranceMm;
-
-    public string IsoSanityText
-    {
-        get
-        {
-            if (MeasuredMm is not { } m || (double)m <= 0)
-                return "Enter your calipered value.";
-            double v = (double)m;
-            double d = v - IsoLongMm;
-            if (Math.Abs(d) <= IsoToleranceMm)
-                return "In range for an ISO card (≈85.60 mm).";
-            return $"{d.ToString("+0.00;-0.00", CultureInfo.InvariantCulture)} mm vs ISO 85.60. Re-check the caliper.";
-        }
-    }
-
-    /// Loads the reference scan from its encoded bytes and auto-detects the card off the UI thread.
-    public async Task LoadScanAsync(string name, byte[] data)
-    {
-        if (!TryInputs(out double mm, out double dpi))
-        {
-            IsError = true;
-            StatusText = "Enter your measured size and a DPI of at least 50 first.";
-            return;
-        }
-
-        // The view owns IsDetecting (it turns the busy indicator on before the file read and clears it when
-        // this returns), so it already covers this detection too.
-        IsError = false;
-        HasResult = false;
-        StatusText = "Detecting the card…";
-        try
-        {
-            ScaleReferenceResult res = await Task.Run(() =>
-            {
-                using Mat image = _imaging.DecodeBgr(data);
-                return _measurer.Measure(image, mm, dpi);
-            });
-            if (!res.Success)
-            {
-                _result = null;
-                IsError = true;
-                StatusText = res.Message ?? "Couldn't detect the card in that scan.";
-                return;
-            }
-            _result = res;
-            StatusText = string.Empty;
-            Recompute(); // sets HasResult (inputs are valid at this point)
-        }
-        catch (Exception ex)
-        {
-            _logger.LogError(ex, "Couldn't read the card scan {Name}.", name);
-            _result = null;
-            IsError = true;
-            StatusText = $"Couldn't read the scan: {ex.Message}";
-        }
-    }
-
-    /// 
-    /// Re-derives the displayed figures from the (fixed) detected edge width and the current mm/DPI,
-    /// so editing either field after a detection updates the result without re-scanning.
-    /// 
-    private void Recompute()
-    {
-        if (_result is null)
-            return;
-        if (!TryInputs(out double mm, out double dpi))
-        {
-            // Inputs went incomplete after a detection — hide the now-stale result (and Save) until
-            // a valid mm/DPI is entered again.
-            HasResult = false;
-            return;
-        }
-
-        double widthPx = _result.MeasuredWidthPx;
-        double pxPerMm = widthPx / mm;
-        double detectedMm = widthPx / (dpi / 25.4);
-        double sizeDiff = Math.Abs(detectedMm - mm);
-
-        PxPerMmText = pxPerMm.ToString("0.000", CultureInfo.InvariantCulture);
-        EffectiveDpiText = (pxPerMm * 25.4).ToString("0", CultureInfo.InvariantCulture);
-        PercentText = ((pxPerMm / (dpi / 25.4) - 1.0) * 100.0).ToString("+0.000;-0.000", CultureInfo.InvariantCulture) + "%";
-        EdgeQualityText = $"Edges straight to {_result.StraightnessPx.ToString("0.00", CultureInfo.InvariantCulture)} px, " +
-                          $"parallel to {_result.ParallelismDegrees.ToString("0.000", CultureInfo.InvariantCulture)}°.";
-        SizeCheckOk = sizeDiff < MaxSizeMismatchMm;
-        string detected = detectedMm.ToString("0.00", CultureInfo.InvariantCulture);
-        string entered = mm.ToString("0.00", CultureInfo.InvariantCulture);
-        SizeSentence = SizeCheckOk
-            ? $"Detected {detected} mm, matches your {entered} mm."
-            : $"Detected {detected} mm doesn't match your {entered} mm. Re-check the DPI and your " +
-              "measurement. Printed artwork near the card edge can also mislead the detection; a plain edge works best.";
-        HasResult = true;
-        Persist();
-    }
-
-    /// 
-    /// Saves automatically once a scan is detected and its size checks out — there is no separate
-    /// save step. A size-mismatched detection (wrong DPI or a mistyped reference) is NOT persisted,
-    /// so it can't silently overwrite a good calibration.
-    /// 
-    private void Persist()
-    {
-        if (!_initialized || _result is null || !TryInputs(out double mm, out double dpi) || !SizeCheckOk)
-        {
-            Saved = false;
-            return;
-        }
-
-        var calibration = new ScannerCalibration(
-            PxPerMm: _result.MeasuredWidthPx / mm,
-            Dpi: dpi,
-            ReferenceMm: mm,
-            MeasuredWidthPx: _result.MeasuredWidthPx,
-            StraightnessPx: _result.StraightnessPx,
-            ParallelismDegrees: _result.ParallelismDegrees,
-            CalibratedUtc: DateTime.UtcNow);
-        try
-        {
-            _store.Save(calibration);
-            Saved = true;
-        }
-        catch (Exception ex)
-        {
-            _logger.LogError(ex, "Couldn't save the scanner calibration.");
-            Saved = false;
-            IsError = true;
-            StatusText = $"Couldn't save the calibration: {ex.Message}";
-        }
-    }
-
-    [RelayCommand]
-    private void Back() => _onDone();
-
-    /// Logs a file-open/read failure surfaced by the view's storage glue.
-    public void LogError(string message, Exception ex) => _logger.LogError(ex, "{Message}", message);
-
-    partial void OnStatusTextChanged(string value) => OnPropertyChanged(nameof(HasStatus));
-
-    partial void OnMeasuredMmChanged(decimal? value)
-    {
-        ClearErrorOnEdit();
-        OnPropertyChanged(nameof(IsoSanityText));
-        OnPropertyChanged(nameof(IsoSanityWarn));
-        OnPropertyChanged(nameof(CanUpload));
-        Recompute();
-    }
-
-    partial void OnDpiChanged(decimal? value)
-    {
-        ClearErrorOnEdit();
-        OnPropertyChanged(nameof(CanUpload));
-        Recompute();
-    }
-
-    // A prior detection error shouldn't linger over now-valid inputs.
-    private void ClearErrorOnEdit()
-    {
-        if (IsError)
-        {
-            IsError = false;
-            StatusText = string.Empty;
-        }
-    }
-
-    private bool TryInputs(out double mm, out double dpi)
-    {
-        // A DPI floor of 50 rules out a mistyped value; there is no ceiling, so an unusually high-resolution
-        // scan is never rejected. The steppers already prevent non-numeric or negative entries.
-        mm = MeasuredMm is { } m ? (double)m : 0;
-        dpi = Dpi is { } d ? (double)d : 0;
-        return mm > 0 && dpi >= 50;
-    }
-}
diff --git a/src/ScanNTune.UI/ViewModels/MainWindowViewModel.cs b/src/ScanNTune.UI/ViewModels/MainWindowViewModel.cs
deleted file mode 100644
index cd3d87a..0000000
--- a/src/ScanNTune.UI/ViewModels/MainWindowViewModel.cs
+++ /dev/null
@@ -1,117 +0,0 @@
-using System;
-using System.Reflection;
-using Avalonia.Media.Imaging;
-using CommunityToolkit.Mvvm.ComponentModel;
-using Microsoft.Extensions.Logging;
-using ScanNTune.Core;
-using ScanNTune.Core.Calibration;
-using ScanNTune.Core.Combining;
-using ScanNTune.Core.Output;
-using ScanNTune.UI.DependencyInjection;
-using ScanNTune.UI.Platform;
-
-namespace ScanNTune.UI.ViewModels;
-
-/// 
-/// The application shell. It owns the engine services and the in-window navigation: a single
-///  is swapped between the scan-input page and the results page (resolved
-/// to a view by the ViewLocator). It is head-agnostic: platform behaviour arrives through
-///  and , so the desktop and WebAssembly
-/// heads share this exact shell.
-/// 
-public partial class MainWindowViewModel : ViewModelBase
-{
-    private readonly ICouponAnalyzer _analyzer;
-    private readonly IScanCombiner _combiner;
-    private readonly IOverlayRenderer _overlayRenderer;
-    private readonly ICorrectionFormatter _corrections;
-    private readonly IScaleReferenceMeasurer _measurer;
-    private readonly ICalibrationStore _calibrationStore;
-    private readonly IPlatformImaging _imaging;
-    private readonly IFilePicker _filePicker;
-    private readonly IDeviceInfo _deviceInfo;
-    private readonly ICouponExporter _couponExporter;
-    private readonly ILoggerFactory _loggerFactory;
-
-    [ObservableProperty]
-    private ViewModelBase _currentPage = null!;
-
-    /// Set once a background update has been downloaded and staged for the next restart.
-    [ObservableProperty]
-    private bool _updateReady;
-
-    public string UpdateStatusText => "Update ready, restart to apply";
-
-    /// Display version for the title bar, e.g. "v0.1.24" — stamped by Nerdbank.GitVersioning.
-    public string AppVersion
-    {
-        get
-        {
-            Assembly assembly = typeof(MainWindowViewModel).Assembly;
-            string? informational = assembly
-                .GetCustomAttribute()?.InformationalVersion;
-            string version = informational?.Split('+', 2)[0]
-                ?? assembly.GetName().Version?.ToString(3)
-                ?? "0.0.0";
-            return $"v{version}";
-        }
-    }
-
-    // Design-time only: lets the XAML previewer build the shell from the same UiModule registrations the app
-    // uses at runtime (with stub platform services and silent logging), so it never drifts from a hand-built
-    // parallel graph. Never used at runtime (the container resolves the constructor below).
-    public MainWindowViewModel() : this(new DesignGraph())
-    {
-    }
-
-    private MainWindowViewModel(DesignGraph design)
-        : this(design.Get(), design.Get(), design.Get(),
-               design.Get(), design.Get(), design.Get(),
-               design.Get(), design.Get(), design.Get(), design.Get(), design.Get())
-    {
-    }
-
-    public MainWindowViewModel(
-        ICouponAnalyzer analyzer,
-        IScanCombiner combiner,
-        IOverlayRenderer overlayRenderer,
-        ICorrectionFormatter corrections,
-        IScaleReferenceMeasurer measurer,
-        ICalibrationStore calibrationStore,
-        IPlatformImaging imaging,
-        IFilePicker filePicker,
-        IDeviceInfo deviceInfo,
-        ICouponExporter couponExporter,
-        ILoggerFactory loggerFactory)
-    {
-        _analyzer = analyzer;
-        _combiner = combiner;
-        _overlayRenderer = overlayRenderer;
-        _corrections = corrections;
-        _measurer = measurer;
-        _calibrationStore = calibrationStore;
-        _imaging = imaging;
-        _filePicker = filePicker;
-        _deviceInfo = deviceInfo;
-        _couponExporter = couponExporter;
-        _loggerFactory = loggerFactory;
-        CurrentPage = CreateScanPage();
-    }
-
-    public void MarkUpdateReady() => UpdateReady = true;
-
-    private ScanPageViewModel CreateScanPage() =>
-        new(_analyzer, _combiner, _overlayRenderer, _calibrationStore, _imaging, _filePicker, _deviceInfo, _couponExporter,
-            ShowResults, ShowCalibration, _loggerFactory.CreateLogger());
-
-    private void ShowResults(TwoScanResult result, CouponSpec coupon, Bitmap? overlayA, Bitmap? overlayB) =>
-        CurrentPage = new ResultsPageViewModel(result, coupon, overlayA, overlayB, _corrections, StartOver);
-
-    private void ShowCalibration() =>
-        CurrentPage = new CalibrationPageViewModel(_measurer, _calibrationStore, _imaging, _filePicker, _deviceInfo, StartOver,
-            _loggerFactory.CreateLogger());
-
-    // Rebuilding the scan page re-reads the stored calibration, so the status pill reflects a
-    // just-saved calibration on return.
-    private void StartOver() => CurrentPage = CreateScanPage();
-}
diff --git a/src/ScanNTune.UI/ViewModels/ResultsPageViewModel.cs b/src/ScanNTune.UI/ViewModels/ResultsPageViewModel.cs
deleted file mode 100644
index 293b8d9..0000000
--- a/src/ScanNTune.UI/ViewModels/ResultsPageViewModel.cs
+++ /dev/null
@@ -1,174 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Avalonia.Media.Imaging;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using ScanNTune.Core;
-using ScanNTune.Core.Output;
-
-namespace ScanNTune.UI.ViewModels;
-
-/// 
-/// The results page: the scanner-cancelled printer figures (from combining the two quarter-turn
-/// scans), the scanner's own error as a diagnostic, and the ready-to-paste firmware/slicer
-/// corrections. A "start over" action returns to the scan page.
-/// 
-public partial class ResultsPageViewModel : ViewModelBase
-{
-    private readonly TwoScanResult _result;
-    private readonly CouponSpec _coupon;
-    private readonly ICorrectionFormatter _corrections;
-    private readonly Action _onStartOver;
-
-    [ObservableProperty]
-    private string _selectedSkewFlavour;
-
-    [ObservableProperty]
-    private string _selectedSizeFlavour;
-
-    [ObservableProperty]
-    [NotifyPropertyChangedFor(nameof(HasSkewPrimaryCaption))]
-    private string _skewPrimaryCaption = string.Empty;
-
-    [ObservableProperty]
-    private string _skewCode = string.Empty;
-
-    [ObservableProperty]
-    private string _skewSecondaryCaption = string.Empty;
-
-    [ObservableProperty]
-    [NotifyPropertyChangedFor(nameof(HasSkewSecondary))]
-    private string _skewSecondaryCode = string.Empty;
-
-    [ObservableProperty]
-    [NotifyPropertyChangedFor(nameof(HasSkewHint))]
-    private string _skewHint = string.Empty;
-
-    public bool HasSkewPrimaryCaption => !string.IsNullOrEmpty(SkewPrimaryCaption);
-    public bool HasSkewSecondary => !string.IsNullOrEmpty(SkewSecondaryCode);
-    public bool HasSkewHint => !string.IsNullOrEmpty(SkewHint);
-
-    [ObservableProperty]
-    private string _sizeCode = string.Empty;
-
-    [ObservableProperty]
-    private string _sizeHint = string.Empty;
-
-    [ObservableProperty]
-    private bool _showCurrentInputs;
-
-    [ObservableProperty]
-    private string _currentLabel = string.Empty;
-
-    // Stepper-driven so the fields work on the Android browser (its soft keyboard can't commit typed text).
-    [ObservableProperty]
-    private decimal? _currentX;
-
-    [ObservableProperty]
-    private decimal? _currentY;
-
-    [ObservableProperty]
-    private bool _scannerExpanded;
-
-    public ResultsPageViewModel(
-        TwoScanResult result,
-        CouponSpec coupon,
-        Bitmap? overlayA,
-        Bitmap? overlayB,
-        ICorrectionFormatter corrections,
-        Action onStartOver)
-    {
-        _result = result;
-        _coupon = coupon;
-        _corrections = corrections;
-        _onStartOver = onStartOver;
-        OverlayA = overlayA;
-        OverlayB = overlayB;
-
-        _selectedSkewFlavour = corrections.SkewFlavours[0];
-        _selectedSizeFlavour = corrections.SizeFlavours[0];
-        string? label = corrections.CurrentValueLabel(_selectedSizeFlavour);
-        _showCurrentInputs = label is not null;
-        _currentLabel = label ?? string.Empty;
-
-        RecomputeSkew();
-        RecomputeSize();
-    }
-
-    public Bitmap? OverlayA { get; }
-
-    public Bitmap? OverlayB { get; }
-
-    public IReadOnlyList SkewFlavours => _corrections.SkewFlavours;
-
-    public IReadOnlyList SizeFlavours => _corrections.SizeFlavours;
-
-    private CalibrationResult Combined => _result.Combined;
-
-    public string XScaleText => $"{Combined.XScalePercent:+0.000;-0.000;0.000} %";
-    public string YScaleText => $"{Combined.YScalePercent:+0.000;-0.000;0.000} %";
-    public string SkewText => $"{Combined.SkewDegrees:+0.000;-0.000;0.000}°";
-
-    /// The one-line glance under the tiles: coverage, fit and the quarter-turn check.
-    public string SummaryText =>
-        $"{Combined.RingsDetected} rings · {Combined.RmsResidualPx:0.00} px fit · {_result.RelativeRotationDegrees:0}° turn";
-
-    public bool RotationLooksValid => _result.RotationLooksValid;
-
-    public string ScanADetailText =>
-        $"Scan 1    X {_result.ScanA.XScalePercent:+0.00;-0.00}%    Y {_result.ScanA.YScalePercent:+0.00;-0.00}%    skew {_result.ScanA.SkewDegrees:+0.00;-0.00}°";
-
-    public string ScanBDetailText =>
-        $"Scan 2    X {_result.ScanB.XScalePercent:+0.00;-0.00}%    Y {_result.ScanB.YScalePercent:+0.00;-0.00}%    skew {_result.ScanB.SkewDegrees:+0.00;-0.00}°";
-
-    public string ScannerDetailText =>
-        $"Scanner    X/Y bias {_result.Scanner.AnisotropyPercent:+0.00;-0.00}%    skew {_result.Scanner.SkewDegrees:+0.00;-0.00}°";
-
-    public bool RotationWarningVisible => !_result.RotationLooksValid;
-
-    public string RotationWarningText => _result.FlipMismatch
-        ? "One scan is mirror-flipped relative to the other, so the scanner error was not cancelled. " +
-          "Scan both with the same face on the glass and try again."
-        : $"The two scans are {_result.RelativeRotationDegrees:0}° apart, not a quarter-turn. " +
-          "The scanner error was not cancelled. Turn the coupon ~90° between scans and try again.";
-
-    [RelayCommand]
-    private void StartOver() => _onStartOver();
-
-    [RelayCommand]
-    private void ToggleScanner() => ScannerExpanded = !ScannerExpanded;
-
-    private void RecomputeSkew()
-    {
-        Correction correction = _corrections.Skew(SelectedSkewFlavour, Combined.SkewDegrees, _coupon);
-        SkewCode = correction.Code;
-        SkewHint = correction.Hint;
-        SkewPrimaryCaption = correction.PrimaryCaption ?? string.Empty;
-        SkewSecondaryCaption = correction.SecondaryCaption ?? string.Empty;
-        SkewSecondaryCode = correction.SecondaryCode ?? string.Empty;
-    }
-
-    private void RecomputeSize()
-    {
-        double? currentX = ShowCurrentInputs && CurrentX is { } x ? (double)x : null;
-        double? currentY = ShowCurrentInputs && CurrentY is { } y ? (double)y : null;
-
-        Correction correction = _corrections.Size(SelectedSizeFlavour, Combined.XScalePercent, Combined.YScalePercent, currentX, currentY);
-        SizeCode = correction.Code;
-        SizeHint = correction.Hint;
-    }
-
-    partial void OnSelectedSkewFlavourChanged(string value) => RecomputeSkew();
-
-    partial void OnSelectedSizeFlavourChanged(string value)
-    {
-        string? label = _corrections.CurrentValueLabel(value);
-        ShowCurrentInputs = label is not null;
-        CurrentLabel = label ?? string.Empty;
-        RecomputeSize();
-    }
-
-    partial void OnCurrentXChanged(decimal? value) => RecomputeSize();
-
-    partial void OnCurrentYChanged(decimal? value) => RecomputeSize();
-}
diff --git a/src/ScanNTune.UI/ViewModels/ScanAnalysisException.cs b/src/ScanNTune.UI/ViewModels/ScanAnalysisException.cs
deleted file mode 100644
index 453c657..0000000
--- a/src/ScanNTune.UI/ViewModels/ScanAnalysisException.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System;
-using Avalonia.Media.Imaging;
-
-namespace ScanNTune.UI.ViewModels;
-
-/// 
-/// Raised when one scan fails to resolve, carrying which scan it was, how many rings were found, and a
-/// diagnostic overlay (the detected rings) so the UI can show what was captured.
-/// 
-public sealed class ScanAnalysisException : Exception
-{
-    public ScanAnalysisException(bool isFirst, int ringCount, string message, Bitmap? diagnostic)
-        : base(message)
-    {
-        IsFirst = isFirst;
-        RingCount = ringCount;
-        Diagnostic = diagnostic;
-    }
-
-    public bool IsFirst { get; }
-
-    public int RingCount { get; }
-
-    public Bitmap? Diagnostic { get; }
-}
diff --git a/src/ScanNTune.UI/ViewModels/ScanPageViewModel.cs b/src/ScanNTune.UI/ViewModels/ScanPageViewModel.cs
deleted file mode 100644
index 167aae1..0000000
--- a/src/ScanNTune.UI/ViewModels/ScanPageViewModel.cs
+++ /dev/null
@@ -1,411 +0,0 @@
-using System;
-using System.Globalization;
-using System.IO;
-using System.Threading.Tasks;
-using Avalonia.Media.Imaging;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using Microsoft.Extensions.Logging;
-using OpenCvSharp;
-using ScanNTune.Core;
-using ScanNTune.Core.Calibration;
-using ScanNTune.Core.Combining;
-using ScanNTune.Core.Output;
-using ScanNTune.UI.Platform;
-
-namespace ScanNTune.UI.ViewModels;
-
-/// 
-/// The two-scan input page: the user loads two scans of the same coupon (the second taken after a
-/// quarter-turn), sets the DPI/coupon geometry, and analyzes. Both scans are required: a single scan
-/// cannot separate the printer's error from the scanner's, so there is deliberately no one-scan path.
-/// Scans are held as encoded bytes and decoded through , so the same view
-/// model runs on the desktop (OpenCV codecs) and in the browser (Skia).
-/// 
-public partial class ScanPageViewModel : ViewModelBase
-{
-    private readonly ICouponAnalyzer _analyzer;
-    private readonly IScanCombiner _combiner;
-    private readonly IOverlayRenderer _overlayRenderer;
-    private readonly ICalibrationStore _calibrationStore;
-    private readonly IPlatformImaging _imaging;
-    private readonly IFilePicker _filePicker;
-    private readonly IDeviceInfo _deviceInfo;
-    private readonly ICouponExporter _couponExporter;
-    private readonly Action _onAnalyzed;
-    private readonly Action _onCalibrate;
-    private readonly ScannerCalibration? _calibration;
-    private readonly ILogger _logger;
-
-    // A downscaled preview is decoded for display; the caption still shows the scan's true pixel size, kept here.
-    private const int ThumbnailWidth = 1000;
-
-    private byte[]? _scan1Data;
-    private byte[]? _scan2Data;
-    private string? _scan1Name;
-    private string? _scan2Name;
-    private (int Width, int Height)? _scan1Size;
-    private (int Width, int Height)? _scan2Size;
-
-    [ObservableProperty]
-    private Bitmap? _scan1Thumb;
-
-    [ObservableProperty]
-    private Bitmap? _scan2Thumb;
-
-    // Numeric fields are stepper-driven (NumericUpDown) rather than free text: on the Android browser the
-    // soft keyboard can't commit typed characters (an Avalonia framework limitation), so the +/- spinners
-    // are the reliable input. Nullable so DPI can be left blank for the anisotropy-and-skew-only path.
-    [ObservableProperty]
-    private decimal? _dpi = 1200m;
-
-    [ObservableProperty]
-    private decimal? _baselineMm = 100m;
-
-    [ObservableProperty]
-    private decimal? _grid = 5m;
-
-    [ObservableProperty]
-    private bool _isBusy;
-
-    [ObservableProperty]
-    private bool _scan1Loading;
-
-    [ObservableProperty]
-    private bool _scan2Loading;
-
-    [ObservableProperty]
-    private bool _isError;
-
-    [ObservableProperty]
-    private bool _scan1Failed;
-
-    [ObservableProperty]
-    private bool _scan2Failed;
-
-    [ObservableProperty]
-    private string _scan1Note = string.Empty;
-
-    [ObservableProperty]
-    private string _scan2Note = string.Empty;
-
-    [ObservableProperty]
-    private string _statusText = string.Empty;
-
-    public ScanPageViewModel(
-        ICouponAnalyzer analyzer,
-        IScanCombiner combiner,
-        IOverlayRenderer overlayRenderer,
-        ICalibrationStore calibrationStore,
-        IPlatformImaging imaging,
-        IFilePicker filePicker,
-        IDeviceInfo deviceInfo,
-        ICouponExporter couponExporter,
-        Action onAnalyzed,
-        Action onCalibrate,
-        ILogger logger)
-    {
-        _analyzer = analyzer;
-        _combiner = combiner;
-        _overlayRenderer = overlayRenderer;
-        _calibrationStore = calibrationStore;
-        _imaging = imaging;
-        _filePicker = filePicker;
-        _deviceInfo = deviceInfo;
-        _couponExporter = couponExporter;
-        _onAnalyzed = onAnalyzed;
-        _onCalibrate = onCalibrate;
-        _logger = logger;
-        _calibration = calibrationStore.Load();
-    }
-
-    // The view initiates the pick (it must run from the tap), so it reaches the head's picker through here.
-    internal IFilePicker FilePicker => _filePicker;
-
-    // On a touch device the numeric fields drop text entry (the view binds this) so tapping one cannot raise a
-    // soft keyboard or leave an uneditable caret; the +/- steppers remain the input.
-    public bool IsTouchDevice => _deviceInfo.IsTouchPrimary;
-
-    public bool IsCalibrated => _calibration is not null;
-
-    // Single status line under the step-1 header (the header already says "Calibrate scanner").
-    public string CalibrationLineText => _calibration is null
-        ? "Optional. Calibrate once for absolute X/Y scale. Skew and anisotropy work without it."
-        : $"Calibrated · {_calibration.EffectiveDpi.ToString("0", CultureInfo.InvariantCulture)} dpi";
-
-    public string CalibrateButtonText => _calibration is null ? "Calibrate scanner" : "Recalibrate";
-
-    // A stored calibration assumes the coupon is scanned at the same DPI it was calibrated at (AnalyzeAsync
-    // uses the calibrated px/mm directly), so remind the user of that exact resolution. Without a
-    // calibration there is no DPI to anchor to, so the hint is hidden (see ScanDpiHint binding).
-    public string ScanDpiHint => _calibration is null
-        ? string.Empty
-        : $"Scan both at {_calibration.Dpi.ToString("0", CultureInfo.InvariantCulture)} dpi.";
-
-    [RelayCommand]
-    private void Calibrate() => _onCalibrate();
-
-    /// Hands the user the printable coupon STL through the head's platform exporter.
-    [RelayCommand]
-    private async Task GetCoupon()
-    {
-        try
-        {
-            await _couponExporter.ExportAsync();
-        }
-        catch (Exception ex)
-        {
-            ReportLoadError("Could not open the coupon", ex);
-        }
-    }
-
-    public bool HasScan1 => Scan1Thumb is not null;
-
-    public bool HasScan2 => Scan2Thumb is not null;
-
-    public bool HasStatus => !string.IsNullOrEmpty(StatusText);
-
-    public string Scan1Caption => Caption(_scan1Name, _scan1Size);
-
-    public string Scan2Caption => Caption(_scan2Name, _scan2Size);
-
-    private string Caption(string? name, (int Width, int Height)? size) =>
-        name is null || size is not { } s
-            ? string.Empty
-            : $"{name} · {s.Width}×{s.Height}";
-
-    /// Load the first (0°) scan from its encoded bytes; a bad image is surfaced, not thrown.
-    public Task LoadScan1Async(string name, byte[] data) => LoadAsync(name, data, isFirst: true);
-
-    /// Load the second (quarter-turned) scan.
-    public Task LoadScan2Async(string name, byte[] data) => LoadAsync(name, data, isFirst: false);
-
-    private async Task LoadAsync(string name, byte[] data, bool isFirst)
-    {
-        // The view turns the slot's busy spinner on before the file read and clears it when this returns, so it
-        // already covers this decode too. Yield once so any pending state paints before the decode briefly
-        // blocks the single wasm thread.
-        IsError = false;
-        await Task.Yield();
-        try
-        {
-            // Decode the preview downscaled. A full-resolution decode of a large phone photo would allocate
-            // tens to hundreds of MB on the single wasm thread near the heap ceiling: that is the multi-minute
-            // wait and the intermittent out-of-memory that only shows on mobile. DecodeToWidth samples at the
-            // codec level, so the preview stays small and fast. The true pixel size for the caption comes from
-            // the image header via the platform imaging, so the downscale does not misreport the resolution.
-            // Read the true size first (cheap header read): it feeds the caption, and getting it before the
-            // decode means a bad header fails fast with no preview bitmap left orphaned. Decode the preview to
-            // the smaller of the target and the source width, so a small scan stays crisp (never upscaled) while
-            // a large photo is sampled down.
-            (int Width, int Height) size = _imaging.GetImageSize(data);
-            int targetWidth = size.Width > 0 ? Math.Min(ThumbnailWidth, size.Width) : ThumbnailWidth;
-            using var stream = new MemoryStream(data);
-            Bitmap bitmap = Bitmap.DecodeToWidth(stream, targetWidth);
-            if (isFirst)
-            {
-                Scan1Thumb?.Dispose();
-                Scan1Thumb = bitmap;
-                _scan1Data = data;
-                _scan1Name = name;
-                _scan1Size = size;
-                Scan1Failed = false;
-                Scan1Note = string.Empty;
-                OnPropertyChanged(nameof(HasScan1));
-                OnPropertyChanged(nameof(Scan1Caption));
-            }
-            else
-            {
-                Scan2Thumb?.Dispose();
-                Scan2Thumb = bitmap;
-                _scan2Data = data;
-                _scan2Name = name;
-                _scan2Size = size;
-                Scan2Failed = false;
-                Scan2Note = string.Empty;
-                OnPropertyChanged(nameof(HasScan2));
-                OnPropertyChanged(nameof(Scan2Caption));
-            }
-            // The filename is shown in the slot itself, so the status line stays quiet on load: it's
-            // reserved for transient states (analyzing, errors).
-            StatusText = string.Empty;
-            AnalyzeCommand.NotifyCanExecuteChanged();
-        }
-        catch (Exception ex)
-        {
-            ReportLoadError("Could not load image", ex);
-        }
-    }
-
-    /// Logs a load/open failure and surfaces it on the status line. Called by the view's glue too.
-    public void ReportLoadError(string message, Exception ex)
-    {
-        _logger.LogError(ex, "{Message}.", message);
-        IsError = true;
-        StatusText = $"{message}: {ex.Message}";
-    }
-
-    [RelayCommand(CanExecute = nameof(CanAnalyze))]
-    private async Task AnalyzeAsync()
-    {
-        if (_scan1Data is not { } data1 || _scan2Data is not { } data2)
-            return;
-
-        IsBusy = true;
-        IsError = false;
-        Scan1Failed = false;
-        Scan2Failed = false;
-        Scan1Note = string.Empty;
-        Scan2Note = string.Empty;
-        AnalyzeCommand.NotifyCanExecuteChanged();
-        StatusText = "Analyzing both scans…";
-        // WebAssembly is single-threaded, so the analysis below runs on the UI thread and briefly blocks it;
-        // yield once so the "Analyzing…" state and progress bar paint before that happens.
-        await Task.Yield();
-        try
-        {
-            // With a stored calibration, use the scanner's measured px/mm directly (the coupon is
-            // scanned at the calibrated DPI); otherwise fall back to the entered nominal DPI/25.4
-            // (anisotropy + skew stay correct either way). A blank DPI is the deliberate
-            // anisotropy-and-skew-only path; the stepper's floor keeps any entered DPI realistic.
-            double? pxPerMm;
-            if (_calibration is not null)
-                pxPerMm = _calibration.PxPerMm;
-            else if (Dpi is { } dpi && dpi >= 50)
-                pxPerMm = (double)dpi / 25.4;
-            else
-                pxPerMm = null;
-
-            CouponSpec coupon = BuildCoupon();
-            var options = new AnalysisOptions { PxPerMm = pxPerMm, Coupon = coupon };
-
-            (TwoScanResult result, Bitmap overlayA, Bitmap overlayB) = await Task.Run(() =>
-            {
-                using Mat image1 = _imaging.DecodeBgr(data1);
-                using Mat image2 = _imaging.DecodeBgr(data2);
-                CalibrationResult a = AnalyzeScan(image1, options, isFirst: true);
-                CalibrationResult b = AnalyzeScan(image2, options, isFirst: false);
-                TwoScanResult combined = _combiner.Combine(a, b);
-                Bitmap oa = RenderOverlayBitmap(image1, a);
-                Bitmap ob = RenderOverlayBitmap(image2, b);
-                return (combined, oa, ob);
-            });
-
-            StatusText = string.Empty;
-            _onAnalyzed(result, coupon, overlayA, overlayB);
-        }
-        catch (ScanAnalysisException ex)
-        {
-            _logger.LogWarning("Scan analysis could not align {Which} scan ({Rings} rings): {Message}",
-                ex.IsFirst ? "first" : "second", ex.RingCount, ex.Message);
-            // Show what the failing scan DID capture, in its own slot, alongside the guidance.
-            ShowScanFailure(ex);
-        }
-        catch (Exception ex)
-        {
-            _logger.LogError(ex, "Two-scan analysis failed.");
-            IsError = true;
-            StatusText = $"{ex.Message} Check the scan quality and that the coupon's two-solid marker is visible.";
-        }
-        finally
-        {
-            IsBusy = false;
-            AnalyzeCommand.NotifyCanExecuteChanged();
-        }
-    }
-
-    /// 
-    /// Analyze one scan. On a resolve failure, render the rings that WERE found (where the platform can
-    /// encode them) and rethrow a  carrying that diagnostic.
-    /// 
-    private CalibrationResult AnalyzeScan(Mat image, AnalysisOptions options, bool isFirst)
-    {
-        try
-        {
-            return _analyzer.Analyze(image, options);
-        }
-        catch (CouponAnalysisException ex)
-        {
-            using Mat overlay = _overlayRenderer.RenderDetectionOverlay(image, ex.DetectedRings);
-            throw new ScanAnalysisException(isFirst, ex.DetectedRings.Count, ex.Message, MatToBitmap(overlay));
-        }
-    }
-
-    private Bitmap RenderOverlayBitmap(Mat image, CalibrationResult result)
-    {
-        using Mat overlay = _overlayRenderer.RenderOverlay(image, result);
-        return MatToBitmap(overlay);
-    }
-
-    // Turn a drawn BGR overlay into an Avalonia bitmap straight from its pixels, so no image codec is
-    // needed: the wasm OpenCV build has no PNG encoder, but the drawing itself works on both platforms.
-    private Bitmap MatToBitmap(Mat bgr)
-    {
-        using var bgra = new Mat();
-        Cv2.CvtColor(bgr, bgra, ColorConversionCodes.BGR2BGRA);
-        return new Bitmap(
-            Avalonia.Platform.PixelFormats.Bgra8888,
-            Avalonia.Platform.AlphaFormat.Opaque,
-            bgra.Data,
-            new Avalonia.PixelSize(bgra.Width, bgra.Height),
-            new Avalonia.Vector(96, 96),
-            (int)bgra.Step());
-    }
-
-    private void ShowScanFailure(ScanAnalysisException ex)
-    {
-        IsError = true;
-        string which = ex.IsFirst ? "First scan" : "Second scan";
-        string note = ex.RingCount > 0
-            ? $"⚠ {ex.RingCount} rings found, but couldn't align them"
-            : "⚠ nothing detected";
-        Bitmap? diagnostic = ex.Diagnostic;
-
-        if (ex.IsFirst)
-        {
-            if (diagnostic is not null)
-            {
-                Scan1Thumb?.Dispose();
-                Scan1Thumb = diagnostic;
-            }
-            Scan1Failed = true;
-            Scan1Note = note;
-            OnPropertyChanged(nameof(HasScan1));
-        }
-        else
-        {
-            if (diagnostic is not null)
-            {
-                Scan2Thumb?.Dispose();
-                Scan2Thumb = diagnostic;
-            }
-            Scan2Failed = true;
-            Scan2Note = note;
-            OnPropertyChanged(nameof(HasScan2));
-        }
-
-        StatusText = ex.RingCount > 0
-            ? $"{which}: found {ex.RingCount} rings but couldn't locate the orientation marker. " +
-              "Check that both solid marker rings and the whole coupon are in the scan (green circles show what was detected)."
-            : $"{which}: no rings detected. The coupon may be out of frame or too faint. Check the scan contrast and DPI.";
-    }
-
-    private bool CanAnalyze() => !IsBusy && _scan1Data is not null && _scan2Data is not null;
-
-    partial void OnStatusTextChanged(string value) => OnPropertyChanged(nameof(HasStatus));
-
-    /// 
-    /// Builds the coupon spec from the stepper fields. Each field carries a valid default and the steppers
-    /// clamp to a sensible floor (baseline > 0, at least two rings a side), so a blank field simply keeps
-    /// the  default rather than needing a separate validation error path.
-    /// 
-    private CouponSpec BuildCoupon()
-    {
-        var coupon = new CouponSpec();
-        if (BaselineMm is { } baseline && baseline > 0)
-            coupon = coupon with { BaselineMm = (double)baseline };
-        if (Grid is { } grid && grid >= 2)
-            coupon = coupon with { GridN = (int)Math.Round(grid) };
-        return coupon;
-    }
-}
diff --git a/src/ScanNTune.UI/ViewModels/ViewModelBase.cs b/src/ScanNTune.UI/ViewModels/ViewModelBase.cs
deleted file mode 100644
index 8938274..0000000
--- a/src/ScanNTune.UI/ViewModels/ViewModelBase.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-using CommunityToolkit.Mvvm.ComponentModel;
-
-namespace ScanNTune.UI.ViewModels;
-
-public abstract class ViewModelBase : ObservableObject
-{
-}
diff --git a/src/ScanNTune.UI/Views/CalibrationPageView.axaml b/src/ScanNTune.UI/Views/CalibrationPageView.axaml
deleted file mode 100644
index 413020d..0000000
--- a/src/ScanNTune.UI/Views/CalibrationPageView.axaml
+++ /dev/null
@@ -1,261 +0,0 @@
-
-
-    
-        
-        
-        
-        
-        
-        
-        
-        
-        
-        
-    
-
-    
-
-        
-        
-            
-                
-                        
-                            
-                                
-                                
-                                
-                                
-                            
-                        
-                    
-
-                    
-                    
-                        
-                            
-                                
-                                
-                            
-                            
-                            
-                                
-                                    
-                                
-