Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906b4f9104 | ||
|
|
9c7d563dbf | ||
|
|
743ad86f58 | ||
|
|
ebbdbd4f55 | ||
|
|
ef0d65453e | ||
|
|
9f2ff015d7 | ||
|
|
5a1ad654b8 | ||
|
|
ec7eb2fcb4 | ||
|
|
c5ff3d1a49 | ||
|
|
8008d9bda4 | ||
|
|
ebcb3cdc2a | ||
|
|
2641edecc1 | ||
|
|
4621d734ff | ||
|
|
0b419702a1 | ||
|
|
d6373c243b | ||
|
|
dc7c8ada96 | ||
|
|
0741c961e6 | ||
|
|
9769d397b9 | ||
|
|
18a6d9394c | ||
|
|
9016932547 | ||
|
|
2f7d8f3ca7 | ||
|
|
41efac68ed | ||
|
|
9aad7b083a | ||
|
|
5ee1b07263 | ||
|
|
53ce298bc1 | ||
|
|
45ed7dbfe5 | ||
|
|
f553f10399 | ||
|
|
a9112445bb | ||
|
|
a584b9746f | ||
|
|
c2fdfafaa7 | ||
|
|
b60d0c3bce | ||
|
|
8a0065274b | ||
|
|
ab2f6e562e | ||
|
|
3026e7081c | ||
|
|
84df0b7a7d | ||
|
|
9f8b2c8567 | ||
|
|
2c97e98dc3 | ||
|
|
0603faca59 | ||
|
|
956b5ff499 |
@@ -64,6 +64,9 @@ jobs:
|
|||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Set Release Environment
|
- name: Set Release Environment
|
||||||
id: set_env
|
id: set_env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -9,33 +9,45 @@ jobs:
|
|||||||
build-macos:
|
build-macos:
|
||||||
name: Build macOS (${{ matrix.platform.name }})
|
name: Build macOS (${{ matrix.platform.name }})
|
||||||
runs-on: ${{ matrix.platform.os }}
|
runs-on: ${{ matrix.platform.os }}
|
||||||
|
env:
|
||||||
|
XDG_CONFIG_HOME: ${{ runner.temp }}/.config
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
platform:
|
platform:
|
||||||
- name: Intel
|
- name: Intel
|
||||||
os: macos-15-intel
|
os: macos
|
||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
binary_dir: macos-x86_64
|
binary_dir: macos-x86_64
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
- name: Apple Silicon
|
# - name: Apple Silicon
|
||||||
os: macos-15
|
# os: macos
|
||||||
target: aarch64-apple-darwin
|
# target: aarch64-apple-darwin
|
||||||
binary_dir: macos-aarch64
|
# binary_dir: macos-aarch64
|
||||||
arch: arm64
|
# arch: arm64
|
||||||
- name: Universal (Intel + Apple Silicon)
|
# - name: Universal (Intel + Apple Silicon)
|
||||||
os: macos-15
|
# os: macos
|
||||||
target: universal-apple-darwin
|
# target: universal-apple-darwin
|
||||||
binary_dir: macos-universal
|
# binary_dir: macos-universal
|
||||||
arch: universal
|
# arch: universal
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure workspace and git permissions
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/.config/git"
|
||||||
|
touch "${{ runner.temp }}/.config/git/ignore"
|
||||||
|
mkdir -p .git/info
|
||||||
|
touch .git/info/exclude
|
||||||
|
chmod -R u+rwX .git || true
|
||||||
|
chmod 644 .git/info/exclude || true
|
||||||
|
git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Rust stable
|
- name: Install Rust stable
|
||||||
@@ -44,12 +56,12 @@ jobs:
|
|||||||
toolchain: stable
|
toolchain: stable
|
||||||
targets: ${{ matrix.platform.target == 'universal-apple-darwin' && 'x86_64-apple-darwin, aarch64-apple-darwin' || matrix.platform.target }}
|
targets: ${{ matrix.platform.target == 'universal-apple-darwin' && 'x86_64-apple-darwin, aarch64-apple-darwin' || matrix.platform.target }}
|
||||||
|
|
||||||
- name: Rust Cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
|
|
||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Fetch ArgyllCMS binaries
|
- name: Fetch ArgyllCMS binaries
|
||||||
env:
|
env:
|
||||||
ARGYLL_SERVER_URL: ${{ github.server_url }}
|
ARGYLL_SERVER_URL: ${{ github.server_url }}
|
||||||
@@ -62,15 +74,6 @@ jobs:
|
|||||||
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist"
|
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/instlist"
|
||||||
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen"
|
test -x "src-tauri/argyll/${{ matrix.platform.binary_dir }}/targen"
|
||||||
|
|
||||||
- name: Run Rust tests
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: "0"
|
|
||||||
CARGO_TARGET_DIR: "${{ runner.temp }}/cargo-target"
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo test --target ${{ matrix.platform.target }}
|
|
||||||
|
|
||||||
- name: Set Release Environment
|
- name: Set Release Environment
|
||||||
id: set_env
|
id: set_env
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -81,7 +84,23 @@ jobs:
|
|||||||
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
|
echo "SHORT_SHA=${SHORT_SHA}" >> $GITHUB_ENV
|
||||||
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Warm up macOS GUI for DMG layout
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Tauri's DMG bundler runs Finder AppleScript to set the background
|
||||||
|
# picture and icon positions. On CI Macs, Finder/Aqua may be idle,
|
||||||
|
# which causes AppleEvent timeouts (-1712). Wake them before build.
|
||||||
|
echo "WindowServer: $(pgrep -l WindowServer || echo 'NOT RUNNING')"
|
||||||
|
echo "console user: $(stat -f '%Su' /dev/console 2>/dev/null || echo unknown)"
|
||||||
|
echo "whoami: $(whoami)"
|
||||||
|
open -g -a Finder || true
|
||||||
|
open -g -a "System Events" || true
|
||||||
|
osascript -e 'tell application "Finder" to get name' || true
|
||||||
|
sleep 10
|
||||||
|
|
||||||
- name: Build Tauri App
|
- name: Build Tauri App
|
||||||
|
env:
|
||||||
|
TAURI_BUNDLER_DMG_IGNORE_CI: "true"
|
||||||
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||||
|
|
||||||
- name: Prepare Release Assets
|
- name: Prepare Release Assets
|
||||||
@@ -90,18 +109,11 @@ jobs:
|
|||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
|
|
||||||
# Stage DMG package
|
# Stage DMG package
|
||||||
DMG_FILE=$(find src-tauri/target -type f -name "*.dmg" | head -n 1)
|
DMG_FILE=$(find src-tauri/target -type f -path "*/release/bundle/dmg/*.dmg" | head -n 1)
|
||||||
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
||||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stage ZIP archive of .app bundle
|
|
||||||
APP_DIR=$(find src-tauri/target -type d -name "*.app" | head -n 1)
|
|
||||||
if [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
|
|
||||||
APP_PARENT=$(dirname "$APP_DIR")
|
|
||||||
APP_NAME=$(basename "$APP_DIR")
|
|
||||||
(cd "$APP_PARENT" && zip -r "${GITHUB_WORKSPACE}/release-assets/${PREFIX}.zip" "$APP_NAME")
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ jobs:
|
|||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Fetch ArgyllCMS binaries
|
- name: Fetch ArgyllCMS binaries
|
||||||
shell: powershell
|
shell: powershell
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Ensure workspace and git permissions
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/.config/git"
|
||||||
|
touch "${{ runner.temp }}/.config/git/ignore"
|
||||||
|
mkdir -p .git/info
|
||||||
|
touch .git/info/exclude
|
||||||
|
chmod -R u+rwX .git || true
|
||||||
|
chmod 644 .git/info/exclude || true
|
||||||
|
git config --local core.excludesFile "${{ runner.temp }}/.config/git/ignore" || true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -103,13 +113,6 @@ jobs:
|
|||||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stage ZIP archive of .app bundle
|
|
||||||
APP_DIR=$(find src-tauri/target -type d -path "*/release/bundle/macos/*.app" | head -n 1)
|
|
||||||
if [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
|
|
||||||
APP_PARENT=$(dirname "$APP_DIR")
|
|
||||||
APP_NAME=$(basename "$APP_DIR")
|
|
||||||
(cd "$APP_PARENT" && zip -r "${GITHUB_WORKSPACE}/release-assets/${PREFIX}.zip" "$APP_NAME")
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -1,10 +1,69 @@
|
|||||||
# ICCery Agent Notes
|
# ICCery Agent Notes
|
||||||
|
|
||||||
## Build Commands
|
## Stage 5 Verification / Profcheck
|
||||||
|
|
||||||
- **Rust backend**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required)
|
- `profcheck` output is parsed from both JSON summaries (preferred) and legacy plain-text report formats.
|
||||||
- **Rust tests**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`
|
- If no delta-E values can be detected, the report cards show 0.00 and a warning is appended to the process log.
|
||||||
- **Frontend**: `cd src-tauri && npm run build` (or `npm run dev` for development)
|
- The `.gam` file for the 3D viewer is parsed using `parseGamutFile`, which supports multiple `BEGIN_DATA` blocks (some Argyll files use a separate block per surface section), inline `#` comments, and out-of-bounds vertex warnings.
|
||||||
|
- Manual parser tests live in `src/js/gamut_viewer.test.js`.
|
||||||
|
|
||||||
|
## 3D Gamut Viewer
|
||||||
|
|
||||||
|
- The viewer renders the measured/derived `.gam` volume and an optional sRGB reference wireframe in CIELAB.
|
||||||
|
- Layer controls (profile, sRGB, axes) each have visibility toggles and opacity sliders.
|
||||||
|
- Click **Reset View** or press **R** to return the camera to its default position.
|
||||||
|
- Full JSDoc is provided on the public API in `src/js/gamut_viewer.js`.
|
||||||
|
|
||||||
|
## Stage 4 colprof Options
|
||||||
|
|
||||||
|
- `colprof` options are exposed in the Stage 4 UI with contextual tooltips:
|
||||||
|
- **FWA / OBA Compensation** (`-f`): choose D50, None, D65, or a custom `.sp` spectrum file.
|
||||||
|
- **Standard Illuminant** (`-i`) and **Observer** (`-o`): override default D50 / 1931 2° for CIE colourimetric calculations.
|
||||||
|
- **Viewing Conditions** (`-c` input, `-d` output): set CIE viewing-condition transforms for the profile table and output intent.
|
||||||
|
- The backend `build_colprof_args` in `commands.rs` maps these UI values to `colprof` CLI flags. Unit tests cover all combinations.
|
||||||
|
|
||||||
|
## Stage 3 Swatch Grid
|
||||||
|
|
||||||
|
- Swatch patches render a 135° diagonal split: top-left is the intended/expected colour, bottom-right is the measured colour.
|
||||||
|
- Argyll `chartread` emits `is_pad` for boundary/spacer patches. White reference patches (e.g. `-e` white steps) may also carry `is_pad`, but they have valid `expected.Lab` or non-zero `device` data. The guard skips only pads with no measurement *and* all-zero device values.
|
||||||
|
- Row/patch order from `chartread` (rows A→Z, patches 1→N within each row) is rendered left-to-right / top-to-bottom to match the `printtarg` output.
|
||||||
|
- Tooltip shows intended Lab (or device %), measured Lab, and ΔE₀₀ with a Good/Warning/Bad classification.
|
||||||
|
|
||||||
|
## UI Button Conventions
|
||||||
|
|
||||||
|
The frontend uses a tiered button sizing system defined in `src/styles/main.css`. Prefer these utility classes over inline `style` attributes.
|
||||||
|
|
||||||
|
| Class | Size | Use for |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `.btn-sm` | 28px | Toolbar actions, header icons, minor toggles |
|
||||||
|
| `.btn-md` (default for `button.secondary`/`button.danger`) | 36px | Standard dialog/form actions and browse buttons |
|
||||||
|
| `.btn-lg` | 40px | Primary stage actions (Generate, Create Layout, Create Profile, Verify, etc.) |
|
||||||
|
| `.btn-icon-sq` | 36×36px | Square icon-only buttons (refresh, settings, etc.) |
|
||||||
|
| `.icon-btn` | 28×28px | Small header icon-only buttons (settings, about, save preset, manage presets) |
|
||||||
|
| `.btn-properties` | 36px | Printer driver preferences button |
|
||||||
|
|
||||||
|
- Primary action buttons use `button.primary` plus `.btn-lg` for major stage actions.
|
||||||
|
- Danger actions use `button.danger` (36px).
|
||||||
|
- All action rows use one of: `.stage-actions`, `.modal-actions`, `.chartread-actions`, `.print-actions-row`, `.btn-row`, `.btn-row-sm`, `.btn-row-end`, or `.input-row-sm`.
|
||||||
|
- Avoid inline `style` on `<button>` elements or their immediate parent rows.
|
||||||
|
|
||||||
|
## Settings & Preferences
|
||||||
|
|
||||||
|
- Settings are persisted to `settings.json` in the app data directory and include the Stage 3 ΔE₀₀ traffic-light thresholds.
|
||||||
|
- Valid threshold values must be non-negative and `delta_e_good_max < delta_e_warning_max`; both the frontend and backend enforce this.
|
||||||
|
- Saving settings dispatches a `settings-saved` custom event so live components (e.g. the swatch grid) can re-classify on the fly.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
- **Rust backend check**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo check` (the project lives on a network filesystem that doesn't support file locking, so `CARGO_INCREMENTAL=0` is required)
|
||||||
|
- **Rust unit tests**: `cd src-tauri && CARGO_INCREMENTAL=0 cargo test`
|
||||||
|
- **Frontend test suites**:
|
||||||
|
- Verification & drift tests: `node src/js/profcheck.test.js` (21 tests)
|
||||||
|
- Chartread classifier & XY table tests: `node src/js/chartread.test.js` (39 tests)
|
||||||
|
- Gamut viewer tests: `node src/js/gamut_viewer.test.js`
|
||||||
|
- Browser devtools console: `import('./profcheck.test.js').then(m => m.runAll())`
|
||||||
|
- **Frontend development server**: `npm run tauri dev`
|
||||||
|
- **Production package build**: `npm run tauri build`
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
@@ -49,3 +108,64 @@ The "Preferences" button opens the native macOS `NSPrintPanel` (not CUPS web UI
|
|||||||
- Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment)
|
- Epson color bypass: `EPIJ_CMat=3` (Off / No Color Adjustment)
|
||||||
- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4`
|
- Canon color bypass: `CNIJIntent2=4` or `CNIJIntent=4`
|
||||||
- Gutenprint: `StpColorCorrection=Uncorrected`
|
- Gutenprint: `StpColorCorrection=Uncorrected`
|
||||||
|
|
||||||
|
## Verification History & Printer Drift Tracking (#95)
|
||||||
|
|
||||||
|
- Historical verification runs are stored in `verification_history.json` in the app data directory.
|
||||||
|
- Record schema (`VerificationRecord` in `src-tauri/src/quality_store.rs`):
|
||||||
|
- `id`: unique record identifier in the format `vr-<epoch_millis>-<seq>`.
|
||||||
|
- `profile_name`: target profile filename.
|
||||||
|
- `printer_name`: device name captured at print spooling (`wizardState.printerName`), or "Unknown".
|
||||||
|
- `avg_de`, `max_de`, `rms_de`: CIEDE2000 metrics from `profcheck` (using `-u` JSON summary).
|
||||||
|
- `patch_count`: number of test patches evaluated.
|
||||||
|
- `status`: classified status using **ICCery verification bands (issue #95)**:
|
||||||
|
- `< 1.0`: "Excellent" (`badge-excellent`)
|
||||||
|
- `< 2.0`: "Good" (`badge-good`)
|
||||||
|
- `< 3.5`: "Acceptable" (`badge-acceptable`)
|
||||||
|
- `>= 3.5`: "Warning" (`badge-poor`)
|
||||||
|
- `timestamp`: ISO-8601 UTC string.
|
||||||
|
- Max capacity is 1,000 records; oldest records evicted on overflow.
|
||||||
|
- Atomic file writes (`.tmp` write followed by `rename`) prevent data corruption.
|
||||||
|
- Tauri IPC command casing:
|
||||||
|
- Nested struct fields (`VerificationRecord`) serialize with `snake_case`.
|
||||||
|
- Top-level Tauri command arguments use `camelCase` (e.g. `savePath`, `record`, `profileName`).
|
||||||
|
- Drift history UI in Stage 5 features an interactive SVG trend chart with ICCery verification reference bands, consecutive-breach alert card (requires $\ge 2$ consecutive runs $\ge 3.5$ on distinct calendar days or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
|
||||||
|
|
||||||
|
## Stage 3 XY Automated Scanning Tables (#93)
|
||||||
|
|
||||||
|
- Supports automated XY scanning tables (GretagMacbeth SpectroScan, X-Rite i1iO) in Stage 3 `chartread`.
|
||||||
|
- Hardware detection in `instlist` flags devices matching `/spectro\s?scan|i1io/i` with `data-xy="1"` and `· XY Table` label suffix.
|
||||||
|
- Runtime auto-detection activates when any XY-specific prompt is classified from `chartread` stdout (supporting i1iO units reporting as i1Pro).
|
||||||
|
- XY State Machine additions:
|
||||||
|
- `STATE.TABLE_PLACE_SHEET`: Prompts user to place sheet on table; button displays "✓ Sheet Placed — Continue".
|
||||||
|
- `STATE.TABLE_ALIGN`: Prompts user to align measurement head with target fiducial patches (`locate patch <ID> with sight`); button displays "✓ Aligned — Continue".
|
||||||
|
- Two-line prompt handling & sticky state:
|
||||||
|
- Argyll `chartread.c` splits XY prompts across two lines (prompt line followed by `hit return to continue...`).
|
||||||
|
- While in `TABLE_PLACE_SHEET` or `TABLE_ALIGN`, subsequent continuation lines remain sticky in that table state, preserving the custom button label and preventing regression to generic `PROMPT_CONTINUE`.
|
||||||
|
- Button behaviors:
|
||||||
|
- `btnAccept`: in `TABLE_*` states, sends `\n` without forcing `STATE.READING`; the state machine advances naturally when Argyll emits the next prompt.
|
||||||
|
- `btnCancel`: in `TABLE_*` states or when an XY table is active, sends `q\n` first to allow the hardware to park its measurement head gracefully before terminating the process.
|
||||||
|
- Multi-sheet and final sheet notice:
|
||||||
|
- Multi-sheet targets are measured within a single `chartread` process lifecycle; sheet changes transition through `TABLE_PLACE_SHEET` without opening the Stage 3 multi-pass averaging panel.
|
||||||
|
- `Please remove last sheet from table` is emitted by Argyll right before writing `.ti3` and exiting; it is classified as an info-only notice (`isRemoveSheetNotice: true`) and does not prompt for user input.
|
||||||
|
- Testing:
|
||||||
|
- Pure line classification unit tests live in `src/js/chartread.test.js` (executable directly in Node or browser console).
|
||||||
|
- Unix/macOS mock script `src-tauri/argyll/mocks/chartread.mock` supports `--xy` flag (or `MOCK_XY_TABLE=1`) with blocking `read` calls simulating calibration, sheet placement, fiducial alignment, and scanning.
|
||||||
|
|
||||||
|
## Stage 3 i1Pro 2 LED Status Feedback (#204)
|
||||||
|
|
||||||
|
- Supports the `-Y l` switch introduced in the ICCery ArgyllCMS fork to drive the dual RGB ring LEDs of the X-Rite i1Pro 2 (Rev E) for real-time visual status feedback during strip measurement:
|
||||||
|
- **Flashing White**: Awaiting baseline calibration on white tile.
|
||||||
|
- **Flashing Blue**: Ready for row swipe / awaiting strip read.
|
||||||
|
- **Flashing Red**: Strip scan error / misread.
|
||||||
|
- **Flashing Green**: Strip scan successfully captured.
|
||||||
|
- Controlled via `enable_i1pro2_leds: bool` in `AppSettings` (persisted in `settings.json`), exposed under Settings → Instrument & Measurement Preferences.
|
||||||
|
- Defaults to `false` ensuring 100% out-of-the-box compatibility with stock upstream ArgyllCMS binaries.
|
||||||
|
- Subprocess error diagnostics in `chartread.js` capture `lastStderrLine` from `process:stderr`, auto-expanding the Process Output `<details>` panel with the stderr explanation if an unpatched binary rejects `-Y l`.
|
||||||
|
|
||||||
|
## CI & Cross-Compilation
|
||||||
|
|
||||||
|
- Release packaging workflows live under `.gitea/workflows/` (`build-macos.yml`, `build-linux.yml`, `build-windows.yml`).
|
||||||
|
- Tag release builds focus exclusively on packaging via `npm run tauri build` without redundant debug-profile test compilations.
|
||||||
|
- Local/CI cross-compilation test execution for Apple Silicon (`aarch64-apple-darwin`) on Intel hosts must use `cargo test --no-run --target aarch64-apple-darwin` to avoid executing ARM64 binaries on an x86_64 CPU (`Bad CPU type in executable (os error 86)`).
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
||||||
|
|
||||||
[](https://git.i3omb.com/gronod/ICCery)
|
[](https://git.i3omb.com/gronod/ICCery)
|
||||||
[](https://git.i3omb.com/gronod/ICCery)
|
[](https://git.i3omb.com/gronod/ICCery)
|
||||||
[](https://tauri.app)
|
[](https://tauri.app)
|
||||||
[](LICENCE.md)
|
[](LICENCE.md)
|
||||||
|
|
||||||
@@ -14,15 +14,29 @@
|
|||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
- 🪄 **Linear 5-Stage Wizard Workflow**:
|
- 🪄 **Linear 5-Stage Wizard Workflow**:
|
||||||
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, and neutral/grey axis boosting.
|
1. **Stage 1 — Patch Generation (`targen`)**: Configure RGB (driver-managed) or CMYK (RIP-managed) patch sets with custom counts, profiling presets, neutral/grey axis boosting, and 11 advanced generation parameters with contextual guidance tooltips. Supports direct-resume from existing `.ti2` target files to jump straight to measurement.
|
||||||
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint). View high-resolution downscaled TIFF previews and print directly using native OS raw unmanaged pathways (Windows GDI uncorrected / Linux CUPS `raw`).
|
2. **Stage 2 — Target Creation & Raw Printing (`printtarg`)**: Format patch targets for handheld spectrophotometers (i1Pro, i1Pro2, ColorMunki, SpyderPrint) and automated XY tables (i1iO, SpectroScan). View high-resolution downscaled TIFF previews and print directly using native OS unmanaged pathways:
|
||||||
3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**: Instrument auto-detection (`instlist`), real-time calibration prompts, interactive strip reading state machine, live swatch grid with CIEDE2000 ($\Delta E_{00}$) quality indicators, and multi-pass sheet averaging for measurement noise reduction.
|
- **macOS**: Native `NSPrintPanel` driver preferences with automatic ColorSync suppression (`AP_ColorMatchingMode=AP_ApplicationColorMatching`), CUPS media type selection, and driver-specific color adjustment bypass detection (Canon `CNIJIntent2`, Epson `ColorCorrection`, Gutenprint).
|
||||||
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, descriptions, and copyright tagging.
|
- **Windows**: GDI uncorrected raw printing and DEVMODE preferences.
|
||||||
5. **Stage 5 — Verification & 3D Gamut (`profcheck` + `iccgamut`)**: Comprehensive mathematical validation report (Peak, Average, RMS $\Delta E$) paired with an interactive 3D CIELAB convex hull color volume viewer, touch controls, and bundled sRGB reference wireframe comparison.
|
- **Linux**: CUPS `raw` queue and PPD media option spooling.
|
||||||
|
3. **Stage 3 — Interactive Measurement (`chartread`) & Averaging (`average`)**:
|
||||||
|
- **Automated XY Scanning Tables (#93)**: Full automated sequence for GretagMacbeth SpectroScan and X-Rite i1iO tables with multi-line prompt classification, fiducial sight alignment prompts, 4-step sequence checklist UI, and graceful head parking (`q\n`).
|
||||||
|
- **Instrument Status Feedback (#204)**: Optional `-Y l` switch support driving the dual RGB ring LEDs of the X-Rite i1Pro 2 (flashing white for calibration, flashing blue for ready/swipe, flashing red for error, flashing green for capture).
|
||||||
|
- **Interactive Controls**: Dedicated `Done & Save .ti3` (`d\n`), `Undo Strip` (`u\n`), and `Skip Strip` (`s\n`) actions.
|
||||||
|
- **Live Swatch Grid**: 135° diagonally split intended-vs-measured colour patches with live CIEDE2000 ($\Delta E_{00}$) quality indicators, white reference patch preservation, and user-configurable good/warning traffic-light thresholds persisted across sessions.
|
||||||
|
- **Noise Reduction**: Multi-pass sheet averaging (`average`) to eliminate spectrophotometer noise.
|
||||||
|
4. **Stage 4 — Profile Calculation (`colprof`)**: Generate high-precision cLUT mathematical ICC/ICM profiles with configurable algorithm quality, OBA/FWA compensation (`-f`), illuminant (`-i`) and observer (`-o`) overrides, viewing-condition transforms (`-c`, `-d`), custom ambient spectrum support, descriptions, and copyright tagging.
|
||||||
|
5. **Stage 5 — Verification, Drift Analytics & 3D Gamut (`profcheck` + `iccgamut`)**:
|
||||||
|
- **Longitudinal Printer Drift Analytics (#95)**: Historical verification logging persisted to `verification_history.json` (up to 1,000 records), an interactive dual-series SVG trend chart with shaded ICCery verification reference bands, consecutive-breach alert recommendation card (detecting drift across distinct dates or $\ge 1$ hour apart), and RFC-4180 compliant CSV export.
|
||||||
|
- **Mathematical Accuracy Report**: Peak, Average, and RMS CIEDE2000 metrics with robust parsing of both Argyll JSON summaries (`-u`) and legacy plain-text reports.
|
||||||
|
- **Interactive 3D Gamut Viewer**: CIELAB coordinate scaffold with crisp CSS2D labels, per-vertex true-colour profile gamut shading, layer visibility toggles and opacity sliders, camera reset (press **R**), touch controls, and bundled sRGB reference wireframe comparison.
|
||||||
|
- 📊 **CGATS Dataset Interoperability (#94)**: Native parser for external CGATS and Argyll `.ti3` datasets with canonical normalization (0–255 scaling, field aliasing, metadata synthesis) and direct-jump workflows to Stage 4 (Profile Calculation) and Stage 5 (Verification).
|
||||||
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
|
- 📋 **Profiling Presets**: One-click configuration presets (Standard RGB Photo, High-Gamut CMYK Proofing, Fast RGB Draft) with custom preset export/import and security validation.
|
||||||
- 🐧 **glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
|
- 🍎 **macOS Universal Binary**: Native Apple Silicon (`arm64`) and Intel (`x86_64`) support with universal binary bundling and fallback resolution.
|
||||||
- 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1`, `.ti2`, `.ti3`, `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation.
|
- 🐧 **Linux glibc Compatibility**: Pre-built Linux packages compiled with Ubuntu 22.04 LTS compatibility for Debian/Ubuntu environments.
|
||||||
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on Linux/macOS) and native OS printer subsystems.
|
- 🛡️ **Disk Artefact Gating**: Stepper navigation strictly verifies generated artefacts on disk (`.ti1` → `.ti2` → `.ti3` → `.icc`/`.icm`), preventing out-of-order execution while preserving backward navigation.
|
||||||
|
- 🌐 **Platform-Aware**: Automatic handling of platform profile conventions (`.icm` on Windows, `.icc` on macOS/Linux) and native OS printer subsystems.
|
||||||
|
- 🎛️ **Standardised UI Design**: Tiered button sizing (`.btn-sm`, `.btn-md`, `.btn-lg`, `.btn-icon-sq`) and consistent action row layouts provide a uniform, responsive interface across all stages.
|
||||||
- ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines.
|
- ⚖️ **Clean AGPL Boundary**: Complete isolation of AGPLv3 binaries via asynchronous tokio IPC process pipelines.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -37,13 +51,16 @@ flowchart TD
|
|||||||
UI[Wizard UI & Swatch Grid]
|
UI[Wizard UI & Swatch Grid]
|
||||||
ThreeJS[3D CIELAB Gamut Viewer]
|
ThreeJS[3D CIELAB Gamut Viewer]
|
||||||
State[Wizard State & Artefact Verifier]
|
State[Wizard State & Artefact Verifier]
|
||||||
PrintEngine["Raw Print Subsystem (GDI / CUPS)"]
|
QualityStore[Verification History & Drift Analytics]
|
||||||
|
PrintEngine["Raw Print Subsystem (GDI / CUPS / NSPrintPanel)"]
|
||||||
ProcMgr[Async Subprocess IPC Manager]
|
ProcMgr[Async Subprocess IPC Manager]
|
||||||
|
|
||||||
UI <--> State
|
UI <--> State
|
||||||
State <--> ProcMgr
|
State <--> ProcMgr
|
||||||
|
State <--> QualityStore
|
||||||
ProcMgr --> ThreeJS
|
ProcMgr --> ThreeJS
|
||||||
UI --> PrintEngine
|
UI --> PrintEngine
|
||||||
|
QualityStore --> UI
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Argyll ["ArgyllCMS Subprocesses (AGPLv3)"]
|
subgraph Argyll ["ArgyllCMS Subprocesses (AGPLv3)"]
|
||||||
@@ -71,6 +88,7 @@ flowchart TD
|
|||||||
- [Node.js](https://nodejs.org/) (v18 or newer)
|
- [Node.js](https://nodejs.org/) (v18 or newer)
|
||||||
- [Rust](https://www.rust-lang.org/) (1.78+ stable)
|
- [Rust](https://www.rust-lang.org/) (1.78+ stable)
|
||||||
- Operating system dependencies:
|
- Operating system dependencies:
|
||||||
|
- **macOS**: macOS 11.0 (Big Sur) or newer, Xcode Command Line Tools (`xcode-select --install`).
|
||||||
- **Windows**: Microsoft Visual Studio C++ Build Tools & WebView2 runtime.
|
- **Windows**: Microsoft Visual Studio C++ Build Tools & WebView2 runtime.
|
||||||
- **Linux (Debian/Ubuntu)**: `libwebkit2gtk-4.1-dev`, `build-essential`, `curl`, `wget`, `file`, `libxdo-dev`, `libssl-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libcups2-dev`.
|
- **Linux (Debian/Ubuntu)**: `libwebkit2gtk-4.1-dev`, `build-essential`, `curl`, `wget`, `file`, `libxdo-dev`, `libssl-dev`, `libayatana-appindicator3-dev`, `librsvg2-dev`, `libcups2-dev`.
|
||||||
|
|
||||||
@@ -90,7 +108,7 @@ npm run fetch-argyll
|
|||||||
npm run tauri dev
|
npm run tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:**
|
> **Notes:**
|
||||||
> - Sidecars are not stored in git; `tauri build` / `tauri dev` will fail until `npm run fetch-argyll` has been run at least once.
|
> - Sidecars are not stored in git; `tauri build` / `tauri dev` will fail until `npm run fetch-argyll` has been run at least once.
|
||||||
> - You can override the downloaded ArgyllCMS release version using `ARGYLL_RELEASE_TAG=vX.Y.Z npm run fetch-argyll`.
|
> - You can override the downloaded ArgyllCMS release version using `ARGYLL_RELEASE_TAG=vX.Y.Z npm run fetch-argyll`.
|
||||||
> - On Windows, the NSIS installer package bundles the ArgyllCMS USB instrument driver suite and offers an optional driver setup step when run with administrative privileges.
|
> - On Windows, the NSIS installer package bundles the ArgyllCMS USB instrument driver suite and offers an optional driver setup step when run with administrative privileges.
|
||||||
@@ -100,7 +118,10 @@ npm run tauri dev
|
|||||||
# Download sidecars (if not already fetched)
|
# Download sidecars (if not already fetched)
|
||||||
npm run fetch-argyll
|
npm run fetch-argyll
|
||||||
|
|
||||||
# Build desktop packages (MSI/NSIS on Windows, DEB/AppImage on Linux)
|
# Build desktop packages
|
||||||
|
# - macOS: .dmg / .app bundle (Intel, Apple Silicon, or Universal with --target universal-apple-darwin)
|
||||||
|
# - Windows: .exe (NSIS) / .msi installer
|
||||||
|
# - Linux: .AppImage / .deb package
|
||||||
npm run tauri build
|
npm run tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+34
-7
@@ -10,10 +10,11 @@ This document outlines the architectural roadmap, completed milestones, and upco
|
|||||||
## 1. Architecture Summary
|
## 1. Architecture Summary
|
||||||
|
|
||||||
ICCery is a native, cross-platform desktop application built with:
|
ICCery is a native, cross-platform desktop application built with:
|
||||||
- **Backend**: Rust + Tauri v2, managing asynchronous process pipes, native printer devmode configurations (Windows GDI & Linux CUPS), and filesystem operations.
|
- **Backend**: Rust + Tauri v2, managing asynchronous process pipes, native printer configurations (macOS Core Printing / `NSPrintPanel`, Windows GDI & DEVMODE, Linux CUPS), and filesystem operations.
|
||||||
- **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout.
|
- **Frontend**: Vanilla JS (ES Modules) + HTML5/CSS3 with a modern dark theme and responsive layout.
|
||||||
- **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons.
|
- **Visualization**: Three.js WebGL engine for 3D CIELAB color gamut volumes and sRGB reference comparisons.
|
||||||
- **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`).
|
- **Engine**: ArgyllCMS command-line utilities orchestrated over isolated standard stream IPC (`stdin`, `stdout`, `stderr`).
|
||||||
|
- **Current Version**: `v0.8.4` (Production release).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -90,15 +91,41 @@ ICCery is a native, cross-platform desktop application built with:
|
|||||||
- [x] **Windows Authenticode Code Signing in Gitea CI** (`v0.6.4` – `v0.6.6`): Integrated Tauri bundle signing hooks via `sign.cmd` batch wrapper with PATH resolution, Gitea Actions secret-based PFX materialization, and ephemeral signing pipeline.
|
- [x] **Windows Authenticode Code Signing in Gitea CI** (`v0.6.4` – `v0.6.6`): Integrated Tauri bundle signing hooks via `sign.cmd` batch wrapper with PATH resolution, Gitea Actions secret-based PFX materialization, and ephemeral signing pipeline.
|
||||||
- [x] **Stage 3 Chartread Completion & Snapshot IPC Fix (#175)** (`v0.6.7` – `v0.6.8`): Added dedicated `Done & Save .ti3` action (`d\n`), `Undo Strip` action (`u\n`), automated completion state detection, and corrected Tauri IPC deserialization parameter (`passIndex`) in `snapshot_ti3`.
|
- [x] **Stage 3 Chartread Completion & Snapshot IPC Fix (#175)** (`v0.6.7` – `v0.6.8`): Added dedicated `Done & Save .ti3` action (`d\n`), `Undo Strip` action (`u\n`), automated completion state detection, and corrected Tauri IPC deserialization parameter (`passIndex`) in `snapshot_ti3`.
|
||||||
|
|
||||||
|
### Milestone 13 — UI/UX & Workflow Polish (`v0.7.0` – `v0.7.4`, `v0.8.0`)
|
||||||
|
- [x] **Global Button Standardization (#177)**: Enforce `.btn-sm`/`.btn-md`/`.btn-lg`/`.btn-icon-sq` tiers across all stages, remove inline button styles, and add CSS custom properties for button metrics.
|
||||||
|
- [x] **Swatch Grid White Patch & Orientation Polish (#178)**: Finalise `is_pad` guard documentation, diagonally split swatch tooltips, and validate `printtarg` row/column ordering.
|
||||||
|
- [x] **Configurable CIEDE2000 Thresholds (#184)**: User-configurable good/warning ΔE₀₀ upper bounds in Settings, persisted across sessions and applied to the Stage 3 swatch grid.
|
||||||
|
- [x] **Stage 4 OBA/FWA Tooltips & Tests (#176)**: Contextual help for OBA/FWA, illuminant, observer, and viewing-condition controls; additional `colprof` arg builder tests.
|
||||||
|
- [x] **3D Gamut Viewer Controls (#185)**: Camera reset, opacity sliders, keyboard shortcut, and full public-API JSDoc.
|
||||||
|
- [x] **Gamut / Profcheck Hardening (#179)**: Validate `.gam` vertex/face parsing, improved `profcheck` regex fallbacks for legacy text output, and user-visible parser warnings.
|
||||||
|
|
||||||
|
### Hardware Status Feedback Release (`v0.8.1`)
|
||||||
|
- [x] **i1Pro 2 LED Status Feedback (#204)**: Added optional `-Y l` switch support to `chartread` driving the dual RGB ring LEDs of the X-Rite i1Pro 2 for visual status feedback (calibration, swipe ready, misread, capture success).
|
||||||
|
- [x] **macOS CI Cross-Compilation Testing**: Hardened `.gitea/workflows/build-macos.yml` by compiling Apple Silicon tests with `--no-run` on Intel runner hosts to avoid architecture execution mismatch.
|
||||||
|
|
||||||
|
### Milestone 12 — Future Workflow & Advanced Analytics (`v0.8.2`)
|
||||||
|
- [x] **Printer Drift Tracking & Verification Analytics (#95)**: Track longitudinal printer drift in Stage 5 over time with historical run logging in `verification_history.json` (1,000 records), interactive dual-series SVG trend chart with ICCery verification reference bands, consecutive-breach alert recommendation card, and RFC-4180 CSV export.
|
||||||
|
- [x] **XY Automated Scanning Tables (#93)**: Full Stage 3 support for automated XY scanning tables (GretagMacbeth SpectroScan, X-Rite i1iO) with pure multi-line prompt classification, fiducial alignment prompts, 4-step sequence checklist, and graceful head parking on cancel.
|
||||||
|
- [ ] ~~**Multi-Language Localization (#96)**~~: *Closed — Won't Fix* (English UI retained as standard color-management terminology).
|
||||||
|
|
||||||
|
### Maintenance & Reliability Release (`v0.8.3`)
|
||||||
|
- [x] **Gamut Viewer Node Test Runner Support (#212)**: Guarded `window` and `window.__TAURI__` globals in `gamut_viewer.js` and added polyfill mock harness to `gamut_viewer.test.js` to enable automated headless test execution via `node src/js/gamut_viewer.test.js`.
|
||||||
|
- [x] **Custom Spectrum File Picker Dialog (#210)**: Implemented native `select_spectrum_file` command wrapping Tauri file dialog with `.sp` filter for custom FWA/OBA spectrum selection in Stage 4 profile generation.
|
||||||
|
- [x] **CGATS Dataset Import File Picker & State Synchronization (#211)**: Implemented native `select_dataset_file` command with `.ti3`, `.txt`, `.cgats`, and `.csv` filter, synchronized wizard target directory and basename upon import, and guarded against empty target states.
|
||||||
|
|
||||||
|
### Maintenance & Reliability Release (`v0.8.4`)
|
||||||
|
- [x] **Atomic Verification History Persistence (#213)**: Hardened `quality_store.rs` with atomic temporary file writes (`.tmp`), explicit flush/sync, and atomic rename to prevent historical drift data loss or corruption upon unexpected system crashes.
|
||||||
|
- [x] **Frontend Unit Testing & CI Integration (#215)**: Added standard `npm test` script executing the 3 frontend test suites (`profcheck`, `chartread`, and `gamut_viewer`) and integrated automated frontend test validation into macOS, Linux, and Windows CI workflows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Future Roadmap
|
## 3. Future Roadmap
|
||||||
|
|
||||||
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0`)
|
### Milestone 9 — macOS Native Support & Enhanced Print Spooling (`v0.4.0` / Post-v0.8)
|
||||||
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with notarization.
|
- [ ] **macOS Platform Bundle**: Build and sign universal macOS `.dmg` bundles with Apple Developer ID notarization and stapling.
|
||||||
- [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass.
|
- [ ] **macOS Raw Spooling**: Native CoreGraphics/CUPS raw print dialog bypass.
|
||||||
- [ ] **SpectroScan & Automated Table Support (#93)**: Support XY automated scanning tables (i1iO / SpectroScan) in `chartread` (deferred).
|
|
||||||
|
|
||||||
### Milestone 12 — Future Workflow & Advanced Analytics (Deferred)
|
### Future Architecture Strategy (Post-v0.8.2)
|
||||||
- [ ] **Batch Verification & Drift Tracking (#95)**: Track printer drift over time by comparing periodic verification measurements against a baseline profile.
|
- **Multi-Device Drift Overlays**: Overlay drift curves from multiple printers/media types on a shared timeline in Stage 5.
|
||||||
- [ ] **Multi-Language Localization (#96)**: Full UI internationalization (English, German, French, Japanese).
|
- **Direct Remote Target Dispatch**: Send `.ti2` target jobs to network print spoolers or remote print labs with automated token callbacks.
|
||||||
|
- **Embedded ICC Profile Inspector**: Direct inspection of cLUT tags, chromatic adaptation matrices, tone reproduction curves (TRC), and profile metadata from saved `.icc`/`.icm` files.
|
||||||
|
|||||||
+18
-12
@@ -23,20 +23,26 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
|
|
||||||
## Delaunator
|
## quickhull3d
|
||||||
Copyright (c) 2017, Mapbox
|
MIT License
|
||||||
|
|
||||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
with or without fee is hereby granted, provided that the above copyright notice
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
and this permission notice appear in all copies.
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
The above copyright notice and this permission notice shall be included in
|
||||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
all copies or substantial portions of the Software.
|
||||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
||||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
## Tauri Framework & Rust Dependencies
|
## Tauri Framework & Rust Dependencies
|
||||||
Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.
|
Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.
|
||||||
|
|||||||
+3
-2
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "iccery",
|
"name": "iccery",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.4",
|
"version": "0.8.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "node src/js/profcheck.test.js && node src/js/chartread.test.js && node src/js/gamut_viewer.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2"
|
||||||
|
|||||||
Generated
+1
-1
@@ -1423,7 +1423,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.7.4"
|
version = "0.8.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
+12
-1
@@ -1,9 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iccery"
|
name = "iccery"
|
||||||
version = "0.7.4"
|
version = "0.8.4"
|
||||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||||
authors = ["Gordon"]
|
authors = ["Gordon"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
include = [
|
||||||
|
"src/**/*",
|
||||||
|
"build.rs",
|
||||||
|
"Cargo.toml",
|
||||||
|
"Cargo.lock",
|
||||||
|
"tauri.conf.json",
|
||||||
|
"capabilities/**/*",
|
||||||
|
"gen/**/*",
|
||||||
|
"icons/**/*",
|
||||||
|
"windows/**/*",
|
||||||
|
]
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,51 @@
|
|||||||
# Mock script for chartread -u
|
# Mock script for chartread -u
|
||||||
# This script simulates the behaviour of chartread for testing purposes.
|
# This script simulates the behaviour of chartread for testing purposes.
|
||||||
|
|
||||||
|
# Check for --xy argument or MOCK_XY_TABLE environment variable
|
||||||
|
IS_XY=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = "--xy" ]; then
|
||||||
|
IS_XY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$IS_XY" = "1" ] || [ "${MOCK_XY_TABLE}" = "1" ]; then
|
||||||
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
read -r _calib
|
||||||
|
echo "Calibration successful."
|
||||||
|
|
||||||
|
echo "Please place sheet 1 of 1 on the table"
|
||||||
|
echo "hit return to continue, Esc or 'q' to give up"
|
||||||
|
read -r _sheet1
|
||||||
|
|
||||||
|
echo "locate patch A1 with the sight,"
|
||||||
|
echo "then hit return to continue"
|
||||||
|
read -r _fid1
|
||||||
|
|
||||||
|
echo "locate patch B24 with the sight,"
|
||||||
|
echo "then hit return to continue"
|
||||||
|
read -r _fid2
|
||||||
|
|
||||||
|
echo "Reading sheet 1..."
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
# Emit mock JSON for strip A
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0]}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Emit mock JSON for strip B
|
||||||
|
cat << 'EOF'
|
||||||
|
ROW_COLORS_JSON: {"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Sheet 1 of 1 read OK"
|
||||||
|
echo "Please remove last sheet from table"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handheld / strip reader simulation
|
||||||
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
echo "Place instrument on calibration tile and hit [Space] to calibrate."
|
||||||
|
|
||||||
# We don't really wait for input, just wait 1 second
|
# We don't really wait for input, just wait 1 second
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Mock script for profcheck
|
# Mock script for profcheck
|
||||||
# Simulates profcheck verification output
|
# Simulates real ArgyllCMS profcheck -v -k -s -u output
|
||||||
|
|
||||||
echo "profcheck: Checking profile accuracy..."
|
echo "profcheck: Checking profile accuracy..."
|
||||||
|
echo "No of test patches = 52"
|
||||||
sleep 1
|
sleep 1
|
||||||
cat << 'EOF'
|
cat << 'EOF'
|
||||||
{"event": "profcheck_complete", "avg_de": 0.85, "max_de": 2.41, "rms_de": 1.02}
|
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||||
EOF
|
EOF
|
||||||
echo "Summary:"
|
echo "Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02"
|
||||||
echo " avg. dE = 0.85"
|
|
||||||
echo " max. dE = 2.41"
|
|
||||||
echo " rms. dE = 1.02"
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ fn main() {
|
|||||||
|
|
||||||
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
|
println!("cargo:rustc-env=BUILD_DATE={}", build_date);
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
println!("cargo:rerun-if-changed=src");
|
||||||
|
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||||
|
println!("cargo:rerun-if-changed=Cargo.toml");
|
||||||
|
println!("cargo:rerun-if-changed=argyll");
|
||||||
|
|
||||||
// Validate that ArgyllCMS sidecar binaries are staged before building
|
// Validate that ArgyllCMS sidecar binaries are staged before building
|
||||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||||
|
|||||||
+164
-3
@@ -391,6 +391,61 @@ pub async fn select_profile_file(
|
|||||||
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn select_spectrum_file(
|
||||||
|
app: AppHandle,
|
||||||
|
default_dir: Option<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let mut builder = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter("Argyll Spectrum File (*.sp)", &["sp"]);
|
||||||
|
if let Some(ref dir) = default_dir {
|
||||||
|
if !dir.trim().is_empty() {
|
||||||
|
builder = builder.set_directory(std::path::PathBuf::from(dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
builder.pick_file(move |file_path| {
|
||||||
|
let res = file_path.map(|p| p.to_string());
|
||||||
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn select_dataset_file(
|
||||||
|
app: AppHandle,
|
||||||
|
default_dir: Option<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let mut builder = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.add_filter(
|
||||||
|
"Measurement Dataset (*.ti3, *.txt, *.cgats, *.csv)",
|
||||||
|
&["ti3", "txt", "cgats", "csv"],
|
||||||
|
);
|
||||||
|
if let Some(ref dir) = default_dir {
|
||||||
|
if !dir.trim().is_empty() {
|
||||||
|
builder = builder.set_directory(std::path::PathBuf::from(dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
builder.pick_file(move |file_path| {
|
||||||
|
let res = file_path.map(|p| p.to_string());
|
||||||
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn select_target_file(
|
pub async fn select_target_file(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -425,6 +480,34 @@ pub async fn select_target_file(
|
|||||||
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn select_csv_save_path(
|
||||||
|
app: AppHandle,
|
||||||
|
default_name: Option<String>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
let mut builder = app.dialog().file().add_filter("CSV", &["csv"]);
|
||||||
|
if let Some(ref name) = default_name {
|
||||||
|
if !name.trim().is_empty() {
|
||||||
|
let filename = if name.to_lowercase().ends_with(".csv") {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}.csv", name)
|
||||||
|
};
|
||||||
|
builder = builder.set_file_name(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
builder.save_file(move |file_path| {
|
||||||
|
let res = file_path.map(|p| p.to_string());
|
||||||
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
rx.await.map_err(|e| format!("Dialog channel error: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn select_directory(
|
pub async fn select_directory(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -829,11 +912,13 @@ pub async fn read_tiff_preview_png(path: String) -> Result<String, String> {
|
|||||||
Ok(base64::engine::general_purpose::STANDARD.encode(png_bytes.into_inner()))
|
Ok(base64::engine::general_purpose::STANDARD.encode(png_bytes.into_inner()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct ChartreadConfig {
|
pub struct ChartreadConfig {
|
||||||
pub basename: String,
|
pub basename: String,
|
||||||
pub cwd: String,
|
pub cwd: String,
|
||||||
pub port: Option<String>,
|
pub port: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_i1pro2_leds: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
||||||
@@ -849,6 +934,11 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.enable_i1pro2_leds.unwrap_or(false) {
|
||||||
|
args.push("-Y".to_string());
|
||||||
|
args.push("l".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
args.push(config.basename.clone());
|
args.push(config.basename.clone());
|
||||||
args
|
args
|
||||||
}
|
}
|
||||||
@@ -857,8 +947,12 @@ pub fn build_chartread_args(config: &ChartreadConfig) -> Vec<String> {
|
|||||||
pub async fn run_chartread(
|
pub async fn run_chartread(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, ProcessManager>,
|
state: State<'_, ProcessManager>,
|
||||||
config: ChartreadConfig,
|
mut config: ChartreadConfig,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
if config.enable_i1pro2_leds.is_none() {
|
||||||
|
let settings = crate::settings::load_settings(app.clone()).unwrap_or_default();
|
||||||
|
config.enable_i1pro2_leds = Some(settings.enable_i1pro2_leds);
|
||||||
|
}
|
||||||
let binary = resolve_binary(app.clone(), "chartread".to_string()).await?;
|
let binary = resolve_binary(app.clone(), "chartread".to_string()).await?;
|
||||||
let args = build_chartread_args(&config);
|
let args = build_chartread_args(&config);
|
||||||
let id = format!("chartread_{}", config.basename);
|
let id = format!("chartread_{}", config.basename);
|
||||||
@@ -1073,6 +1167,7 @@ pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec<String> {
|
|||||||
"-v".to_string(),
|
"-v".to_string(),
|
||||||
"-k".to_string(),
|
"-k".to_string(),
|
||||||
"-s".to_string(),
|
"-s".to_string(),
|
||||||
|
"-u".to_string(),
|
||||||
config.ti3_path.clone(),
|
config.ti3_path.clone(),
|
||||||
config.icc_path.clone(),
|
config.icc_path.clone(),
|
||||||
]
|
]
|
||||||
@@ -1558,6 +1653,7 @@ mod tests {
|
|||||||
basename: "my_profile".to_string(),
|
basename: "my_profile".to_string(),
|
||||||
cwd: "/home/user".to_string(),
|
cwd: "/home/user".to_string(),
|
||||||
port: None,
|
port: None,
|
||||||
|
enable_i1pro2_leds: None,
|
||||||
};
|
};
|
||||||
let args = build_chartread_args(&config);
|
let args = build_chartread_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||||
@@ -1569,6 +1665,7 @@ mod tests {
|
|||||||
basename: "my_profile".to_string(),
|
basename: "my_profile".to_string(),
|
||||||
cwd: "/home/user".to_string(),
|
cwd: "/home/user".to_string(),
|
||||||
port: Some("".to_string()),
|
port: Some("".to_string()),
|
||||||
|
enable_i1pro2_leds: None,
|
||||||
};
|
};
|
||||||
let args = build_chartread_args(&config);
|
let args = build_chartread_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||||
@@ -1580,11 +1677,48 @@ mod tests {
|
|||||||
basename: "my_profile".to_string(),
|
basename: "my_profile".to_string(),
|
||||||
cwd: "/home/user".to_string(),
|
cwd: "/home/user".to_string(),
|
||||||
port: Some("1".to_string()),
|
port: Some("1".to_string()),
|
||||||
|
enable_i1pro2_leds: None,
|
||||||
};
|
};
|
||||||
let args = build_chartread_args(&config);
|
let args = build_chartread_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-u", "-c", "1", "my_profile"]);
|
assert_eq!(args, vec!["-v", "-u", "-c", "1", "my_profile"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_chartread_args_leds_enabled() {
|
||||||
|
let config = ChartreadConfig {
|
||||||
|
basename: "my_profile".to_string(),
|
||||||
|
cwd: "/home/user".to_string(),
|
||||||
|
port: None,
|
||||||
|
enable_i1pro2_leds: Some(true),
|
||||||
|
};
|
||||||
|
let args = build_chartread_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "-u", "-Y", "l", "my_profile"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_chartread_args_with_port_and_leds() {
|
||||||
|
let config = ChartreadConfig {
|
||||||
|
basename: "my_profile".to_string(),
|
||||||
|
cwd: "/home/user".to_string(),
|
||||||
|
port: Some("1".to_string()),
|
||||||
|
enable_i1pro2_leds: Some(true),
|
||||||
|
};
|
||||||
|
let args = build_chartread_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "-u", "-c", "1", "-Y", "l", "my_profile"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_chartread_args_leds_disabled() {
|
||||||
|
let config = ChartreadConfig {
|
||||||
|
basename: "my_profile".to_string(),
|
||||||
|
cwd: "/home/user".to_string(),
|
||||||
|
port: None,
|
||||||
|
enable_i1pro2_leds: Some(false),
|
||||||
|
};
|
||||||
|
let args = build_chartread_args(&config);
|
||||||
|
assert_eq!(args, vec!["-v", "-u", "my_profile"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_average_args() {
|
fn test_build_average_args() {
|
||||||
let config = AverageConfig {
|
let config = AverageConfig {
|
||||||
@@ -1783,6 +1917,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_colprof_args_combined_fwa_illuminant_observer_viewing() {
|
||||||
|
let config = ColprofConfig {
|
||||||
|
algorithm: "x".to_string(),
|
||||||
|
quality: "u".to_string(),
|
||||||
|
intent: Some("a".to_string()),
|
||||||
|
copyright: Some("C".to_string()),
|
||||||
|
description: Some("D".to_string()),
|
||||||
|
basename: "profile".to_string(),
|
||||||
|
cwd: "".to_string(),
|
||||||
|
fwa: Some("D65".to_string()),
|
||||||
|
illuminant: Some("D65".to_string()),
|
||||||
|
observer: Some("2015_2".to_string()),
|
||||||
|
input_viewing_cond: Some("pc".to_string()),
|
||||||
|
output_viewing_cond: Some("md".to_string()),
|
||||||
|
};
|
||||||
|
let args = build_colprof_args(&config);
|
||||||
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
|
"-v", "-a", "x", "-q", "u", "-t", "a",
|
||||||
|
"-f", "D65", "-i", "D65", "-o", "2015_2",
|
||||||
|
"-c", "pc", "-d", "md", "-D", "D", "-C", "C", "profile"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_profcheck_args() {
|
fn test_build_profcheck_args() {
|
||||||
let config = ProfcheckConfig {
|
let config = ProfcheckConfig {
|
||||||
@@ -1791,7 +1952,7 @@ mod tests {
|
|||||||
cwd: "/home/user".to_string(),
|
cwd: "/home/user".to_string(),
|
||||||
};
|
};
|
||||||
let args = build_profcheck_args(&config);
|
let args = build_profcheck_args(&config);
|
||||||
assert_eq!(args, vec!["-v", "-k", "-s", "my_profile.ti3", "my_profile.icc"]);
|
assert_eq!(args, vec!["-v", "-k", "-s", "-u", "my_profile.ti3", "my_profile.icc"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod commands;
|
|||||||
mod events;
|
mod events;
|
||||||
mod print;
|
mod print;
|
||||||
mod process_manager;
|
mod process_manager;
|
||||||
|
mod quality_store;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -50,6 +51,8 @@ pub fn run() {
|
|||||||
commands::inspect_dataset_preview,
|
commands::inspect_dataset_preview,
|
||||||
commands::select_existing_target,
|
commands::select_existing_target,
|
||||||
commands::select_profile_file,
|
commands::select_profile_file,
|
||||||
|
commands::select_spectrum_file,
|
||||||
|
commands::select_dataset_file,
|
||||||
commands::select_target_file,
|
commands::select_target_file,
|
||||||
commands::select_directory,
|
commands::select_directory,
|
||||||
commands::send_stdin,
|
commands::send_stdin,
|
||||||
@@ -74,6 +77,11 @@ pub fn run() {
|
|||||||
commands::get_printer_capabilities,
|
commands::get_printer_capabilities,
|
||||||
commands::show_printer_properties,
|
commands::show_printer_properties,
|
||||||
commands::print_target_native,
|
commands::print_target_native,
|
||||||
|
commands::select_csv_save_path,
|
||||||
|
quality_store::save_verification_record,
|
||||||
|
quality_store::get_verification_history,
|
||||||
|
quality_store::clear_verification_history,
|
||||||
|
quality_store::export_verification_history_csv,
|
||||||
settings::load_settings,
|
settings::load_settings,
|
||||||
settings::save_settings,
|
settings::save_settings,
|
||||||
settings::get_all_presets,
|
settings::get_all_presets,
|
||||||
|
|||||||
+20
-34
@@ -253,14 +253,17 @@ pub fn detect_driver_color_bypass(output: &str) -> Option<(&'static str, &'stati
|
|||||||
Some(("CNIJIntent2", "4"))
|
Some(("CNIJIntent2", "4"))
|
||||||
} else if output.contains("CNIJIntent") {
|
} else if output.contains("CNIJIntent") {
|
||||||
Some(("CNIJIntent", "4"))
|
Some(("CNIJIntent", "4"))
|
||||||
|
} else if output.contains("EPIJ_CCor") {
|
||||||
|
// Epson "Color Settings" option. The value 0 disables driver-side color correction.
|
||||||
|
Some(("EPIJ_CCor", "0"))
|
||||||
} else if output.contains("EPIJ_CMat") {
|
} else if output.contains("EPIJ_CMat") {
|
||||||
// Epson "Color Settings" option. The value 3 is "Off (No Color
|
// Epson "Color Settings" option. The value 3 is "Off (No Color
|
||||||
// Adjustment)" which disables driver-side color management.
|
// Adjustment)" which disables driver-side color management.
|
||||||
Some(("EPIJ_CMat", "3"))
|
Some(("EPIJ_CMat", "3"))
|
||||||
} else if output.contains("ColorCorrection") {
|
|
||||||
Some(("ColorCorrection", "Uncorrected"))
|
|
||||||
} else if output.contains("StpColorCorrection") {
|
} else if output.contains("StpColorCorrection") {
|
||||||
Some(("StpColorCorrection", "Uncorrected"))
|
Some(("StpColorCorrection", "Uncorrected"))
|
||||||
|
} else if output.contains("ColorCorrection") {
|
||||||
|
Some(("ColorCorrection", "Uncorrected"))
|
||||||
} else if output.contains("EpsonColorMode") {
|
} else if output.contains("EpsonColorMode") {
|
||||||
Some(("EpsonColorMode", "Off"))
|
Some(("EpsonColorMode", "Off"))
|
||||||
} else {
|
} else {
|
||||||
@@ -515,38 +518,21 @@ printer Zebra_Label disabled since Wed 10 Jun 2026 - reason: out of ribbon\n\
|
|||||||
let merged = merge_printer_info(&destinations, &statuses, default_dest);
|
let merged = merge_printer_info(&destinations, &statuses, default_dest);
|
||||||
assert_eq!(merged.len(), 4);
|
assert_eq!(merged.len(), 4);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(merged[0].name, "Epson-Stylus-SX420W".to_string());
|
||||||
merged[0],
|
assert_eq!(merged[0].status, "Idle".to_string());
|
||||||
Printer {
|
assert!(merged[0].is_default);
|
||||||
name: "Epson-Stylus-SX420W".to_string(),
|
|
||||||
status: "Idle".to_string(),
|
assert_eq!(merged[1].name, "HP_LaserJet".to_string());
|
||||||
is_default: true,
|
assert_eq!(merged[1].status, "Printing".to_string());
|
||||||
}
|
assert!(!merged[1].is_default);
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(merged[2].name, "Virtual_PDF".to_string());
|
||||||
merged[1],
|
assert_eq!(merged[2].status, "Idle".to_string());
|
||||||
Printer {
|
assert!(!merged[2].is_default);
|
||||||
name: "HP_LaserJet".to_string(),
|
|
||||||
status: "Printing".to_string(),
|
assert_eq!(merged[3].name, "Zebra_Label".to_string());
|
||||||
is_default: false,
|
assert_eq!(merged[3].status, "Stopped".to_string());
|
||||||
}
|
assert!(!merged[3].is_default);
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
merged[2],
|
|
||||||
Printer {
|
|
||||||
name: "Virtual_PDF".to_string(),
|
|
||||||
status: "Idle".to_string(),
|
|
||||||
is_default: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
merged[3],
|
|
||||||
Printer {
|
|
||||||
name: "Zebra_Label".to_string(),
|
|
||||||
status: "Stopped".to_string(),
|
|
||||||
is_default: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ mod tests {
|
|||||||
let pm = ProcessManager::new();
|
let pm = ProcessManager::new();
|
||||||
// Test helper using dummy/mock or direct map operations
|
// Test helper using dummy/mock or direct map operations
|
||||||
{
|
{
|
||||||
let mut stdins = pm.stdins.lock().await;
|
let stdins = pm.stdins.lock().await;
|
||||||
assert!(!stdins.contains_key("test_proc"));
|
assert!(!stdins.contains_key("test_proc"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
static SEQ_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
|
const HISTORY_CAP: usize = 1000;
|
||||||
|
const STORE_VERSION: u32 = 1;
|
||||||
|
const MAX_STRING_LEN: usize = 200;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct VerificationRecord {
|
||||||
|
pub id: String, // backend-generated only: "vr-<epoch_millis>-<seq>"
|
||||||
|
pub timestamp: String, // ISO 8601 string
|
||||||
|
pub printer_name: String,
|
||||||
|
pub profile_name: String,
|
||||||
|
pub avg_de: f64,
|
||||||
|
pub max_de: f64,
|
||||||
|
pub rms_de: f64,
|
||||||
|
pub patch_count: u32,
|
||||||
|
pub status: String, // backend ALWAYS fills via classify_status
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct VerificationHistoryStore {
|
||||||
|
pub version: u32,
|
||||||
|
pub records: Vec<VerificationRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VerificationHistoryStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
records: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a unique record identifier: "vr-<epoch_millis>-<seq>"
|
||||||
|
pub fn generate_record_id() -> String {
|
||||||
|
let millis = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let seq = SEQ_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
format!("vr-{}-{}", millis, seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies status into ICCery verification bands (issue #95):
|
||||||
|
/// - < 1.0: "excellent"
|
||||||
|
/// - < 2.0: "good"
|
||||||
|
/// - < 3.5: "acceptable"
|
||||||
|
/// - >= 3.5: "warning"
|
||||||
|
pub fn classify_status(avg_de: f64) -> &'static str {
|
||||||
|
if avg_de < 1.0 {
|
||||||
|
"excellent"
|
||||||
|
} else if avg_de < 2.0 {
|
||||||
|
"good"
|
||||||
|
} else if avg_de < 3.5 {
|
||||||
|
"acceptable"
|
||||||
|
} else {
|
||||||
|
"warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates record data constraints.
|
||||||
|
pub fn validate_record(record: &VerificationRecord) -> Result<(), String> {
|
||||||
|
if record.profile_name.trim().is_empty() {
|
||||||
|
return Err("Profile name cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
if record.profile_name.len() > MAX_STRING_LEN {
|
||||||
|
return Err(format!("Profile name exceeds maximum length of {} characters.", MAX_STRING_LEN));
|
||||||
|
}
|
||||||
|
if record.printer_name.len() > MAX_STRING_LEN {
|
||||||
|
return Err(format!("Printer name exceeds maximum length of {} characters.", MAX_STRING_LEN));
|
||||||
|
}
|
||||||
|
if record.timestamp.trim().is_empty() {
|
||||||
|
return Err("Timestamp cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
if !record.avg_de.is_finite() || record.avg_de < 0.0 {
|
||||||
|
return Err("Average ΔE must be a finite non-negative number.".to_string());
|
||||||
|
}
|
||||||
|
if !record.max_de.is_finite() || record.max_de < 0.0 {
|
||||||
|
return Err("Peak ΔE must be a finite non-negative number.".to_string());
|
||||||
|
}
|
||||||
|
if !record.rms_de.is_finite() || record.rms_de < 0.0 {
|
||||||
|
return Err("RMS ΔE must be a finite non-negative number.".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads verification history from a JSON file.
|
||||||
|
/// Missing, unreadable, or corrupted files safely return an empty vector.
|
||||||
|
pub fn load_history_file(path: &Path) -> Vec<VerificationRecord> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let content = match fs::read_to_string(path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
match serde_json::from_str::<VerificationHistoryStore>(&content) {
|
||||||
|
Ok(store) => store.records,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes verification records to the specified JSON path.
|
||||||
|
/// Automatically creates parent directories if needed.
|
||||||
|
/// Evicts oldest records by timestamp if count exceeds HISTORY_CAP (1,000).
|
||||||
|
pub fn write_history_file(path: &Path, records: &[VerificationRecord]) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bounded_records: Vec<VerificationRecord> = records.to_vec();
|
||||||
|
// Sort by timestamp ascending
|
||||||
|
bounded_records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
||||||
|
|
||||||
|
if bounded_records.len() > HISTORY_CAP {
|
||||||
|
let excess = bounded_records.len() - HISTORY_CAP;
|
||||||
|
bounded_records.drain(0..excess);
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = VerificationHistoryStore {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
records: bounded_records,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&store)
|
||||||
|
.map_err(|e| format!("Failed to serialize verification history: {}", e))?;
|
||||||
|
fs::write(path, json).map_err(|e| format!("Failed to write verification history: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to escape CSV text fields according to RFC-4180.
|
||||||
|
fn escape_csv_field(val: &str) -> String {
|
||||||
|
if val.contains('"') || val.contains(',') || val.contains('\n') || val.contains('\r') {
|
||||||
|
format!("\"{}\"", val.replace('"', "\"\""))
|
||||||
|
} else {
|
||||||
|
val.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats records into standard RFC-4180 CSV string.
|
||||||
|
pub fn to_csv(records: &[VerificationRecord]) -> String {
|
||||||
|
let mut csv = String::from("id,timestamp,printer_name,profile_name,avg_de,max_de,rms_de,patch_count,status\r\n");
|
||||||
|
for r in records {
|
||||||
|
csv.push_str(&format!(
|
||||||
|
"{},{},{},{},{:.4},{:.4},{:.4},{},{}\r\n",
|
||||||
|
escape_csv_field(&r.id),
|
||||||
|
escape_csv_field(&r.timestamp),
|
||||||
|
escape_csv_field(&r.printer_name),
|
||||||
|
escape_csv_field(&r.profile_name),
|
||||||
|
r.avg_de,
|
||||||
|
r.max_de,
|
||||||
|
r.rms_de,
|
||||||
|
r.patch_count,
|
||||||
|
escape_csv_field(&r.status),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
csv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates CSV destination file path.
|
||||||
|
pub fn validate_csv_dest(dest: &str) -> Result<PathBuf, String> {
|
||||||
|
let trimmed = dest.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err("Destination path cannot be empty.".to_string());
|
||||||
|
}
|
||||||
|
let mut path = PathBuf::from(trimmed);
|
||||||
|
if path.extension().is_none() || path.extension().unwrap_or_default() != "csv" {
|
||||||
|
path.set_extension("csv");
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let app_data = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
Ok(app_data.join("verification_history.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tauri Commands
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_verification_record(
|
||||||
|
app: AppHandle,
|
||||||
|
mut record: VerificationRecord,
|
||||||
|
) -> Result<VerificationRecord, String> {
|
||||||
|
record.id = generate_record_id();
|
||||||
|
record.status = classify_status(record.avg_de).to_string();
|
||||||
|
validate_record(&record)?;
|
||||||
|
|
||||||
|
let path = get_store_path(&app)?;
|
||||||
|
let mut records = load_history_file(&path);
|
||||||
|
records.push(record.clone());
|
||||||
|
write_history_file(&path, &records)?;
|
||||||
|
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_verification_history(
|
||||||
|
app: AppHandle,
|
||||||
|
profile_name: Option<String>,
|
||||||
|
printer_name: Option<String>,
|
||||||
|
) -> Result<Vec<VerificationRecord>, String> {
|
||||||
|
let path = get_store_path(&app)?;
|
||||||
|
let mut records = load_history_file(&path);
|
||||||
|
|
||||||
|
if let Some(ref prof) = profile_name {
|
||||||
|
let prof_clean = prof.trim();
|
||||||
|
if !prof_clean.is_empty() {
|
||||||
|
records.retain(|r| r.profile_name == prof_clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref prn) = printer_name {
|
||||||
|
let prn_clean = prn.trim();
|
||||||
|
if !prn_clean.is_empty() {
|
||||||
|
records.retain(|r| r.printer_name == prn_clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return chronological order (ascending timestamp)
|
||||||
|
records.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn clear_verification_history(app: AppHandle) -> Result<(), String> {
|
||||||
|
let path = get_store_path(&app)?;
|
||||||
|
write_history_file(&path, &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn export_verification_history_csv(
|
||||||
|
app: AppHandle,
|
||||||
|
dest_path: String,
|
||||||
|
profile_name: Option<String>,
|
||||||
|
printer_name: Option<String>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let validated_path = validate_csv_dest(&dest_path)?;
|
||||||
|
let records = get_verification_history(app, profile_name, printer_name)?;
|
||||||
|
let count = records.len();
|
||||||
|
let csv_content = to_csv(&records);
|
||||||
|
|
||||||
|
if let Some(parent) = validated_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|e| format!("Failed to create CSV parent directory: {}", e))?;
|
||||||
|
}
|
||||||
|
fs::write(&validated_path, csv_content)
|
||||||
|
.map_err(|e| format!("Failed to write CSV file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unit Tests (Path-based, no AppHandle required)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
fn sample_record(id: &str, ts: &str, avg: f64) -> VerificationRecord {
|
||||||
|
VerificationRecord {
|
||||||
|
id: id.to_string(),
|
||||||
|
timestamp: ts.to_string(),
|
||||||
|
printer_name: "Canon PRO-1000".to_string(),
|
||||||
|
profile_name: "ProLustre_Photo".to_string(),
|
||||||
|
avg_de: avg,
|
||||||
|
max_de: avg * 2.0,
|
||||||
|
rms_de: avg * 1.2,
|
||||||
|
patch_count: 50,
|
||||||
|
status: classify_status(avg).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_classify_status_bands() {
|
||||||
|
// Boundaries: <1.0 excellent, <2.0 good, <3.5 acceptable, >=3.5 warning
|
||||||
|
assert_eq!(classify_status(0.0), "excellent");
|
||||||
|
assert_eq!(classify_status(0.999), "excellent");
|
||||||
|
assert_eq!(classify_status(1.0), "good");
|
||||||
|
assert_eq!(classify_status(1.999), "good");
|
||||||
|
assert_eq!(classify_status(2.0), "acceptable");
|
||||||
|
assert_eq!(classify_status(3.499), "acceptable");
|
||||||
|
assert_eq!(classify_status(3.5), "warning");
|
||||||
|
assert_eq!(classify_status(5.2), "warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_record_valid_and_invalid() {
|
||||||
|
let valid = sample_record("vr-1", "2026-09-05T12:00:00Z", 0.85);
|
||||||
|
assert!(validate_record(&valid).is_ok());
|
||||||
|
|
||||||
|
let mut empty_prof = valid.clone();
|
||||||
|
empty_prof.profile_name = " ".to_string();
|
||||||
|
assert!(validate_record(&empty_prof).is_err());
|
||||||
|
|
||||||
|
let mut empty_ts = valid.clone();
|
||||||
|
empty_ts.timestamp = "".to_string();
|
||||||
|
assert!(validate_record(&empty_ts).is_err());
|
||||||
|
|
||||||
|
let mut negative_de = valid.clone();
|
||||||
|
negative_de.avg_de = -0.1;
|
||||||
|
assert!(validate_record(&negative_de).is_err());
|
||||||
|
|
||||||
|
let mut nan_de = valid.clone();
|
||||||
|
nan_de.max_de = f64::NAN;
|
||||||
|
assert!(validate_record(&nan_de).is_err());
|
||||||
|
|
||||||
|
let mut inf_de = valid.clone();
|
||||||
|
inf_de.rms_de = f64::INFINITY;
|
||||||
|
assert!(validate_record(&inf_de).is_err());
|
||||||
|
|
||||||
|
let mut long_name = valid.clone();
|
||||||
|
long_name.profile_name = "a".repeat(201);
|
||||||
|
assert!(validate_record(&long_name).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_load_missing_and_corrupt() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_corrupt");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let missing_path = temp_dir.join("nonexistent.json");
|
||||||
|
let records = load_history_file(&missing_path);
|
||||||
|
assert!(records.is_empty());
|
||||||
|
|
||||||
|
let corrupt_path = temp_dir.join("corrupt.json");
|
||||||
|
fs::write(&corrupt_path, "{ broken json ... ").unwrap();
|
||||||
|
let records_corrupt = load_history_file(&corrupt_path);
|
||||||
|
assert!(records_corrupt.is_empty());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_write_and_roundtrip() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_roundtrip");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let file_path = temp_dir.join("verification_history.json");
|
||||||
|
let rec1 = sample_record("vr-1", "2026-09-01T10:00:00Z", 0.75);
|
||||||
|
let rec2 = sample_record("vr-2", "2026-09-02T10:00:00Z", 1.85);
|
||||||
|
|
||||||
|
write_history_file(&file_path, &[rec1.clone(), rec2.clone()]).unwrap();
|
||||||
|
let loaded = load_history_file(&file_path);
|
||||||
|
assert_eq!(loaded.len(), 2);
|
||||||
|
assert_eq!(loaded[0], rec1);
|
||||||
|
assert_eq!(loaded[1], rec2);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cap_eviction_drops_oldest_by_timestamp() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("iccery_test_quality_store_eviction");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).unwrap();
|
||||||
|
|
||||||
|
let file_path = temp_dir.join("capped_history.json");
|
||||||
|
|
||||||
|
// Generate 1005 records with disordered timestamps
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for i in 0..1005 {
|
||||||
|
// ts ranges from 1000 to 2004
|
||||||
|
let ts = format!("2026-01-01T{:04}Z", i);
|
||||||
|
records.push(sample_record(&format!("vr-{}", i), &ts, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle slightly so input is out of order
|
||||||
|
records.swap(0, 500);
|
||||||
|
|
||||||
|
write_history_file(&file_path, &records).unwrap();
|
||||||
|
let loaded = load_history_file(&file_path);
|
||||||
|
|
||||||
|
assert_eq!(loaded.len(), HISTORY_CAP); // 1000 records
|
||||||
|
// Oldest 5 records (ts 0000..0004) should have been evicted
|
||||||
|
assert_eq!(loaded[0].timestamp, "2026-01-01T0005Z");
|
||||||
|
assert_eq!(loaded[loaded.len() - 1].timestamp, "2026-01-01T1004Z");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_to_csv_escaping_and_formatting() {
|
||||||
|
let mut rec1 = sample_record("vr-1", "2026-09-05T12:00:00Z", 0.85);
|
||||||
|
rec1.printer_name = "Canon \"Pro\" 1000, Tray 1".to_string(); // quotes + comma
|
||||||
|
rec1.profile_name = "FineArt, Velvet".to_string();
|
||||||
|
|
||||||
|
let csv = to_csv(&[rec1]);
|
||||||
|
let lines: Vec<&str> = csv.split("\r\n").filter(|l| !l.is_empty()).collect();
|
||||||
|
assert_eq!(lines.len(), 2);
|
||||||
|
assert_eq!(lines[0], "id,timestamp,printer_name,profile_name,avg_de,max_de,rms_de,patch_count,status");
|
||||||
|
assert!(lines[1].contains("\"Canon \"\"Pro\"\" 1000, Tray 1\""));
|
||||||
|
assert!(lines[1].contains("\"FineArt, Velvet\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_csv_dest() {
|
||||||
|
assert!(validate_csv_dest(" ").is_err());
|
||||||
|
|
||||||
|
let p1 = validate_csv_dest("/tmp/history.csv").unwrap();
|
||||||
|
assert_eq!(p1.extension().unwrap(), "csv");
|
||||||
|
|
||||||
|
let p2 = validate_csv_dest("/tmp/history").unwrap();
|
||||||
|
assert_eq!(p2.extension().unwrap(), "csv");
|
||||||
|
assert_eq!(p2.to_string_lossy(), "/tmp/history.csv");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,13 +60,49 @@ pub struct ProfilingPreset {
|
|||||||
pub no_randomize: Option<bool>,
|
pub no_randomize: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
|
fn default_delta_e_good_max() -> f64 { 2.0 }
|
||||||
|
fn default_delta_e_warning_max() -> f64 { 5.0 }
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
pub argyll_binary_dir: Option<String>,
|
pub argyll_binary_dir: Option<String>,
|
||||||
pub default_instrument: Option<String>,
|
pub default_instrument: Option<String>,
|
||||||
pub log_level: Option<String>,
|
pub log_level: Option<String>,
|
||||||
|
#[serde(default = "default_delta_e_good_max")]
|
||||||
|
pub delta_e_good_max: f64,
|
||||||
|
#[serde(default = "default_delta_e_warning_max")]
|
||||||
|
pub delta_e_warning_max: f64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_presets: Vec<ProfilingPreset>,
|
pub custom_presets: Vec<ProfilingPreset>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enable_i1pro2_leds: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
argyll_binary_dir: None,
|
||||||
|
default_instrument: None,
|
||||||
|
log_level: None,
|
||||||
|
delta_e_good_max: default_delta_e_good_max(),
|
||||||
|
delta_e_warning_max: default_delta_e_warning_max(),
|
||||||
|
custom_presets: Vec::new(),
|
||||||
|
enable_i1pro2_leds: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppSettings {
|
||||||
|
/// Validate thresholds and other cross-field invariants.
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.delta_e_good_max < 0.0 || self.delta_e_warning_max < 0.0 {
|
||||||
|
return Err("ΔE thresholds cannot be negative.".to_string());
|
||||||
|
}
|
||||||
|
if self.delta_e_good_max >= self.delta_e_warning_max {
|
||||||
|
return Err("Good ΔE threshold must be strictly less than the warning threshold.".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||||
@@ -239,6 +275,7 @@ pub fn load_settings(app: AppHandle) -> Result<AppSettings, String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
|
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
|
||||||
|
settings.validate()?;
|
||||||
let path = app.path().app_data_dir().unwrap();
|
let path = app.path().app_data_dir().unwrap();
|
||||||
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
||||||
let json = serde_json::to_string_pretty(&settings).unwrap();
|
let json = serde_json::to_string_pretty(&settings).unwrap();
|
||||||
@@ -380,6 +417,39 @@ mod tests {
|
|||||||
assert_eq!(preset, imported);
|
assert_eq!(preset, imported);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_delta_e_thresholds() {
|
||||||
|
let settings = AppSettings::default();
|
||||||
|
assert_eq!(settings.delta_e_good_max, 2.0);
|
||||||
|
assert_eq!(settings.delta_e_warning_max, 5.0);
|
||||||
|
assert!(settings.delta_e_good_max < settings.delta_e_warning_max);
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delta_e_threshold_serialization_defaults() {
|
||||||
|
let json = "{}";
|
||||||
|
let settings: AppSettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(settings.delta_e_good_max, 2.0);
|
||||||
|
assert_eq!(settings.delta_e_warning_max, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delta_e_threshold_validation() {
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
settings.delta_e_good_max = 5.0;
|
||||||
|
settings.delta_e_warning_max = 2.0;
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
|
||||||
|
settings.delta_e_good_max = -1.0;
|
||||||
|
settings.delta_e_warning_max = 2.0;
|
||||||
|
assert!(settings.validate().is_err());
|
||||||
|
|
||||||
|
settings.delta_e_good_max = 1.0;
|
||||||
|
settings.delta_e_warning_max = 3.0;
|
||||||
|
assert!(settings.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_log_level_filter() {
|
fn test_parse_log_level_filter() {
|
||||||
assert_eq!(parse_log_level_filter(Some("error")), log::LevelFilter::Error);
|
assert_eq!(parse_log_level_filter(Some("error")), log::LevelFilter::Error);
|
||||||
@@ -399,4 +469,28 @@ mod tests {
|
|||||||
assert_eq!(parse_log_level_filter(Some("unknown")), log::LevelFilter::Info);
|
assert_eq!(parse_log_level_filter(Some("unknown")), log::LevelFilter::Info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_enable_i1pro2_leds() {
|
||||||
|
let settings = AppSettings::default();
|
||||||
|
assert_eq!(settings.enable_i1pro2_leds, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_enable_i1pro2_leds_serialization() {
|
||||||
|
// Missing field defaults to false
|
||||||
|
let json_empty = "{}";
|
||||||
|
let settings_empty: AppSettings = serde_json::from_str(json_empty).unwrap();
|
||||||
|
assert_eq!(settings_empty.enable_i1pro2_leds, false);
|
||||||
|
|
||||||
|
// Explicit true
|
||||||
|
let json_true = r#"{"enable_i1pro2_leds": true}"#;
|
||||||
|
let settings_true: AppSettings = serde_json::from_str(json_true).unwrap();
|
||||||
|
assert_eq!(settings_true.enable_i1pro2_leds, true);
|
||||||
|
|
||||||
|
// Explicit false
|
||||||
|
let json_false = r#"{"enable_i1pro2_leds": false}"#;
|
||||||
|
let settings_false: AppSettings = serde_json::from_str(json_false).unwrap();
|
||||||
|
assert_eq!(settings_false.enable_i1pro2_leds, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ICCery",
|
"productName": "ICCery",
|
||||||
"version": "0.7.4",
|
"version": "0.8.4",
|
||||||
"identifier": "com.gronod.iccery",
|
"identifier": "com.gronod.iccery",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../src"
|
"frontendDist": "../src"
|
||||||
|
|||||||
+157
-34
@@ -35,9 +35,9 @@
|
|||||||
|
|
||||||
<!-- Profiling Preset Selector -->
|
<!-- Profiling Preset Selector -->
|
||||||
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
|
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
<div class="form-group-header">
|
||||||
<label for="presetSelect" style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600;">Profiling Preset</label>
|
<label for="presetSelect" class="form-group-label-small">Profiling Preset</label>
|
||||||
<div style="display: flex; gap: 6px; align-items: center; justify-content: flex-end;">
|
<div class="btn-row-sm">
|
||||||
<button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset">
|
<button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -131,10 +131,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group has-tooltip">
|
<div class="form-group has-tooltip">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
<div class="form-group-header">
|
||||||
<label style="margin: 0;">Project Directory & Filename</label>
|
<label>Project Directory & Filename</label>
|
||||||
<div style="display:flex; justify-content:flex-end;">
|
<div class="btn-row-sm">
|
||||||
<button type="button" id="btn-import-dataset" class="secondary btn-sm" style="margin-right: 4px;">Import Dataset...</button>
|
<button type="button" id="btn-import-dataset" class="secondary btn-sm">Import Dataset...</button>
|
||||||
<button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button>
|
<button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,8 +407,8 @@
|
|||||||
|
|
||||||
<!-- Target Label & Metadata (printtarg -d) -->
|
<!-- Target Label & Metadata (printtarg -d) -->
|
||||||
<div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
<div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
<div class="form-group-header">
|
||||||
<label style="margin: 0; font-weight: 600;">Target Label & Metadata (Optional)</label>
|
<label class="form-group-title">Target Label & Metadata (Optional)</label>
|
||||||
<button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button>
|
<button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p>
|
<p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p>
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
<select id="printerSelect">
|
<select id="printerSelect">
|
||||||
<option value="" disabled selected>Detecting printers...</option>
|
<option value="" disabled selected>Detecting printers...</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn-icon" id="btnRefreshPrinters" title="Refresh printer list">↻</button>
|
<button type="button" class="btn-icon-sq" id="btnRefreshPrinters" title="Refresh printer list">↻</button>
|
||||||
<button type="button" class="btn-properties" id="btnPrinterProperties" title="Open native printer driver preferences (disable colour management, set media type, etc.)">⚙️ Preferences</button>
|
<button type="button" class="btn-properties" id="btnPrinterProperties" title="Open native printer driver preferences (disable colour management, set media type, etc.)">⚙️ Preferences</button>
|
||||||
<span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span>
|
<span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="print-actions-row">
|
<div class="print-actions-row">
|
||||||
<button class="primary btn-print" id="btnPrintAll">
|
<button class="primary" id="btnPrintAll">
|
||||||
<span class="btn-text">🖨️ Print All Pages (Bypass CM)</span>
|
<span class="btn-text">🖨️ Print All Pages (Bypass CM)</span>
|
||||||
<span class="btn-spinner hidden"></span>
|
<span class="btn-spinner hidden"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -550,8 +550,8 @@
|
|||||||
<div class="form-container" style="margin-bottom: 16px;">
|
<div class="form-container" style="margin-bottom: 16px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="chartreadInstrumentSelect">Communication Port</label>
|
<label for="chartreadInstrumentSelect">Communication Port</label>
|
||||||
<div class="input-row" style="display: flex; gap: 8px; align-items: center;">
|
<div class="input-row-sm">
|
||||||
<select id="chartreadInstrumentSelect" style="flex: 1;">
|
<select id="chartreadInstrumentSelect">
|
||||||
<option value="">Auto (First available port)</option>
|
<option value="">Auto (First available port)</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="secondary" id="btnDetectInstruments" title="Detect connected instruments via instlist">↻ Detect</button>
|
<button type="button" class="secondary" id="btnDetectInstruments" title="Detect connected instruments via instlist">↻ Detect</button>
|
||||||
@@ -559,6 +559,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- XY Scanning Table Panel -->
|
||||||
|
<div id="xyTableHint" class="notification-banner info hidden">
|
||||||
|
<span>ℹ️</span>
|
||||||
|
<span>SpectroScan → use the SS layout in Stage 2; i1iO → use i1/i1Pro2 layout — the table is auto-detected when chartread starts.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="xyTablePanel" class="xy-table-panel hidden">
|
||||||
|
<div class="xy-table-header">
|
||||||
|
<h4>XY Automated Scanning Table Sequence</h4>
|
||||||
|
<span id="xyTableActiveStepBadge" class="status-badge badge-idle">Standby</span>
|
||||||
|
</div>
|
||||||
|
<ol class="xy-steps-list">
|
||||||
|
<li id="xyStepPlace" class="xy-step">Place sheet on table</li>
|
||||||
|
<li id="xyStepAlign" class="xy-step">Align reference patches with sight</li>
|
||||||
|
<li id="xyStepScan" class="xy-step">Automated scan in progress</li>
|
||||||
|
<li id="xyStepRemove" class="xy-step">Remove sheet from table</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- State & Prompt Area -->
|
<!-- State & Prompt Area -->
|
||||||
<div class="chartread-status">
|
<div class="chartread-status">
|
||||||
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
<div class="status-label">State: <span id="chartreadState">IDLE</span></div>
|
||||||
@@ -593,13 +612,13 @@
|
|||||||
|
|
||||||
<!-- Multi-Pass Session & Averaging Controls -->
|
<!-- Multi-Pass Session & Averaging Controls -->
|
||||||
<div class="averaging-panel hidden" id="chartreadAveragingPanel" style="margin-top: 16px; padding: 14px; border: 1px solid var(--border-color, #333); border-radius: 8px; background: rgba(255, 255, 255, 0.03);">
|
<div class="averaging-panel hidden" id="chartreadAveragingPanel" style="margin-top: 16px; padding: 14px; border: 1px solid var(--border-color, #333); border-radius: 8px; background: rgba(255, 255, 255, 0.03);">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
<div class="form-group-header">
|
||||||
<h3 style="margin: 0; font-size: 1rem;">Multi-Pass Target Averaging</h3>
|
<h3 class="form-group-title">Multi-Pass Target Averaging</h3>
|
||||||
<span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span>
|
<span id="passCounterBadge" class="status-badge badge-idle">Pass 1 Complete</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="sub-label" style="margin-bottom: 12px;">Each successful sheet is saved as a separate _passN.ti3 file. Stage 4 stays locked until you finish. Measure extra copies to reduce noise, then finish to write the canonical .ti3.</p>
|
<p class="sub-label" style="margin-bottom: 12px;">Each successful sheet is saved as a separate _passN.ti3 file. Stage 4 stays locked until you finish. Measure extra copies to reduce noise, then finish to write the canonical .ti3.</p>
|
||||||
<div id="passesList" class="passes-list" style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;"></div>
|
<div id="passesList" class="passes-list" style="display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;"></div>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div class="btn-row">
|
||||||
<button type="button" class="secondary" id="btnMeasureAnotherSheet">➕ Measure Another Sheet</button>
|
<button type="button" class="secondary" id="btnMeasureAnotherSheet">➕ Measure Another Sheet</button>
|
||||||
<button type="button" class="primary" id="btnFinishAndAverage" title="Complete measurement (Finish) before profiling">✓ Average Passes & Proceed →</button>
|
<button type="button" class="primary" id="btnFinishAndAverage" title="Complete measurement (Finish) before profiling">✓ Average Passes & Proceed →</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -648,7 +667,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group has-tooltip">
|
||||||
<label>OBA / FWA Compensation</label>
|
<label>OBA / FWA Compensation</label>
|
||||||
<select id="colprofFwa">
|
<select id="colprofFwa">
|
||||||
<option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option>
|
<option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option>
|
||||||
@@ -656,14 +675,24 @@
|
|||||||
<option value="D65">D65 FWA Compensation</option>
|
<option value="D65">D65 FWA Compensation</option>
|
||||||
<option value="custom">Custom Ambient Spectrum (.sp)...</option>
|
<option value="custom">Custom Ambient Spectrum (.sp)...</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="tooltip-text">
|
||||||
|
Select how colprof handles Optical Brightening Agent (OBA) / Fluorescent Whitening Agent (FWA) compensation.<br>
|
||||||
|
• <strong>D50</strong>: Simulates ISO 3664 M1 viewing (D50, UV-cut) — recommended for modern OBA papers.<br>
|
||||||
|
• <strong>None</strong>: Native M0; no FWA compensation. Can look yellow under D50 if the paper contains OBA.<br>
|
||||||
|
• <strong>D65</strong>: Simulates outdoor D65 daylight; useful for outdoor display / packaging proofing.<br>
|
||||||
|
• <strong>Custom (.sp)</strong>: Provide an Argyll spectrum file describing the viewing booth illuminant.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group hidden" id="colprofCustomSpRow">
|
<div class="form-group hidden has-tooltip" id="colprofCustomSpRow">
|
||||||
<label>Custom Spectrum File (.sp)</label>
|
<label>Custom Spectrum File (.sp)</label>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div class="input-row-sm">
|
||||||
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly style="flex:1;">
|
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly>
|
||||||
<button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button>
|
<button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tooltip-text">
|
||||||
|
Path to an Argyll-format spectrum file (.sp) representing the ambient illuminant. This is passed to colprof via the -f option and overrides the built-in D50/D65 compensation.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="advanced-section-details">
|
<details class="advanced-section-details">
|
||||||
@@ -673,7 +702,7 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="advanced-content">
|
<div class="advanced-content">
|
||||||
<div class="advanced-grid-2col">
|
<div class="advanced-grid-2col">
|
||||||
<div class="form-group">
|
<div class="form-group has-tooltip">
|
||||||
<label>Standard Illuminant</label>
|
<label>Standard Illuminant</label>
|
||||||
<select id="colprofIlluminant">
|
<select id="colprofIlluminant">
|
||||||
<option value="" selected>Default (D50)</option>
|
<option value="" selected>Default (D50)</option>
|
||||||
@@ -685,8 +714,11 @@
|
|||||||
<option value="F8">F8</option>
|
<option value="F8">F8</option>
|
||||||
<option value="F10">F10</option>
|
<option value="F10">F10</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="tooltip-text">
|
||||||
|
Illuminant used for the CIE colourimetric calculations in colprof (option -i). Leave at Default (D50) for normal print profiling.
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
|
<div class="form-group has-tooltip">
|
||||||
<label>Standard Observer</label>
|
<label>Standard Observer</label>
|
||||||
<select id="colprofObserver">
|
<select id="colprofObserver">
|
||||||
<option value="" selected>Default (1931 2°)</option>
|
<option value="" selected>Default (1931 2°)</option>
|
||||||
@@ -694,8 +726,11 @@
|
|||||||
<option value="2015_2">2015 2°</option>
|
<option value="2015_2">2015 2°</option>
|
||||||
<option value="2015_10">2015 10°</option>
|
<option value="2015_10">2015 10°</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="tooltip-text">
|
||||||
|
CIE standard observer used for the colourimetric calculations (option -o). The 1931 2° observer is the default for graphic-arts profiling.
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
|
<div class="form-group has-tooltip">
|
||||||
<label>Input Viewing Condition</label>
|
<label>Input Viewing Condition</label>
|
||||||
<select id="colprofInputViewCond">
|
<select id="colprofInputViewCond">
|
||||||
<option value="none" selected>None</option>
|
<option value="none" selected>None</option>
|
||||||
@@ -704,8 +739,11 @@
|
|||||||
<option value="pe">Print evaluation (CIE 116-1995)</option>
|
<option value="pe">Print evaluation (CIE 116-1995)</option>
|
||||||
<option value="pm">Print evaluation (partial mid-tone adaptation)</option>
|
<option value="pm">Print evaluation (partial mid-tone adaptation)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="tooltip-text">
|
||||||
|
Viewing condition under which the print target was measured (option -c). This affects the adapted white point used in the profile lookup table.
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
</div>
|
||||||
|
<div class="form-group has-tooltip">
|
||||||
<label>Output Viewing Condition</label>
|
<label>Output Viewing Condition</label>
|
||||||
<select id="colprofOutputViewCond">
|
<select id="colprofOutputViewCond">
|
||||||
<option value="none" selected>None</option>
|
<option value="none" selected>None</option>
|
||||||
@@ -716,6 +754,9 @@
|
|||||||
<option value="jd">Projector in dark environment</option>
|
<option value="jd">Projector in dark environment</option>
|
||||||
<option value="tv">Television/Film Studio</option>
|
<option value="tv">Television/Film Studio</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="tooltip-text">
|
||||||
|
Viewing condition under which the profile is intended to be used (option -d). Useful for creating display / output-referred profiles.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -723,7 +764,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stage-actions">
|
<div class="stage-actions">
|
||||||
<button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button>
|
<button class="primary btn-lg" id="btnCreateProfile">Calculate Profile (.icc)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Spinner and Progress Stage -->
|
<!-- Spinner and Progress Stage -->
|
||||||
@@ -736,7 +777,7 @@
|
|||||||
<div class="success-card hidden" id="colprofSuccessCard">
|
<div class="success-card hidden" id="colprofSuccessCard">
|
||||||
<h3>🎉 Profile Calculation Complete</h3>
|
<h3>🎉 Profile Calculation Complete</h3>
|
||||||
<p id="colprofSuccessInfo"></p>
|
<p id="colprofSuccessInfo"></p>
|
||||||
<button class="primary" id="btnGoToVerify">Proceed to Verification →</button>
|
<button class="primary btn-lg" id="btnGoToVerify">Proceed to Verification →</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Process Log -->
|
<!-- Process Log -->
|
||||||
@@ -752,7 +793,7 @@
|
|||||||
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
||||||
|
|
||||||
<div class="stage-actions">
|
<div class="stage-actions">
|
||||||
<button class="primary" id="btnVerify">Verify Profile Accuracy</button>
|
<button class="primary btn-lg" id="btnVerify">Verify Profile Accuracy</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Report Card -->
|
<!-- Report Card -->
|
||||||
@@ -782,6 +823,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Verification History & Drift Tracking -->
|
||||||
|
<details class="log-container drift-history" id="driftHistorySection">
|
||||||
|
<summary>Verification History & Drift Tracking</summary>
|
||||||
|
<div class="notification-banner warning hidden" id="driftAlertCard">
|
||||||
|
<span id="driftAlertIcon">⚠️</span><span id="driftAlertText"></span>
|
||||||
|
</div>
|
||||||
|
<div class="drift-filter-row hidden" id="driftFilterRow">
|
||||||
|
<label for="driftPrinterFilter">Filter by Printer:</label>
|
||||||
|
<select id="driftPrinterFilter">
|
||||||
|
<option value="">All Printers</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="driftChartWrap" class="drift-chart-wrap">
|
||||||
|
<svg id="driftTrendChart" role="img" aria-label="Printer Drift Verification History"></svg>
|
||||||
|
</div>
|
||||||
|
<p id="driftEmptyState" class="help-hint">No verification history yet for this profile.</p>
|
||||||
|
<div class="drift-table-wrap">
|
||||||
|
<table id="verificationHistoryTable" class="drift-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Printer</th>
|
||||||
|
<th>Avg ΔE₀₀</th>
|
||||||
|
<th>Peak ΔE₀₀</th>
|
||||||
|
<th>RMS ΔE₀₀</th>
|
||||||
|
<th>Patches</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="verificationHistoryTbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button type="button" class="secondary" id="btnExportHistoryCsv" disabled>Export CSV…</button>
|
||||||
|
<button type="button" class="danger" id="btnClearHistory" disabled>Clear History</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- 3D Gamut Viewer -->
|
<!-- 3D Gamut Viewer -->
|
||||||
<div class="gamut-viewer-section">
|
<div class="gamut-viewer-section">
|
||||||
<div class="gamut-viewer-header">
|
<div class="gamut-viewer-header">
|
||||||
@@ -805,6 +884,10 @@
|
|||||||
<span class="gamut-toggle-slider"></span>
|
<span class="gamut-toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gamut-legend-item gamut-slider-item">
|
||||||
|
<label for="rngProfileOpacity" class="gamut-slider-label">Opacity</label>
|
||||||
|
<input type="range" id="rngProfileOpacity" min="0.1" max="1" step="0.05" value="0.88">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="gamut-legend-item">
|
<div class="gamut-legend-item">
|
||||||
<span class="gamut-swatch gamut-swatch-srgb"></span>
|
<span class="gamut-swatch gamut-swatch-srgb"></span>
|
||||||
@@ -814,6 +897,10 @@
|
|||||||
<span class="gamut-toggle-slider"></span>
|
<span class="gamut-toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gamut-legend-item gamut-slider-item">
|
||||||
|
<label for="rngSrgbOpacity" class="gamut-slider-label">Opacity</label>
|
||||||
|
<input type="range" id="rngSrgbOpacity" min="0.1" max="1" step="0.05" value="0.55">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="gamut-legend-item">
|
<div class="gamut-legend-item">
|
||||||
<span class="gamut-swatch gamut-swatch-axes"></span>
|
<span class="gamut-swatch gamut-swatch-axes"></span>
|
||||||
@@ -823,6 +910,15 @@
|
|||||||
<span class="gamut-toggle-slider"></span>
|
<span class="gamut-toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gamut-legend-item gamut-slider-item">
|
||||||
|
<label for="rngAxisOpacity" class="gamut-slider-label">Opacity</label>
|
||||||
|
<input type="range" id="rngAxisOpacity" min="0.1" max="1" step="0.05" value="0.8">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gamut-controls-actions">
|
||||||
|
<button type="button" id="btnGamutResetCamera" class="btn-sm" title="Reset camera to default view (R)">⟲ Reset View</button>
|
||||||
|
<span class="gamut-keyboard-hint">Press R to reset</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -844,10 +940,37 @@
|
|||||||
<label for="argyll_binary_dir">ArgyllCMS Binary Directory Override</label>
|
<label for="argyll_binary_dir">ArgyllCMS Binary Directory Override</label>
|
||||||
<input type="text" id="argyll_binary_dir" placeholder="Leave empty for bundled sidecars (e.g. /usr/bin)">
|
<input type="text" id="argyll_binary_dir" placeholder="Leave empty for bundled sidecars (e.g. /usr/bin)">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||||
<label for="default_instrument">Default Instrument Override</label>
|
<label style="font-weight: 600;">Instrument & Measurement Preferences</label>
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<label for="default_instrument" class="sub-label">Default Instrument Override</label>
|
||||||
<input type="text" id="default_instrument" placeholder="e.g. i1">
|
<input type="text" id="default_instrument" placeholder="e.g. i1">
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
<label class="checkbox-label" for="enable_i1pro2_leds" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||||
|
<input type="checkbox" id="enable_i1pro2_leds">
|
||||||
|
<span>Enable i1Pro 2 status LEDs (<code>-Y l</code>)</span>
|
||||||
|
</label>
|
||||||
|
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Provides visual status feedback via the instrument ring LEDs during patch reading. Requires patched ArgyllCMS build.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||||
|
<label style="font-weight: 600;">Stage 3 ΔE₀₀ Traffic-Light Thresholds</label>
|
||||||
|
<div class="input-row" style="margin-top: 8px; align-items: flex-start;">
|
||||||
|
<div>
|
||||||
|
<label for="deltaEGoodMax" class="sub-label">Good / Warning boundary</label>
|
||||||
|
<input type="number" id="deltaEGoodMax" min="0" step="0.1" value="2.0">
|
||||||
|
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">ΔE below this value is shown as green (Good).</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="deltaEWarningMax" class="sub-label">Warning / Bad boundary</label>
|
||||||
|
<input type="number" id="deltaEWarningMax" min="0" step="0.1" value="5.0">
|
||||||
|
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">ΔE above this value is shown as red (Bad).</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small id="deltaEThresholdError" class="help-hint" style="display:none; color: var(--error-color, #ff5f5f); margin-top: 6px;">Good threshold must be less than warning threshold.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||||
<label style="font-weight: 600;">Diagnostics & Logging</label>
|
<label style="font-weight: 600;">Diagnostics & Logging</label>
|
||||||
<div class="input-row" style="margin-top: 8px;">
|
<div class="input-row" style="margin-top: 8px;">
|
||||||
@@ -862,7 +985,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Applies immediately on Save</small>
|
<small class="help-hint" style="display:block; font-size:0.75rem; color:var(--text-muted, #888); margin-top:3px;">Applies immediately on Save</small>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap;">
|
<div class="btn-row wrap">
|
||||||
<button type="button" id="btnOpenLogFolder" class="secondary" title="Open log folder in file explorer">📂 Open Log Folder</button>
|
<button type="button" id="btnOpenLogFolder" class="secondary" title="Open log folder in file explorer">📂 Open Log Folder</button>
|
||||||
<button type="button" id="btnCopyLogPath" class="secondary" title="Copy full log file path to clipboard">📋 Copy Path</button>
|
<button type="button" id="btnCopyLogPath" class="secondary" title="Copy full log file path to clipboard">📋 Copy Path</button>
|
||||||
<button type="button" id="btnCopyLogExcerpt" class="secondary" title="Copy recent log entries (last 200 lines) to clipboard">📋 Copy Recent Logs</button>
|
<button type="button" id="btnCopyLogExcerpt" class="secondary" title="Copy recent log entries (last 200 lines) to clipboard">📋 Copy Recent Logs</button>
|
||||||
@@ -884,7 +1007,7 @@
|
|||||||
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
<img src="./assets/ICCery-logo.svg" alt="ICCery Logo" class="about-logo" />
|
||||||
</div>
|
</div>
|
||||||
<h2>About ICCery</h2>
|
<h2>About ICCery</h2>
|
||||||
<p><strong>Version:</strong> <span id="aboutVersion">v0.7.2</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
<p><strong>Version:</strong> <span id="aboutVersion">v0.8.2</span> • <strong>Build Date:</strong> <span id="aboutBuildDate">September 2026</span></p>
|
||||||
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
<p><strong>Copyright © 2026 Gordon Bolton. All rights reserved.</strong></p>
|
||||||
<h3>Licences & EULA</h3>
|
<h3>Licences & EULA</h3>
|
||||||
<div class="license-text-container">
|
<div class="license-text-container">
|
||||||
@@ -936,9 +1059,9 @@
|
|||||||
<p>Copyright (c) 2010-2023 three.js authors</p>
|
<p>Copyright (c) 2010-2023 three.js authors</p>
|
||||||
<p class="license-legal-block">Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
|
<p class="license-legal-block">Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
|
||||||
|
|
||||||
<h5>Delaunator</h5>
|
<h5>quickhull3d</h5>
|
||||||
<p>Copyright (c) 2017, Mapbox</p>
|
<p>MIT License</p>
|
||||||
<p class="license-legal-block">Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.</p>
|
<p class="license-legal-block">Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:<br/><br/>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.<br/><br/>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
|
||||||
|
|
||||||
<h5>Tauri Framework & Rust Dependencies</h5>
|
<h5>Tauri Framework & Rust Dependencies</h5>
|
||||||
<p>Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.</p>
|
<p>Copyright (c) 2019-present, Tauri Programme within The Commons Conservancy.</p>
|
||||||
|
|||||||
+38
-9
@@ -1,4 +1,6 @@
|
|||||||
const { invoke } = window.__TAURI__.core;
|
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke
|
||||||
|
? window.__TAURI__.core.invoke
|
||||||
|
: async () => {};
|
||||||
|
|
||||||
export class CgatsInterop {
|
export class CgatsInterop {
|
||||||
constructor(appState) {
|
constructor(appState) {
|
||||||
@@ -22,26 +24,53 @@ export class CgatsInterop {
|
|||||||
|
|
||||||
async handleImport() {
|
async handleImport() {
|
||||||
try {
|
try {
|
||||||
const filePath = await invoke('select_target_file');
|
const filePath = await invoke('select_dataset_file', {
|
||||||
|
defaultDir: this.appState?.cwd || null,
|
||||||
|
});
|
||||||
if (!filePath) return; // User cancelled
|
if (!filePath) return; // User cancelled
|
||||||
|
|
||||||
// Inspect first to show modal (optional, skipping for now, directly import)
|
const isWindows = filePath.includes('\\');
|
||||||
// Since we are mocking the UI a bit for this branch, we will just import directly
|
const sep = isWindows ? '\\' : '/';
|
||||||
|
const parts = filePath.split(sep);
|
||||||
|
const fileName = parts.pop();
|
||||||
|
const fileDir = parts.join(sep);
|
||||||
|
const fileStem = fileName.replace(/\.[^/.]+$/, '');
|
||||||
|
|
||||||
|
const targetCwd = this.appState?.cwd || fileDir;
|
||||||
|
const targetBasename = this.appState?.basename || fileStem;
|
||||||
|
|
||||||
|
const targetBasenameInput = document.getElementById('targetBasename');
|
||||||
|
if (targetBasenameInput && !targetBasenameInput.value.trim()) {
|
||||||
|
targetBasenameInput.value = targetBasename;
|
||||||
|
}
|
||||||
|
const selectedPathDisplay = document.getElementById('selectedPathDisplay');
|
||||||
|
if (selectedPathDisplay && (!selectedPathDisplay.textContent || selectedPathDisplay.textContent.includes('No directory') || !this.appState?.cwd)) {
|
||||||
|
selectedPathDisplay.textContent = `Directory: ${targetCwd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.appState?.setTarget) {
|
||||||
|
await this.appState.setTarget(targetBasename, targetCwd);
|
||||||
|
}
|
||||||
|
|
||||||
const summary = await invoke('import_measurement_dataset', {
|
const summary = await invoke('import_measurement_dataset', {
|
||||||
filePath,
|
filePath,
|
||||||
targetCwd: this.appState.cwd,
|
targetCwd,
|
||||||
targetBasename: this.appState.basename,
|
targetBasename,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.appState.showNotice(`Successfully imported dataset (${summary.patch_count} patches)`, 'success');
|
this.appState?.showNotice?.(`Successfully imported dataset (${summary.patch_count} patches)`, 'success');
|
||||||
|
|
||||||
// Update state to jump to stage 4
|
// Update state to jump to stage 4
|
||||||
|
if (this.appState?.updateGating) {
|
||||||
await this.appState.updateGating();
|
await this.appState.updateGating();
|
||||||
|
}
|
||||||
|
if (this.appState) {
|
||||||
this.appState.currentStage = 4;
|
this.appState.currentStage = 4;
|
||||||
this.appState.applyStageDOM(4);
|
this.appState.applyStageDOM?.(4);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.appState.showNotice(`Failed to import dataset: ${e}`, 'error');
|
this.appState?.showNotice?.(`Failed to import dataset: ${e}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+430
-52
@@ -45,7 +45,7 @@ export function populateStage3TargetContext(metadata) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// State machine states
|
// State machine states
|
||||||
const STATE = {
|
export const STATE = {
|
||||||
IDLE: "IDLE",
|
IDLE: "IDLE",
|
||||||
CALIBRATING: "CALIBRATING",
|
CALIBRATING: "CALIBRATING",
|
||||||
AWAITING_STRIP: "AWAITING_STRIP",
|
AWAITING_STRIP: "AWAITING_STRIP",
|
||||||
@@ -53,13 +53,242 @@ const STATE = {
|
|||||||
ALL_STRIPS_READ: "ALL_STRIPS_READ",
|
ALL_STRIPS_READ: "ALL_STRIPS_READ",
|
||||||
WARNING: "WARNING",
|
WARNING: "WARNING",
|
||||||
PROMPT_CONTINUE: "PROMPT_CONTINUE",
|
PROMPT_CONTINUE: "PROMPT_CONTINUE",
|
||||||
|
TABLE_PLACE_SHEET: "TABLE_PLACE_SHEET",
|
||||||
|
TABLE_ALIGN: "TABLE_ALIGN",
|
||||||
ERROR: "ERROR",
|
ERROR: "ERROR",
|
||||||
FINISHED: "FINISHED",
|
FINISHED: "FINISHED",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies a line of stdout from chartread into a state transition, prompt, and metadata.
|
||||||
|
* Pure function with no side-effects or DOM interaction.
|
||||||
|
*
|
||||||
|
* @param {string} line - Raw stdout line
|
||||||
|
* @param {string} currentState - The current STATE value
|
||||||
|
* @returns {{ state: string, prompt: string, matched: boolean, meta?: object }}
|
||||||
|
*/
|
||||||
|
export function classifyChartreadLine(line, currentState = STATE.IDLE) {
|
||||||
|
if (typeof line !== "string") {
|
||||||
|
return { state: currentState, prompt: "", matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineTrim = line.trim();
|
||||||
|
const lineLower = lineTrim.toLowerCase();
|
||||||
|
|
||||||
|
if (!lineTrim) {
|
||||||
|
return { state: currentState, prompt: "", matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Info-only: Remove last sheet notice (emitted by Argyll before writing .ti3 and exiting)
|
||||||
|
if (lineLower.includes("remove last sheet from table") || lineLower.includes("remove last sheet")) {
|
||||||
|
return {
|
||||||
|
state: currentState,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { isRemoveSheetNotice: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Info-only: Sheet read OK
|
||||||
|
const sheetOkMatch = lineTrim.match(/sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok/i);
|
||||||
|
if (sheetOkMatch) {
|
||||||
|
return {
|
||||||
|
state: currentState,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: {
|
||||||
|
sheetOk: true,
|
||||||
|
sheet: parseInt(sheetOkMatch[1], 10),
|
||||||
|
totalSheets: parseInt(sheetOkMatch[2], 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fiducial alignment prompt (XY table)
|
||||||
|
// e.g. "locate patch A1 with the sight," or "locate patch 1 with the sight"
|
||||||
|
const alignMatch = lineTrim.match(/locate\s+patch\s+([A-Za-z0-9_]+)\s+with\s+(?:the\s+)?sight/i);
|
||||||
|
if (alignMatch) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_ALIGN,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { patch: alignMatch[1] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lineLower.includes("locate patch") && lineLower.includes("sight")) {
|
||||||
|
const fallbackMatch = lineTrim.match(/locate\s+patch\s+([^\s,]+)/i);
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_ALIGN,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { patch: fallbackMatch ? fallbackMatch[1] : "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sheet placement prompt (XY table)
|
||||||
|
// e.g. "Please place sheet 1 of 1 on the table" or "Please remove previous sheet and place sheet 2 of 2 on the table"
|
||||||
|
const placeMatch = lineTrim.match(/place\s+sheet\s+(\d+)\s+of\s+(\d+)/i);
|
||||||
|
if (placeMatch) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_PLACE_SHEET,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: {
|
||||||
|
sheet: parseInt(placeMatch[1], 10),
|
||||||
|
totalSheets: parseInt(placeMatch[2], 10),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lineLower.includes("place sheet") || lineLower.includes("remove previous sheet")) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_PLACE_SHEET,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Continuation lines ("hit return to continue...", etc.)
|
||||||
|
// Real Argyll XY prompts are two lines:
|
||||||
|
// Line 1: "locate patch A1 with the sight,"
|
||||||
|
// Line 2: "then hit return to continue"
|
||||||
|
// When line 2 arrives, if we are in TABLE_PLACE_SHEET or TABLE_ALIGN, we MUST remain sticky in that table state!
|
||||||
|
const isContinuePrompt =
|
||||||
|
(lineLower.includes("hit return to continue") ||
|
||||||
|
lineLower.includes("then hit return to continue") ||
|
||||||
|
lineLower.includes("hit return to continue, esc or 'q' to give up")) &&
|
||||||
|
!lineLower.includes("use it anyway");
|
||||||
|
|
||||||
|
if (isContinuePrompt) {
|
||||||
|
if (currentState === STATE.TABLE_PLACE_SHEET) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_PLACE_SHEET,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { isContinuation: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (currentState === STATE.TABLE_ALIGN) {
|
||||||
|
return {
|
||||||
|
state: STATE.TABLE_ALIGN,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
meta: { isContinuation: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: STATE.PROMPT_CONTINUE,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Strip / measurement completed signals
|
||||||
|
if (
|
||||||
|
lineLower.includes("'d' if done") ||
|
||||||
|
lineLower.includes("'d' when done") ||
|
||||||
|
lineLower.includes("d if done") ||
|
||||||
|
lineLower.includes("d when done") ||
|
||||||
|
lineLower.includes("d to finish") ||
|
||||||
|
lineLower.includes("d to save") ||
|
||||||
|
lineLower.includes("all strips read") ||
|
||||||
|
lineLower.includes("all patches read") ||
|
||||||
|
lineLower.includes("done reading")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.ALL_STRIPS_READ,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Warning prompts (e.g. unexpected response, use it anyway)
|
||||||
|
if (
|
||||||
|
lineLower.includes("(warning)") ||
|
||||||
|
lineLower.includes("use it anyway") ||
|
||||||
|
lineLower.includes("seem to have read strip pass") ||
|
||||||
|
lineLower.includes("unexpected response")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.WARNING,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Calibration prompts
|
||||||
|
if (
|
||||||
|
((lineLower.includes("place") &&
|
||||||
|
(lineLower.includes("reference") ||
|
||||||
|
lineLower.includes("white") ||
|
||||||
|
lineLower.includes("calibrat") ||
|
||||||
|
lineLower.includes("standard"))) ||
|
||||||
|
lineLower.includes("hit any key to continue") ||
|
||||||
|
lineLower.includes("calibration")) &&
|
||||||
|
!lineLower.includes("place sheet") &&
|
||||||
|
!lineLower.includes("locate patch")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.CALIBRATING,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Ready to read / Strip trigger (handheld / strip readers)
|
||||||
|
if (
|
||||||
|
((lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) ||
|
||||||
|
lineLower.includes("ready to read") ||
|
||||||
|
(lineLower.includes("read") && lineLower.includes("strip") && lineLower.includes("key"))) &&
|
||||||
|
!lineLower.includes("all strips read")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.AWAITING_STRIP,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Reading / Scanning
|
||||||
|
if (
|
||||||
|
lineLower.includes("reading strip") ||
|
||||||
|
lineLower.includes("processing") ||
|
||||||
|
lineLower.includes("scanning") ||
|
||||||
|
lineLower.includes("reading sheet")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.READING,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Errors
|
||||||
|
if (
|
||||||
|
lineLower.includes("error") ||
|
||||||
|
lineLower.includes("too fast") ||
|
||||||
|
lineLower.includes("too slow") ||
|
||||||
|
lineLower.includes("misread") ||
|
||||||
|
lineLower.includes("failed to read")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
state: STATE.ERROR,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: currentState,
|
||||||
|
prompt: lineTrim,
|
||||||
|
matched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let currentState = STATE.IDLE;
|
let currentState = STATE.IDLE;
|
||||||
let currentProcessId = "";
|
let currentProcessId = "";
|
||||||
let measurementInProgress = false;
|
let measurementInProgress = false;
|
||||||
|
let xyTableDetected = false;
|
||||||
let currentPassIndex = 0;
|
let currentPassIndex = 0;
|
||||||
const recordedPasses = [];
|
const recordedPasses = [];
|
||||||
|
|
||||||
@@ -83,6 +312,82 @@ export function initChartread() {
|
|||||||
const passCounterBadge = document.getElementById("passCounterBadge");
|
const passCounterBadge = document.getElementById("passCounterBadge");
|
||||||
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
|
const btnMeasureAnotherSheet = document.getElementById("btnMeasureAnotherSheet");
|
||||||
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
|
const btnFinishAndAverage = document.getElementById("btnFinishAndAverage");
|
||||||
|
const xyTableHint = document.getElementById("xyTableHint");
|
||||||
|
const xyTablePanel = document.getElementById("xyTablePanel");
|
||||||
|
const xyTableActiveStepBadge = document.getElementById("xyTableActiveStepBadge");
|
||||||
|
const xyStepPlace = document.getElementById("xyStepPlace");
|
||||||
|
const xyStepAlign = document.getElementById("xyStepAlign");
|
||||||
|
const xyStepScan = document.getElementById("xyStepScan");
|
||||||
|
const xyStepRemove = document.getElementById("xyStepRemove");
|
||||||
|
|
||||||
|
function setXyTableVisible(visible) {
|
||||||
|
if (xyTableHint) {
|
||||||
|
if (visible) xyTableHint.classList.remove("hidden");
|
||||||
|
else xyTableHint.classList.add("hidden");
|
||||||
|
}
|
||||||
|
if (xyTablePanel) {
|
||||||
|
if (visible) xyTablePanel.classList.remove("hidden");
|
||||||
|
else xyTablePanel.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateXyTableSequence(phase) {
|
||||||
|
if (!xyTablePanel) return;
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ el: xyStepPlace, name: "place" },
|
||||||
|
{ el: xyStepAlign, name: "align" },
|
||||||
|
{ el: xyStepScan, name: "scan" },
|
||||||
|
{ el: xyStepRemove, name: "remove" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const phaseOrder = ["place", "align", "scan", "remove", "done"];
|
||||||
|
const targetIndex = phaseOrder.indexOf(phase);
|
||||||
|
|
||||||
|
steps.forEach((step, idx) => {
|
||||||
|
if (!step.el) return;
|
||||||
|
step.el.classList.remove("active", "completed");
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
if (idx < targetIndex) {
|
||||||
|
step.el.classList.add("completed");
|
||||||
|
} else if (idx === targetIndex && phase !== "done") {
|
||||||
|
step.el.classList.add("active");
|
||||||
|
} else if (phase === "done") {
|
||||||
|
step.el.classList.add("completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (xyTableActiveStepBadge) {
|
||||||
|
xyTableActiveStepBadge.className = "status-badge";
|
||||||
|
switch (phase) {
|
||||||
|
case "place":
|
||||||
|
xyTableActiveStepBadge.textContent = "Step 1: Place Sheet";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||||
|
break;
|
||||||
|
case "align":
|
||||||
|
xyTableActiveStepBadge.textContent = "Step 2: Align Patches";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||||
|
break;
|
||||||
|
case "scan":
|
||||||
|
xyTableActiveStepBadge.textContent = "Step 3: Scanning";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
xyTableActiveStepBadge.textContent = "Step 4: Remove Sheet";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-primary");
|
||||||
|
break;
|
||||||
|
case "done":
|
||||||
|
xyTableActiveStepBadge.textContent = "Complete";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-good");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
xyTableActiveStepBadge.textContent = "Standby";
|
||||||
|
xyTableActiveStepBadge.classList.add("badge-idle");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setMeasurementBusy(busy) {
|
function setMeasurementBusy(busy) {
|
||||||
measurementInProgress = busy;
|
measurementInProgress = busy;
|
||||||
@@ -182,7 +487,13 @@ export function initChartread() {
|
|||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
// Port value for -c switch. If port is 1 or auto, empty string leaves -c omitted for default port
|
// Port value for -c switch. If port is 1 or auto, empty string leaves -c omitted for default port
|
||||||
opt.value = inst.port && inst.port !== "1" ? inst.port : "";
|
opt.value = inst.port && inst.port !== "1" ? inst.port : "";
|
||||||
|
const isXy = /spectro\s?scan|i1io/i.test(inst.name) || /spectro\s?scan|i1io/i.test(inst.type);
|
||||||
|
if (isXy) {
|
||||||
|
opt.dataset.xy = "1";
|
||||||
|
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""} · XY Table`;
|
||||||
|
} else {
|
||||||
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""}`;
|
opt.textContent = `${inst.type || inst.name}${inst.port ? ` (Port ${inst.port})` : ""}`;
|
||||||
|
}
|
||||||
instrumentSelect.appendChild(opt);
|
instrumentSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
instrumentSelect.value = "";
|
instrumentSelect.value = "";
|
||||||
@@ -202,6 +513,21 @@ export function initChartread() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (instrumentSelect) {
|
||||||
|
instrumentSelect.addEventListener("change", () => {
|
||||||
|
const selectedOpt = instrumentSelect.selectedOptions && instrumentSelect.selectedOptions[0];
|
||||||
|
const isXy = Boolean(selectedOpt && selectedOpt.dataset && selectedOpt.dataset.xy === "1");
|
||||||
|
if (isXy) {
|
||||||
|
xyTableDetected = true;
|
||||||
|
setXyTableVisible(true);
|
||||||
|
updateXyTableSequence("standby");
|
||||||
|
} else if (!measurementInProgress) {
|
||||||
|
xyTableDetected = false;
|
||||||
|
setXyTableVisible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setState(newState) {
|
function setState(newState) {
|
||||||
currentState = newState;
|
currentState = newState;
|
||||||
if (stateLabel) stateLabel.textContent = newState;
|
if (stateLabel) stateLabel.textContent = newState;
|
||||||
@@ -287,6 +613,22 @@ export function initChartread() {
|
|||||||
}
|
}
|
||||||
if (btnCancel) btnCancel.classList.remove("hidden");
|
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||||
break;
|
break;
|
||||||
|
case STATE.TABLE_PLACE_SHEET:
|
||||||
|
if (btnAccept) {
|
||||||
|
btnAccept.disabled = false;
|
||||||
|
btnAccept.textContent = "✓ Sheet Placed — Continue";
|
||||||
|
btnAccept.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
|
case STATE.TABLE_ALIGN:
|
||||||
|
if (btnAccept) {
|
||||||
|
btnAccept.disabled = false;
|
||||||
|
btnAccept.textContent = "✓ Aligned — Continue";
|
||||||
|
btnAccept.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
if (btnCancel) btnCancel.classList.remove("hidden");
|
||||||
|
break;
|
||||||
case STATE.ERROR:
|
case STATE.ERROR:
|
||||||
if (btnRetry) {
|
if (btnRetry) {
|
||||||
btnRetry.disabled = false;
|
btnRetry.disabled = false;
|
||||||
@@ -339,6 +681,15 @@ export function initChartread() {
|
|||||||
setState(STATE.CALIBRATING);
|
setState(STATE.CALIBRATING);
|
||||||
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
setPrompt("Starting chartread... waiting for instrument calibration prompt.");
|
||||||
|
|
||||||
|
const selectedOpt = instrumentSelect && instrumentSelect.selectedOptions && instrumentSelect.selectedOptions[0];
|
||||||
|
if (selectedOpt && selectedOpt.dataset && selectedOpt.dataset.xy === "1") {
|
||||||
|
xyTableDetected = true;
|
||||||
|
setXyTableVisible(true);
|
||||||
|
updateXyTableSequence("standby");
|
||||||
|
} else {
|
||||||
|
xyTableDetected = false;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedPort = instrumentSelect && instrumentSelect.value ? instrumentSelect.value : null;
|
const selectedPort = instrumentSelect && instrumentSelect.value ? instrumentSelect.value : null;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@@ -365,69 +716,74 @@ export function initChartread() {
|
|||||||
logPre.textContent += line + "\n";
|
logPre.textContent += line + "\n";
|
||||||
logPre.scrollTop = logPre.scrollHeight;
|
logPre.scrollTop = logPre.scrollHeight;
|
||||||
|
|
||||||
// Parse prompts for state transitions
|
const classified = classifyChartreadLine(line, currentState);
|
||||||
const lineLower = line.toLowerCase();
|
|
||||||
|
|
||||||
|
if (classified.matched) {
|
||||||
if (
|
if (
|
||||||
lineLower.includes("'d' if done") ||
|
classified.state === STATE.TABLE_PLACE_SHEET ||
|
||||||
lineLower.includes("'d' when done") ||
|
classified.state === STATE.TABLE_ALIGN ||
|
||||||
lineLower.includes("d if done") ||
|
(classified.meta && (classified.meta.isRemoveSheetNotice || classified.meta.sheetOk))
|
||||||
lineLower.includes("d when done") ||
|
|
||||||
lineLower.includes("d to finish") ||
|
|
||||||
lineLower.includes("d to save") ||
|
|
||||||
lineLower.includes("all strips read") ||
|
|
||||||
lineLower.includes("all patches read") ||
|
|
||||||
lineLower.includes("done reading")
|
|
||||||
) {
|
|
||||||
setState(STATE.ALL_STRIPS_READ);
|
|
||||||
setPrompt(`🎉 ${line.trim()} — Click 'Done & Save .ti3' to save.`);
|
|
||||||
} else if (
|
|
||||||
lineLower.includes("(warning)") ||
|
|
||||||
lineLower.includes("use it anyway") ||
|
|
||||||
lineLower.includes("seem to have read strip pass") ||
|
|
||||||
lineLower.includes("unexpected response") ||
|
|
||||||
lineLower.includes("hit return to use it anyway")
|
|
||||||
) {
|
) {
|
||||||
|
if (!xyTableDetected) {
|
||||||
|
xyTableDetected = true;
|
||||||
|
setXyTableVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xyTableDetected) {
|
||||||
|
if (classified.state === STATE.TABLE_PLACE_SHEET) {
|
||||||
|
updateXyTableSequence("place");
|
||||||
|
} else if (classified.state === STATE.TABLE_ALIGN) {
|
||||||
|
updateXyTableSequence("align");
|
||||||
|
} else if (classified.state === STATE.READING) {
|
||||||
|
updateXyTableSequence("scan");
|
||||||
|
} else if (classified.meta && classified.meta.isRemoveSheetNotice) {
|
||||||
|
updateXyTableSequence("remove");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classified.state !== currentState) {
|
||||||
|
setState(classified.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classified.state === STATE.TABLE_PLACE_SHEET) {
|
||||||
|
const sheetInfo = (classified.meta && classified.meta.sheet && classified.meta.totalSheets)
|
||||||
|
? ` (Sheet ${classified.meta.sheet} of ${classified.meta.totalSheets})`
|
||||||
|
: "";
|
||||||
|
setPrompt(`📋 ${classified.prompt}${sheetInfo}`);
|
||||||
|
} else if (classified.state === STATE.TABLE_ALIGN) {
|
||||||
|
const patchInfo = (classified.meta && classified.meta.patch)
|
||||||
|
? ` [Patch ${classified.meta.patch}]`
|
||||||
|
: "";
|
||||||
|
setPrompt(`🎯 ${classified.prompt}${patchInfo}`);
|
||||||
|
} else if (classified.meta && classified.meta.isRemoveSheetNotice) {
|
||||||
|
setPrompt(`ℹ️ ${classified.prompt}`);
|
||||||
|
} else if (classified.meta && classified.meta.sheetOk) {
|
||||||
|
setPrompt(`✅ ${classified.prompt}`);
|
||||||
|
} else if (classified.state === STATE.ALL_STRIPS_READ) {
|
||||||
|
setPrompt(`🎉 ${classified.prompt} — Click 'Done & Save .ti3' to save.`);
|
||||||
|
} else if (classified.state === STATE.WARNING) {
|
||||||
const previousPrompt = promptText ? promptText.textContent.trim() : "";
|
const previousPrompt = promptText ? promptText.textContent.trim() : "";
|
||||||
|
const lineLower = line.toLowerCase();
|
||||||
const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway");
|
const isContinuationPrompt = lineLower.includes("hit return to use it anyway") || lineLower.includes("use it anyway");
|
||||||
setState(STATE.WARNING);
|
|
||||||
if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) {
|
if (currentState === STATE.WARNING && isContinuationPrompt && previousPrompt && !previousPrompt.includes(line.trim())) {
|
||||||
setPrompt(`${previousPrompt}\n${line.trim()}`);
|
setPrompt(`${previousPrompt}\n${line.trim()}`);
|
||||||
} else {
|
} else {
|
||||||
setPrompt(line.trim());
|
setPrompt(classified.prompt);
|
||||||
|
}
|
||||||
|
} else if (classified.state === STATE.ERROR) {
|
||||||
|
setPrompt("⚠️ " + classified.prompt);
|
||||||
|
} else {
|
||||||
|
setPrompt(classified.prompt);
|
||||||
}
|
}
|
||||||
} else if (
|
|
||||||
lineLower.includes("place sheet") ||
|
|
||||||
lineLower.includes("remove previous sheet") ||
|
|
||||||
(lineLower.includes("hit return to continue") && !lineLower.includes("use it anyway"))
|
|
||||||
) {
|
|
||||||
setState(STATE.PROMPT_CONTINUE);
|
|
||||||
setPrompt(line.trim());
|
|
||||||
} else if (
|
|
||||||
(lineLower.includes("place") && (lineLower.includes("reference") || lineLower.includes("white") || lineLower.includes("calibrat") || lineLower.includes("standard"))) ||
|
|
||||||
lineLower.includes("hit any key to continue") ||
|
|
||||||
lineLower.includes("calibration")
|
|
||||||
) {
|
|
||||||
setState(STATE.CALIBRATING);
|
|
||||||
setPrompt(line.trim());
|
|
||||||
} else if (
|
|
||||||
(lineLower.includes("hit") && lineLower.includes("read") && lineLower.includes("strip")) ||
|
|
||||||
lineLower.includes("ready to read") ||
|
|
||||||
(lineLower.includes("read") && lineLower.includes("strip") && lineLower.includes("key"))
|
|
||||||
) {
|
|
||||||
setState(STATE.AWAITING_STRIP);
|
|
||||||
setPrompt(line.trim());
|
|
||||||
} else if (lineLower.includes("reading strip") || lineLower.includes("processing")) {
|
|
||||||
setState(STATE.READING);
|
|
||||||
setPrompt(line.trim());
|
|
||||||
} else if (lineLower.includes("error") || lineLower.includes("too fast") || lineLower.includes("too slow") || lineLower.includes("misread") || lineLower.includes("failed to read")) {
|
|
||||||
setState(STATE.ERROR);
|
|
||||||
setPrompt("⚠️ " + line.trim());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let lastStderrLine = "";
|
||||||
|
|
||||||
const unlistenStderr = await listen("process:stderr", (event) => {
|
const unlistenStderr = await listen("process:stderr", (event) => {
|
||||||
if (event.payload.id === currentProcessId && event.payload.line) {
|
if (event.payload.id === currentProcessId && event.payload.line) {
|
||||||
|
lastStderrLine = event.payload.line;
|
||||||
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
logPre.textContent += "ERR: " + event.payload.line + "\n";
|
||||||
logPre.scrollTop = logPre.scrollHeight;
|
logPre.scrollTop = logPre.scrollHeight;
|
||||||
}
|
}
|
||||||
@@ -441,6 +797,9 @@ export function initChartread() {
|
|||||||
stopSwatchListener();
|
stopSwatchListener();
|
||||||
|
|
||||||
if (event.payload.code === 0) {
|
if (event.payload.code === 0) {
|
||||||
|
if (xyTableDetected) {
|
||||||
|
updateXyTableSequence("done");
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const passIndex = currentPassIndex + 1;
|
const passIndex = currentPassIndex + 1;
|
||||||
const filename = await invoke("snapshot_ti3", {
|
const filename = await invoke("snapshot_ti3", {
|
||||||
@@ -471,8 +830,10 @@ export function initChartread() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(STATE.FINISHED);
|
setState(STATE.FINISHED);
|
||||||
setPrompt(`❌ chartread exited with code ${event.payload.code}.`);
|
if (logContainer) logContainer.open = true;
|
||||||
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.\n`;
|
const errDetail = lastStderrLine ? ` (${lastStderrLine})` : "";
|
||||||
|
setPrompt(`❌ chartread exited with code ${event.payload.code}.${errDetail}`);
|
||||||
|
logPre.textContent += `\n[ERROR] chartread exited with code ${event.payload.code}.${errDetail}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMeasurementBusy(false);
|
setMeasurementBusy(false);
|
||||||
@@ -610,8 +971,12 @@ export function initChartread() {
|
|||||||
try {
|
try {
|
||||||
btnAccept.disabled = true;
|
btnAccept.disabled = true;
|
||||||
await invoke("send_stdin", { id: currentProcessId, input: "\n" });
|
await invoke("send_stdin", { id: currentProcessId, input: "\n" });
|
||||||
|
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN) {
|
||||||
|
setPrompt("Continuing XY table sequence...");
|
||||||
|
} else {
|
||||||
setState(STATE.READING);
|
setState(STATE.READING);
|
||||||
setPrompt("Accepted. Processing...");
|
setPrompt("Accepted. Processing...");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("send_stdin error:", e);
|
console.error("send_stdin error:", e);
|
||||||
btnAccept.disabled = false;
|
btnAccept.disabled = false;
|
||||||
@@ -677,14 +1042,27 @@ export function initChartread() {
|
|||||||
if (btnCancel) {
|
if (btnCancel) {
|
||||||
btnCancel.addEventListener("click", async () => {
|
btnCancel.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
|
btnCancel.disabled = true;
|
||||||
|
if (currentState === STATE.TABLE_PLACE_SHEET || currentState === STATE.TABLE_ALIGN || xyTableDetected) {
|
||||||
|
// For XY table states, send 'q\n' first to allow the table to park its measurement head gracefully
|
||||||
|
try {
|
||||||
|
await invoke("send_stdin", { id: currentProcessId, input: "q\n" });
|
||||||
|
} catch (_) {}
|
||||||
|
// Brief pause before kill to allow graceful parking
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
await invoke("kill_process", { id: currentProcessId });
|
await invoke("kill_process", { id: currentProcessId });
|
||||||
stopSwatchListener();
|
stopSwatchListener();
|
||||||
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
|
setState(recordedPasses.length > 0 ? STATE.FINISHED : STATE.IDLE);
|
||||||
setPrompt("Measurement cancelled.");
|
setPrompt("Measurement cancelled.");
|
||||||
|
if (xyTableDetected) {
|
||||||
|
updateXyTableSequence("standby");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("kill_process error:", e);
|
console.error("kill_process error:", e);
|
||||||
setPrompt(`Cancel failed: ${e}`);
|
setPrompt(`Cancel failed: ${e}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
btnCancel.disabled = false;
|
||||||
setMeasurementBusy(false);
|
setMeasurementBusy(false);
|
||||||
stopSwatchListener();
|
stopSwatchListener();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Unit & console tests for chartread.js stdout classifier and state machine transitions.
|
||||||
|
// Can be run in browser devtools console:
|
||||||
|
// import('./chartread.test.js').then(m => m.runAll())
|
||||||
|
// Or in Node:
|
||||||
|
// node src/js/chartread.test.js
|
||||||
|
|
||||||
|
// Node environment polyfill for browser globals
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
globalThis.window = {
|
||||||
|
__TAURI__: {
|
||||||
|
core: { invoke: () => Promise.resolve() },
|
||||||
|
event: {
|
||||||
|
listen: () => Promise.resolve(() => {}),
|
||||||
|
emit: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: () => {},
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => ({
|
||||||
|
classList: { add: () => {}, remove: () => {} },
|
||||||
|
style: {},
|
||||||
|
appendChild: () => {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { STATE, classifyChartreadLine } = await import('./chartread.js');
|
||||||
|
|
||||||
|
export function runAll() {
|
||||||
|
console.group('Chartread Classifier & XY Table Tests');
|
||||||
|
let passed = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
function assert(actual, expected, message) {
|
||||||
|
total++;
|
||||||
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
|
if (ok) {
|
||||||
|
console.log('PASS:', message);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.error('FAIL:', message, '\nExpected:', expected, '\nGot:', actual);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. XY Sheet Placement prompts
|
||||||
|
const place1 = classifyChartreadLine("Please place sheet 1 of 1 on the table", STATE.CALIBRATING);
|
||||||
|
assert(place1.state, STATE.TABLE_PLACE_SHEET, 'sheet 1 of 1 state TABLE_PLACE_SHEET');
|
||||||
|
assert(place1.matched, true, 'sheet 1 of 1 matched');
|
||||||
|
assert(place1.meta?.sheet, 1, 'sheet 1 of 1 sheet number 1');
|
||||||
|
assert(place1.meta?.totalSheets, 1, 'sheet 1 of 1 total sheets 1');
|
||||||
|
|
||||||
|
const place2 = classifyChartreadLine("Please remove previous sheet and place sheet 2 of 2 on the table", STATE.READING);
|
||||||
|
assert(place2.state, STATE.TABLE_PLACE_SHEET, 'sheet 2 of 2 state TABLE_PLACE_SHEET');
|
||||||
|
assert(place2.meta?.sheet, 2, 'sheet 2 of 2 sheet number 2');
|
||||||
|
assert(place2.meta?.totalSheets, 2, 'sheet 2 of 2 total sheets 2');
|
||||||
|
|
||||||
|
const placeGeneric = classifyChartreadLine("place sheet on table", STATE.IDLE);
|
||||||
|
assert(placeGeneric.state, STATE.TABLE_PLACE_SHEET, 'generic place sheet state TABLE_PLACE_SHEET');
|
||||||
|
|
||||||
|
// 2. XY Fiducial patch alignment prompts
|
||||||
|
const fid1 = classifyChartreadLine("locate patch A1 with the sight,", STATE.TABLE_PLACE_SHEET);
|
||||||
|
assert(fid1.state, STATE.TABLE_ALIGN, 'locate patch A1 state TABLE_ALIGN');
|
||||||
|
assert(fid1.matched, true, 'locate patch A1 matched');
|
||||||
|
assert(fid1.meta?.patch, "A1", 'locate patch A1 meta patch');
|
||||||
|
|
||||||
|
const fid2 = classifyChartreadLine("locate patch B24 with the sight", STATE.TABLE_ALIGN);
|
||||||
|
assert(fid2.state, STATE.TABLE_ALIGN, 'locate patch B24 state TABLE_ALIGN');
|
||||||
|
assert(fid2.meta?.patch, "B24", 'locate patch B24 meta patch');
|
||||||
|
|
||||||
|
const fid3 = classifyChartreadLine("locate patch 1 with sight", STATE.TABLE_ALIGN);
|
||||||
|
assert(fid3.state, STATE.TABLE_ALIGN, 'locate patch 1 state TABLE_ALIGN');
|
||||||
|
assert(fid3.meta?.patch, "1", 'locate patch 1 meta patch');
|
||||||
|
|
||||||
|
// 3. Two-line prompt sticky transitions
|
||||||
|
// A continuation line arriving while in TABLE_PLACE_SHEET must remain in TABLE_PLACE_SHEET
|
||||||
|
const contSheet1 = classifyChartreadLine("hit return to continue, Esc or 'q' to give up", STATE.TABLE_PLACE_SHEET);
|
||||||
|
assert(contSheet1.state, STATE.TABLE_PLACE_SHEET, 'sheet continuation remains in TABLE_PLACE_SHEET');
|
||||||
|
assert(contSheet1.meta?.isContinuation, true, 'sheet continuation flag set');
|
||||||
|
|
||||||
|
const contSheet2 = classifyChartreadLine("then hit return to continue", STATE.TABLE_PLACE_SHEET);
|
||||||
|
assert(contSheet2.state, STATE.TABLE_PLACE_SHEET, 'then hit return remains in TABLE_PLACE_SHEET');
|
||||||
|
|
||||||
|
// A continuation line arriving while in TABLE_ALIGN must remain in TABLE_ALIGN
|
||||||
|
const contAlign1 = classifyChartreadLine("then hit return to continue", STATE.TABLE_ALIGN);
|
||||||
|
assert(contAlign1.state, STATE.TABLE_ALIGN, 'align continuation remains in TABLE_ALIGN');
|
||||||
|
assert(contAlign1.meta?.isContinuation, true, 'align continuation flag set');
|
||||||
|
|
||||||
|
const contAlign2 = classifyChartreadLine("hit return to continue, Esc or 'q' to give up", STATE.TABLE_ALIGN);
|
||||||
|
assert(contAlign2.state, STATE.TABLE_ALIGN, 'align esc/q continuation remains in TABLE_ALIGN');
|
||||||
|
|
||||||
|
// Continuation prompt in strip mode becomes generic PROMPT_CONTINUE
|
||||||
|
const contStrip = classifyChartreadLine("hit return to continue", STATE.READING);
|
||||||
|
assert(contStrip.state, STATE.PROMPT_CONTINUE, 'strip continue transitions to PROMPT_CONTINUE');
|
||||||
|
|
||||||
|
// 4. Final sheet removal notice (Info-only, does not change state or trigger stdin prompt)
|
||||||
|
const removeSheet = classifyChartreadLine("Please remove last sheet from table", STATE.READING);
|
||||||
|
assert(removeSheet.state, STATE.READING, 'remove last sheet notice preserves currentState');
|
||||||
|
assert(removeSheet.matched, true, 'remove last sheet notice is matched');
|
||||||
|
assert(removeSheet.meta?.isRemoveSheetNotice, true, 'remove last sheet notice flag set');
|
||||||
|
|
||||||
|
// 5. Sheet read OK notices
|
||||||
|
const sheetOk = classifyChartreadLine("Sheet 1 of 1 read OK", STATE.READING);
|
||||||
|
assert(sheetOk.matched, true, 'sheet read OK matched');
|
||||||
|
assert(sheetOk.meta?.sheetOk, true, 'sheet read OK flag');
|
||||||
|
assert(sheetOk.meta?.sheet, 1, 'sheet read OK sheet 1');
|
||||||
|
assert(sheetOk.meta?.totalSheets, 1, 'sheet read OK totalSheets 1');
|
||||||
|
|
||||||
|
// 6. Strip mode regressions
|
||||||
|
const calib = classifyChartreadLine("Place instrument on calibration tile and hit [Space] to calibrate.", STATE.IDLE);
|
||||||
|
assert(calib.state, STATE.CALIBRATING, 'strip calibration prompt transitions to CALIBRATING');
|
||||||
|
|
||||||
|
const awaitStrip = classifyChartreadLine("Hit [Space] to read strip A (or 's' to skip).", STATE.CALIBRATING);
|
||||||
|
assert(awaitStrip.state, STATE.AWAITING_STRIP, 'strip prompt transitions to AWAITING_STRIP');
|
||||||
|
|
||||||
|
const readingStrip = classifyChartreadLine("Reading strip A...", STATE.AWAITING_STRIP);
|
||||||
|
assert(readingStrip.state, STATE.READING, 'reading strip transitions to READING');
|
||||||
|
|
||||||
|
const readingSheet = classifyChartreadLine("Reading sheet 1...", STATE.TABLE_ALIGN);
|
||||||
|
assert(readingSheet.state, STATE.READING, 'reading sheet transitions to READING');
|
||||||
|
|
||||||
|
const warning = classifyChartreadLine("Warning: unexpected response from instrument", STATE.READING);
|
||||||
|
assert(warning.state, STATE.WARNING, 'unexpected response transitions to WARNING');
|
||||||
|
|
||||||
|
const warnAnyway = classifyChartreadLine("Hit return to use it anyway", STATE.WARNING);
|
||||||
|
assert(warnAnyway.state, STATE.WARNING, 'use it anyway stays in WARNING');
|
||||||
|
|
||||||
|
const allStripsDone = classifyChartreadLine("All strips read. Hit 'd' when done", STATE.READING);
|
||||||
|
assert(allStripsDone.state, STATE.ALL_STRIPS_READ, 'all strips read transitions to ALL_STRIPS_READ');
|
||||||
|
|
||||||
|
const err = classifyChartreadLine("Fatal error: instrument communication failed", STATE.READING);
|
||||||
|
assert(err.state, STATE.ERROR, 'instrument communication failed transitions to ERROR');
|
||||||
|
|
||||||
|
const unrecognized = classifyChartreadLine("some debug log output [1234]", STATE.READING);
|
||||||
|
assert(unrecognized.state, STATE.READING, 'unrecognized line preserves currentState');
|
||||||
|
assert(unrecognized.matched, false, 'unrecognized line matched is false');
|
||||||
|
|
||||||
|
console.log(`\nResults: ${passed} / ${total} tests passed.`);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
if (passed !== total) {
|
||||||
|
throw new Error(`Chartread tests failed: ${total - passed} failure(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-run if executed in Node.js
|
||||||
|
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('chartread.test.js')) {
|
||||||
|
runAll();
|
||||||
|
}
|
||||||
+2
-2
@@ -54,8 +54,8 @@ export function initColprof() {
|
|||||||
if (btnBrowseCustomSp && colprofCustomSpPath) {
|
if (btnBrowseCustomSp && colprofCustomSpPath) {
|
||||||
btnBrowseCustomSp.addEventListener("click", async () => {
|
btnBrowseCustomSp.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const selected = await window.__TAURI__.dialog.open({
|
const selected = await invoke("select_spectrum_file", {
|
||||||
filters: [{ name: 'Spectrum', extensions: ['sp'] }]
|
defaultDir: chartreadCwd || wizardState.cwd || null,
|
||||||
});
|
});
|
||||||
if (selected) {
|
if (selected) {
|
||||||
colprofCustomSpPath.value = selected;
|
colprofCustomSpPath.value = selected;
|
||||||
|
|||||||
+175
-18
@@ -1,8 +1,7 @@
|
|||||||
import { computeQuickHull } from "./vendor/quickhull.js";
|
import { computeQuickHull } from "./vendor/quickhull.js";
|
||||||
import { labToSrgb } from "./color_convert.js";
|
import { labToSrgb } from "./color_convert.js";
|
||||||
|
|
||||||
const { invoke } = window.__TAURI__.core;
|
const invoke = typeof window !== 'undefined' && window.__TAURI__?.core?.invoke ? window.__TAURI__.core.invoke : null;
|
||||||
const { listen } = window.__TAURI__.event;
|
|
||||||
|
|
||||||
let scene, camera, renderer, labelRenderer, controls;
|
let scene, camera, renderer, labelRenderer, controls;
|
||||||
let currentProfileMesh = null;
|
let currentProfileMesh = null;
|
||||||
@@ -204,30 +203,80 @@ function _line(from, to, material) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Parse .gam file text → { vertices: [[L,a,b],...], faces: [[v0,v1,v2],...] }
|
/**
|
||||||
|
* Parse an Argyll `.gam` text file into vertex and face arrays.
|
||||||
|
*
|
||||||
|
* Argyll `.gam` files contain a header followed by one or more `BEGIN_DATA`
|
||||||
|
* ... `END_DATA` blocks. The first data block is a vertex list
|
||||||
|
* (index L a b); subsequent blocks contain triangle face indices (v0 v1 v2).
|
||||||
|
* Blank lines and hash `#` comments outside data blocks are ignored.
|
||||||
|
*
|
||||||
|
* @param {string} text - Raw contents of the .gam file.
|
||||||
|
* @returns {{ vertices: number[][], faces: number[][], warnings: string[] }}
|
||||||
|
*/
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
export function parseGamutFile(text) {
|
export function parseGamutFile(text) {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const vertices = [];
|
const vertices = [];
|
||||||
const faces = [];
|
const faces = [];
|
||||||
|
const warnings = [];
|
||||||
let dataStarted = false;
|
let dataStarted = false;
|
||||||
let dataBlock = 0; // 1 = vertices section, 2 = faces section
|
let dataBlock = 0; // 1 = vertices section, 2+ = faces sections
|
||||||
|
|
||||||
for (const line of lines) {
|
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
||||||
const trimmed = line.trim();
|
const raw = lines[lineIdx];
|
||||||
if (trimmed === 'BEGIN_DATA') { dataBlock++; dataStarted = true; continue; }
|
const trimmed = raw.replace(/#.*$/, '').trim(); // strip inline comments
|
||||||
if (trimmed === 'END_DATA') { dataStarted = false; continue; }
|
if (trimmed === '') continue;
|
||||||
|
|
||||||
|
if (trimmed.toUpperCase() === 'BEGIN_DATA') {
|
||||||
|
dataBlock++;
|
||||||
|
dataStarted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.toUpperCase() === 'END_DATA') {
|
||||||
|
dataStarted = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!dataStarted) continue;
|
if (!dataStarted) continue;
|
||||||
|
|
||||||
const parts = trimmed.split(/\s+/).map(Number);
|
const parts = trimmed.split(/\s+/).map(Number);
|
||||||
if (dataBlock === 1 && parts.length >= 4) {
|
const allNumeric = parts.every(n => !Number.isNaN(n));
|
||||||
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
|
if (!allNumeric) {
|
||||||
} else if (dataBlock === 2 && parts.length >= 3) {
|
warnings.push(`Skipping non-numeric data at line ${lineIdx + 1}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataBlock === 1) {
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
// Vertex format: index L a b (index is usually ignored)
|
||||||
|
const [_, L, a, b] = parts;
|
||||||
|
if (L < 0 || L > 100 || Math.abs(a) > 128 || Math.abs(b) > 128) {
|
||||||
|
warnings.push(`Vertex at line ${lineIdx + 1} is outside plausible CIELAB bounds: L=${L}, a=${a}, b=${b}`);
|
||||||
|
}
|
||||||
|
vertices.push([L, a, b]);
|
||||||
|
} else {
|
||||||
|
warnings.push(`Vertex data at line ${lineIdx + 1} has only ${parts.length} values`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Face data can appear in multiple blocks (some .gam files use a
|
||||||
|
// separate DATA block per surface type or per convex-hull section).
|
||||||
|
if (parts.length >= 3) {
|
||||||
faces.push([parts[0], parts[1], parts[2]]);
|
faces.push([parts[0], parts[1], parts[2]]);
|
||||||
|
} else {
|
||||||
|
warnings.push(`Face data at line ${lineIdx + 1} has only ${parts.length} values`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { vertices, faces };
|
if (vertices.length > 0 && faces.length === 0) {
|
||||||
|
warnings.push(`Parsed ${vertices.length} vertices but no faces; will compute convex hull on the fly.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataBlock === 0) {
|
||||||
|
warnings.push('No BEGIN_DATA blocks found; file may be empty or not a valid Argyll .gam file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vertices, faces, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -304,7 +353,10 @@ function _renderSrgbReference(text, previousGroup) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { vertices, faces } = parseGamutFile(text);
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
console.warn('Gamut parser warnings:', warnings.join('\n'));
|
||||||
|
}
|
||||||
const built = _buildGeometry(vertices, faces);
|
const built = _buildGeometry(vertices, faces);
|
||||||
if (!built) return null;
|
if (!built) return null;
|
||||||
|
|
||||||
@@ -348,7 +400,10 @@ function _renderProfileGamut(text, previousMesh) {
|
|||||||
if (previousMesh.material) previousMesh.material.dispose();
|
if (previousMesh.material) previousMesh.material.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { vertices, faces } = parseGamutFile(text);
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
console.warn('Gamut parser warnings:', warnings.join('\n'));
|
||||||
|
}
|
||||||
const built = _buildGeometry(vertices, faces);
|
const built = _buildGeometry(vertices, faces);
|
||||||
if (!built) return null;
|
if (!built) return null;
|
||||||
|
|
||||||
@@ -383,7 +438,77 @@ function _renderProfileGamut(text, previousMesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Public: load bundled sRGB reference gamut
|
// Camera & view controls
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Reset the camera to its default home position and orientation.
|
||||||
|
* Also resets orbit controls target to the centre of the CIELAB volume.
|
||||||
|
*/
|
||||||
|
export function resetCamera() {
|
||||||
|
if (!camera || !controls) return;
|
||||||
|
camera.position.set(180, 120, 180);
|
||||||
|
camera.lookAt(0, 50, 0);
|
||||||
|
controls.target.set(0, 50, 0);
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the opacity of the rendered profile gamut surface (0–1).
|
||||||
|
* @param {number} opacity
|
||||||
|
*/
|
||||||
|
export function setProfileOpacity(opacity) {
|
||||||
|
if (currentProfileMesh && currentProfileMesh.material) {
|
||||||
|
currentProfileMesh.material.opacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
currentProfileMesh.material.transparent = opacity < 1;
|
||||||
|
currentProfileMesh.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the opacity of the sRGB reference wireframe and fill (0–1).
|
||||||
|
* @param {number} opacity
|
||||||
|
*/
|
||||||
|
export function setSrgbReferenceOpacity(opacity) {
|
||||||
|
if (sRgbGroup) {
|
||||||
|
sRgbGroup.traverse((child) => {
|
||||||
|
if (child.material) {
|
||||||
|
child.material.opacity = child.material.opacity >= 0.5
|
||||||
|
? Math.max(0.05, Math.min(1, opacity))
|
||||||
|
: Math.max(0.02, Math.min(0.2, opacity * 0.2));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the opacity of the CIELAB axis scaffold (0–1).
|
||||||
|
* @param {number} opacity
|
||||||
|
*/
|
||||||
|
export function setAxisOpacity(opacity) {
|
||||||
|
if (axisScaffoldGroup) {
|
||||||
|
axisScaffoldGroup.traverse((child) => {
|
||||||
|
if (child.material) {
|
||||||
|
child.material.opacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard shortcuts for the gamut viewer.
|
||||||
|
* @param {KeyboardEvent} e
|
||||||
|
*/
|
||||||
|
function _onKeyDown(e) {
|
||||||
|
if (e.key === 'r' || e.key === 'R') {
|
||||||
|
resetCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Load and render the bundled sRGB reference gamut.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
export async function loadSrgbReferenceGamut() {
|
export async function loadSrgbReferenceGamut() {
|
||||||
try {
|
try {
|
||||||
@@ -398,7 +523,11 @@ export async function loadSrgbReferenceGamut() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Public: load and render a profile gamut from a .gam file path
|
/**
|
||||||
|
* Load and render a profile gamut from a `.gam` file path.
|
||||||
|
* @param {string} gamFilePath - Absolute path to the gamut file.
|
||||||
|
* @returns {Promise<THREE.Mesh|null>}
|
||||||
|
*/
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
export async function loadGamutMesh(gamFilePath) {
|
export async function loadGamutMesh(gamFilePath) {
|
||||||
try {
|
try {
|
||||||
@@ -428,7 +557,7 @@ export function toggleAxes(visible) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Wire legend toggle checkboxes to the toggle functions
|
// Wire legend toggle checkboxes, opacity sliders, and reset button
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
function _wireToggles() {
|
function _wireToggles() {
|
||||||
const bindings = [
|
const bindings = [
|
||||||
@@ -440,4 +569,32 @@ function _wireToggles() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.addEventListener('change', (e) => fn(e.target.checked));
|
if (el) el.addEventListener('change', (e) => fn(e.target.checked));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetBtn = document.getElementById('btnGamutResetCamera');
|
||||||
|
if (resetBtn) resetBtn.addEventListener('click', resetCamera);
|
||||||
|
|
||||||
|
const profileOpacity = document.getElementById('rngProfileOpacity');
|
||||||
|
if (profileOpacity) {
|
||||||
|
profileOpacity.addEventListener('input', (e) => setProfileOpacity(parseFloat(e.target.value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const srgbOpacity = document.getElementById('rngSrgbOpacity');
|
||||||
|
if (srgbOpacity) {
|
||||||
|
srgbOpacity.addEventListener('input', (e) => setSrgbReferenceOpacity(parseFloat(e.target.value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const axisOpacity = document.getElementById('rngAxisOpacity');
|
||||||
|
if (axisOpacity) {
|
||||||
|
axisOpacity.addEventListener('input', (e) => setAxisOpacity(parseFloat(e.target.value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only listen for 'R' reset when the viewer tab is active.
|
||||||
|
const stage5 = document.getElementById('stage-5');
|
||||||
|
if (stage5) {
|
||||||
|
stage5.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.key === 'r' || e.key === 'R') && !stage5.classList.contains('hidden')) {
|
||||||
|
resetCamera();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Unit & console tests for gamut_viewer.js parsing.
|
||||||
|
// Can be run in browser devtools console:
|
||||||
|
// import('./gamut_viewer.test.js').then(m => m.runAll())
|
||||||
|
// Or in Node:
|
||||||
|
// node src/js/gamut_viewer.test.js
|
||||||
|
|
||||||
|
// Node environment polyfill for browser globals
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
globalThis.window = {
|
||||||
|
__TAURI__: {
|
||||||
|
core: { invoke: () => Promise.resolve() },
|
||||||
|
event: { listen: () => Promise.resolve(() => {}) }
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: () => {}
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parseGamutFile } = await import('./gamut_viewer.js');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
export function runAll() {
|
||||||
|
console.group('Gamut Viewer Parser Tests');
|
||||||
|
passed = 0;
|
||||||
|
total = 0;
|
||||||
|
testParseGamutBasic();
|
||||||
|
testParseGamutDualTable();
|
||||||
|
testParseGamutWithComments();
|
||||||
|
console.log(`\nResults: ${passed} / ${total} tests passed.`);
|
||||||
|
console.groupEnd();
|
||||||
|
|
||||||
|
if (passed !== total) {
|
||||||
|
throw new Error(`Gamut viewer parser tests failed: ${total - passed} failure(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual(actual, expected, message) {
|
||||||
|
total++;
|
||||||
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
|
if (ok) {
|
||||||
|
passed++;
|
||||||
|
console.log('PASS:', message);
|
||||||
|
} else {
|
||||||
|
console.error('FAIL:', message, 'expected', expected, 'got', actual);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function testParseGamutBasic() {
|
||||||
|
const text = `GAMUT file
|
||||||
|
BEGIN_DATA
|
||||||
|
0 50 0 0
|
||||||
|
1 100 0 0
|
||||||
|
2 0 -128 0
|
||||||
|
3 0 0 128
|
||||||
|
END_DATA
|
||||||
|
BEGIN_DATA
|
||||||
|
0 1 2
|
||||||
|
1 2 3
|
||||||
|
END_DATA`;
|
||||||
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
assertEqual(vertices.length, 4, 'basic gamut vertex count');
|
||||||
|
assertEqual(faces.length, 2, 'basic gamut face count');
|
||||||
|
assertEqual(warnings.length, 0, 'basic gamut no warnings');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testParseGamutDualTable() {
|
||||||
|
const text = `GAMUT file
|
||||||
|
BEGIN_DATA
|
||||||
|
0 50 0 0
|
||||||
|
1 100 0 0
|
||||||
|
2 0 -128 0
|
||||||
|
3 0 0 128
|
||||||
|
END_DATA
|
||||||
|
BEGIN_DATA
|
||||||
|
0 1 2
|
||||||
|
END_DATA
|
||||||
|
BEGIN_DATA
|
||||||
|
1 2 3
|
||||||
|
END_DATA`;
|
||||||
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
assertEqual(vertices.length, 4, 'dual-table gamut vertex count');
|
||||||
|
assertEqual(faces.length, 2, 'dual-table gamut face count');
|
||||||
|
assertEqual(warnings.length, 0, 'dual-table gamut no warnings');
|
||||||
|
}
|
||||||
|
|
||||||
|
function testParseGamutWithComments() {
|
||||||
|
const text = `GAMUT file
|
||||||
|
# this is a comment
|
||||||
|
BEGIN_DATA
|
||||||
|
0 50 0 0
|
||||||
|
1 100 0 0
|
||||||
|
# inline comment
|
||||||
|
2 0 -128 0
|
||||||
|
3 0 0 128
|
||||||
|
END_DATA
|
||||||
|
BEGIN_DATA
|
||||||
|
0 1 2
|
||||||
|
1 2 3
|
||||||
|
END_DATA`;
|
||||||
|
const { vertices, faces, warnings } = parseGamutFile(text);
|
||||||
|
assertEqual(vertices.length, 4, 'commented gamut vertex count');
|
||||||
|
assertEqual(faces.length, 2, 'commented gamut face count');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-run if executed in Node.js
|
||||||
|
if (typeof process !== 'undefined' && process.argv && process.argv[1]?.endsWith('gamut_viewer.test.js')) {
|
||||||
|
runAll();
|
||||||
|
}
|
||||||
@@ -574,6 +574,7 @@ export function initPrinttarg() {
|
|||||||
showNotification("error", "Please select a destination printer first.");
|
showNotification("error", "Please select a destination printer first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
wizardState.printerName = printerName;
|
||||||
|
|
||||||
const options = getSelectedPrintOptions();
|
const options = getSelectedPrintOptions();
|
||||||
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
|
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
|
||||||
@@ -619,6 +620,7 @@ export function initPrinttarg() {
|
|||||||
showNotification("error", "Please select a destination printer first.");
|
showNotification("error", "Please select a destination printer first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
wizardState.printerName = printerName;
|
||||||
|
|
||||||
const options = getSelectedPrintOptions();
|
const options = getSelectedPrintOptions();
|
||||||
const cwd = stage1Cwd;
|
const cwd = stage1Cwd;
|
||||||
|
|||||||
+533
-52
@@ -14,6 +14,423 @@ export function setStage4Result(basename, cwd) {
|
|||||||
profileBasename = basename || wizardState.basename;
|
profileBasename = basename || wizardState.basename;
|
||||||
profileCwd = cwd || wizardState.cwd;
|
profileCwd = cwd || wizardState.cwd;
|
||||||
wizardState.setTarget(profileBasename, profileCwd);
|
wizardState.setTarget(profileBasename, profileCwd);
|
||||||
|
loadVerificationHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse profcheck output for Average, Peak, and RMS delta-E values and patch count.
|
||||||
|
* Supports Argyll's -u JSON summary object, text summary line, and legacy plain-text output.
|
||||||
|
* @param {string} stdout - Full profcheck stdout.
|
||||||
|
* @returns {{ avgDe: number, maxDe: number, rmsDe: number, patchCount: number, warnings: string[] }}
|
||||||
|
*/
|
||||||
|
export function parseProfcheckReport(stdout) {
|
||||||
|
let avgDe = 0.0;
|
||||||
|
let maxDe = 0.0;
|
||||||
|
let rmsDe = 0.0;
|
||||||
|
let patchCount = 0;
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
if (!stdout || typeof stdout !== 'string') {
|
||||||
|
return { avgDe, maxDe, rmsDe, patchCount, warnings: ['No stdout received from profcheck.'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse patch count from "No of test patches = (\d+)"
|
||||||
|
const patchMatch = stdout.match(/No\s+of\s+test\s+patches\s*=\s*(\d+)/i);
|
||||||
|
if (patchMatch) {
|
||||||
|
patchCount = parseInt(patchMatch[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argyll's JSON output can appear either as a compact object on a single
|
||||||
|
// line or embedded inside larger text. Accept objects with event === "report"
|
||||||
|
// or containing any of avg_de, avg_de2000, peak_de, peak_de2000, rms, rms_de.
|
||||||
|
const jsonObjects = [];
|
||||||
|
const re = /\{[\s\S]*?\}/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(stdout)) !== null) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(m[0]);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
if (
|
||||||
|
parsed.event === 'report' ||
|
||||||
|
'avg_de' in parsed ||
|
||||||
|
'avg_de2000' in parsed ||
|
||||||
|
'peak_de' in parsed ||
|
||||||
|
'peak_de2000' in parsed ||
|
||||||
|
'rms' in parsed ||
|
||||||
|
'rms_de' in parsed
|
||||||
|
) {
|
||||||
|
jsonObjects.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not a valid JSON object, ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonObjects.length > 0) {
|
||||||
|
// When several report objects exist (de2000, de94, de), prefer *de2000 object matching -k
|
||||||
|
const de2000Obj = jsonObjects.find(o => 'avg_de2000' in o || 'peak_de2000' in o);
|
||||||
|
const targetJson = de2000Obj || jsonObjects[jsonObjects.length - 1];
|
||||||
|
|
||||||
|
avgDe = typeof targetJson.avg_de2000 === 'number' ? targetJson.avg_de2000 :
|
||||||
|
(typeof targetJson.avg_de === 'number' ? targetJson.avg_de : 0);
|
||||||
|
maxDe = typeof targetJson.peak_de2000 === 'number' ? targetJson.peak_de2000 :
|
||||||
|
(typeof targetJson.max_de === 'number' ? targetJson.max_de :
|
||||||
|
(typeof targetJson.peak_de === 'number' ? targetJson.peak_de : 0));
|
||||||
|
rmsDe = typeof targetJson.rms === 'number' ? targetJson.rms :
|
||||||
|
(typeof targetJson.rms_de === 'number' ? targetJson.rms_de : 0);
|
||||||
|
} else {
|
||||||
|
// Check for standard Argyll text summary line:
|
||||||
|
// Profile check complete, errors...: max. = %f, avg. = %f, RMS = %f
|
||||||
|
const summaryMatch = stdout.match(/Profile check complete,\s*errors[^\:]*:\s*max\.\s*=\s*([\d\.]+),\s*avg\.\s*=\s*([\d\.]+),\s*RMS\s*=\s*([\d\.]+)/i);
|
||||||
|
if (summaryMatch) {
|
||||||
|
maxDe = parseFloat(summaryMatch[1]);
|
||||||
|
avgDe = parseFloat(summaryMatch[2]);
|
||||||
|
rmsDe = parseFloat(summaryMatch[3]);
|
||||||
|
} else {
|
||||||
|
// Regex fallbacks for standard profcheck text output
|
||||||
|
const avgPatterns = [
|
||||||
|
/avg(?:\.?|erage)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/average\s+(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/mean\s+(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/dE\s+average[^\d]*([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
const maxPatterns = [
|
||||||
|
/max(?:\.?|imum)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/worst\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/dE\s+max[^\d]*([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
const rmsPatterns = [
|
||||||
|
/RMS(?:\.?)\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i,
|
||||||
|
/rms(?:\.?)\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
/root\s+mean\s+sq(?:uare)?\s*(?:dE\s*)?([\d\.]+)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const find = (patterns) => {
|
||||||
|
for (const p of patterns) {
|
||||||
|
const match = stdout.match(p);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const avgMatch = find(avgPatterns);
|
||||||
|
const maxMatch = find(maxPatterns);
|
||||||
|
const rmsMatch = find(rmsPatterns);
|
||||||
|
|
||||||
|
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
||||||
|
else warnings.push('Could not detect Average ΔE in profcheck output.');
|
||||||
|
|
||||||
|
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
||||||
|
else warnings.push('Could not detect Peak ΔE in profcheck output.');
|
||||||
|
|
||||||
|
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
||||||
|
else warnings.push('Could not detect RMS ΔE in profcheck output.');
|
||||||
|
|
||||||
|
if (!avgMatch && !maxMatch && !rmsMatch) {
|
||||||
|
warnings.push('No delta-E values were found in profcheck output.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { avgDe, maxDe, rmsDe, patchCount, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for a printer drift breach condition:
|
||||||
|
* Returns an alert string if the last >=2 consecutive records have avg_de >= 3.5
|
||||||
|
* and span distinct calendar dates (or are >= 1 hour apart).
|
||||||
|
* @param {Array<object>} records - Array of verification records sorted ascending by timestamp.
|
||||||
|
* @returns {string|null} Alert text or null if no breach.
|
||||||
|
*/
|
||||||
|
export function checkBreachAlert(records) {
|
||||||
|
if (!records || records.length < 2) return null;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
let firstBreach = null;
|
||||||
|
let latestBreach = null;
|
||||||
|
|
||||||
|
for (let i = records.length - 1; i >= 0; i--) {
|
||||||
|
if (records[i].avg_de >= 3.5) {
|
||||||
|
count++;
|
||||||
|
if (!latestBreach) latestBreach = records[i];
|
||||||
|
firstBreach = records[i];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count >= 2 && firstBreach && latestBreach) {
|
||||||
|
const tFirst = new Date(firstBreach.timestamp).getTime();
|
||||||
|
const tLatest = new Date(latestBreach.timestamp).getTime();
|
||||||
|
const diffHours = (tLatest - tFirst) / (1000 * 60 * 60);
|
||||||
|
const dFirst = firstBreach.timestamp.slice(0, 10);
|
||||||
|
const dLatest = latestBreach.timestamp.slice(0, 10);
|
||||||
|
|
||||||
|
if (dFirst !== dLatest || diffHours >= 1.0) {
|
||||||
|
const fmt = (ts) => {
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
} catch (_) {
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return `⚠️ Re-profiling Recommended — ${count} consecutive verifications out of tolerance (first: ${fmt(firstBreach.timestamp)}, latest: ${fmt(latestBreach.timestamp)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads verification history records and updates the Stage 5 drift analytics UI.
|
||||||
|
*/
|
||||||
|
export async function loadVerificationHistory(selectedPrinter = "") {
|
||||||
|
const profileName = profileBasename || wizardState.basename || "";
|
||||||
|
const driftSection = document.getElementById("driftHistorySection");
|
||||||
|
if (!driftSection) return;
|
||||||
|
|
||||||
|
const alertCard = document.getElementById("driftAlertCard");
|
||||||
|
const alertText = document.getElementById("driftAlertText");
|
||||||
|
const emptyState = document.getElementById("driftEmptyState");
|
||||||
|
const chartWrap = document.getElementById("driftChartWrap");
|
||||||
|
const chartSvg = document.getElementById("driftTrendChart");
|
||||||
|
const tbody = document.getElementById("verificationHistoryTbody");
|
||||||
|
const btnExport = document.getElementById("btnExportHistoryCsv");
|
||||||
|
const btnClear = document.getElementById("btnClearHistory");
|
||||||
|
const printerFilterSelect = document.getElementById("driftPrinterFilter");
|
||||||
|
const filterRow = document.getElementById("driftFilterRow");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch all records for current profile to populate printer options
|
||||||
|
const allProfileRecords = await invoke("get_verification_history", {
|
||||||
|
profileName: profileName || null,
|
||||||
|
printerName: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (printerFilterSelect) {
|
||||||
|
const distinctPrinters = Array.from(new Set(allProfileRecords.map(r => r.printer_name).filter(Boolean)));
|
||||||
|
printerFilterSelect.innerHTML = `<option value="">All Printers</option>`;
|
||||||
|
distinctPrinters.forEach(p => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = p;
|
||||||
|
opt.textContent = p;
|
||||||
|
if (p === selectedPrinter) opt.selected = true;
|
||||||
|
printerFilterSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (filterRow) {
|
||||||
|
filterRow.classList.toggle("hidden", distinctPrinters.length <= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch filtered records
|
||||||
|
const records = await invoke("get_verification_history", {
|
||||||
|
profileName: profileName || null,
|
||||||
|
printerName: selectedPrinter || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Breach Alert check (evaluated against profile records chronologically)
|
||||||
|
const breachMessage = checkBreachAlert(records);
|
||||||
|
if (alertCard && alertText) {
|
||||||
|
if (breachMessage) {
|
||||||
|
alertText.textContent = breachMessage;
|
||||||
|
alertCard.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
alertCard.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRecords = records.length > 0;
|
||||||
|
if (btnExport) btnExport.disabled = !hasRecords;
|
||||||
|
if (btnClear) btnClear.disabled = allProfileRecords.length === 0;
|
||||||
|
|
||||||
|
if (!hasRecords) {
|
||||||
|
if (emptyState) emptyState.classList.remove("hidden");
|
||||||
|
if (chartWrap) chartWrap.classList.add("hidden");
|
||||||
|
if (tbody) tbody.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyState) emptyState.classList.add("hidden");
|
||||||
|
if (chartWrap) chartWrap.classList.remove("hidden");
|
||||||
|
|
||||||
|
// 4. Render Hand-rolled SVG Trend Chart
|
||||||
|
if (chartSvg) {
|
||||||
|
renderDriftTrendChart(chartSvg, records);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Render Run-Log Table (newest first)
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const reversed = [...records].reverse();
|
||||||
|
reversed.forEach(r => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
const tdDate = document.createElement("td");
|
||||||
|
try {
|
||||||
|
tdDate.textContent = new Date(r.timestamp).toLocaleString();
|
||||||
|
} catch (_) {
|
||||||
|
tdDate.textContent = r.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tdPrinter = document.createElement("td");
|
||||||
|
tdPrinter.textContent = r.printer_name || "Unknown";
|
||||||
|
|
||||||
|
const tdAvg = document.createElement("td");
|
||||||
|
tdAvg.textContent = r.avg_de.toFixed(2);
|
||||||
|
|
||||||
|
const tdMax = document.createElement("td");
|
||||||
|
tdMax.textContent = r.max_de.toFixed(2);
|
||||||
|
|
||||||
|
const tdRms = document.createElement("td");
|
||||||
|
tdRms.textContent = r.rms_de.toFixed(2);
|
||||||
|
|
||||||
|
const tdPatches = document.createElement("td");
|
||||||
|
tdPatches.textContent = r.patch_count || "-";
|
||||||
|
|
||||||
|
const tdStatus = document.createElement("td");
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
const statusClass = r.status === 'excellent' ? 'badge-excellent' :
|
||||||
|
r.status === 'good' ? 'badge-good' :
|
||||||
|
r.status === 'acceptable' ? 'badge-acceptable' : 'badge-poor';
|
||||||
|
badge.className = `status-badge ${statusClass}`;
|
||||||
|
badge.textContent = r.status ? r.status.toUpperCase() : "UNKNOWN";
|
||||||
|
tdStatus.appendChild(badge);
|
||||||
|
|
||||||
|
tr.appendChild(tdDate);
|
||||||
|
tr.appendChild(tdPrinter);
|
||||||
|
tr.appendChild(tdAvg);
|
||||||
|
tr.appendChild(tdMax);
|
||||||
|
tr.appendChild(tdRms);
|
||||||
|
tr.appendChild(tdPatches);
|
||||||
|
tr.appendChild(tdStatus);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to load verification history: ${e}`, 'Stage5-Profcheck');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-rolled SVG line chart rendering verification drift trends with ICCery threshold bands.
|
||||||
|
*/
|
||||||
|
function renderDriftTrendChart(svg, records) {
|
||||||
|
// Downsample to the last 50 points if necessary
|
||||||
|
const data = records.length > 50 ? records.slice(records.length - 50) : records;
|
||||||
|
|
||||||
|
const width = 640;
|
||||||
|
const height = 220;
|
||||||
|
const padLeft = 45;
|
||||||
|
const padRight = 30;
|
||||||
|
const padTop = 20;
|
||||||
|
const padBottom = 28;
|
||||||
|
|
||||||
|
const plotW = width - padLeft - padRight;
|
||||||
|
const plotH = height - padTop - padBottom;
|
||||||
|
|
||||||
|
// Compute max Y (minimum 4.0 for all threshold bands)
|
||||||
|
let maxY = 4.0;
|
||||||
|
data.forEach(d => {
|
||||||
|
if (d.max_de > maxY) maxY = d.max_de;
|
||||||
|
if (d.avg_de > maxY) maxY = d.avg_de;
|
||||||
|
});
|
||||||
|
maxY = Math.ceil(maxY * 1.1);
|
||||||
|
|
||||||
|
const getY = (val) => padTop + plotH - (val / maxY) * plotH;
|
||||||
|
const getX = (idx) => {
|
||||||
|
if (data.length <= 1) return padLeft + plotW / 2;
|
||||||
|
return padLeft + (idx / (data.length - 1)) * plotW;
|
||||||
|
};
|
||||||
|
|
||||||
|
let elements = [];
|
||||||
|
|
||||||
|
// Threshold background bands
|
||||||
|
// Bands at: 0-1.0 (Excellent), 1.0-2.0 (Good), 2.0-3.5 (Acceptable), 3.5-maxY (Warning)
|
||||||
|
const bands = [
|
||||||
|
{ from: 0.0, to: 1.0, color: "rgba(34, 197, 94, 0.08)", label: "Excellent (< 1.0)" },
|
||||||
|
{ from: 1.0, to: 2.0, color: "rgba(59, 130, 246, 0.08)", label: "Good (< 2.0)" },
|
||||||
|
{ from: 2.0, to: 3.5, color: "rgba(245, 158, 11, 0.08)", label: "Acceptable (< 3.5)" },
|
||||||
|
{ from: 3.5, to: maxY, color: "rgba(239, 68, 68, 0.08)", label: "Warning (≥ 3.5)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
bands.forEach(b => {
|
||||||
|
const yTop = getY(Math.min(b.to, maxY));
|
||||||
|
const yBot = getY(b.from);
|
||||||
|
const bandH = Math.max(0, yBot - yTop);
|
||||||
|
elements.push(`<rect x="${padLeft}" y="${yTop}" width="${plotW}" height="${bandH}" fill="${b.color}" />`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Threshold lines
|
||||||
|
[1.0, 2.0, 3.5].forEach(thresh => {
|
||||||
|
if (thresh <= maxY) {
|
||||||
|
const y = getY(thresh);
|
||||||
|
elements.push(`<line x1="${padLeft}" y1="${y}" x2="${padLeft + plotW}" y2="${y}" stroke="rgba(255,255,255,0.15)" stroke-dasharray="3,3" />`);
|
||||||
|
elements.push(`<text x="${padLeft + plotW - 4}" y="${y - 3}" class="drift-band-label">${thresh.toFixed(1)} ΔE</text>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verification bands caption
|
||||||
|
elements.push(`<text x="${padLeft + 6}" y="${padTop + 12}" fill="rgba(255,255,255,0.35)" font-size="9px" font-family="sans-serif">ICCery verification bands</text>`);
|
||||||
|
|
||||||
|
// Y Axis ticks
|
||||||
|
const ySteps = [0, 1, 2, 3.5];
|
||||||
|
if (maxY > 5) ySteps.push(Math.floor(maxY));
|
||||||
|
ySteps.forEach(val => {
|
||||||
|
const y = getY(val);
|
||||||
|
elements.push(`<line x1="${padLeft - 4}" y1="${y}" x2="${padLeft}" y2="${y}" stroke="rgba(255,255,255,0.3)" />`);
|
||||||
|
elements.push(`<text x="${padLeft - 8}" y="${y + 3}" text-anchor="end" class="drift-chart-text">${val.toFixed(1)}</text>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// X Axis baseline
|
||||||
|
elements.push(`<line x1="${padLeft}" y1="${padTop + plotH}" x2="${padLeft + plotW}" y2="${padTop + plotH}" stroke="rgba(255,255,255,0.3)" />`);
|
||||||
|
|
||||||
|
// Series points & paths
|
||||||
|
if (data.length === 1) {
|
||||||
|
const x = getX(0);
|
||||||
|
const yAvg = getY(data[0].avg_de);
|
||||||
|
const yMax = getY(data[0].max_de);
|
||||||
|
|
||||||
|
// Dashed horizontal line across plot for single point
|
||||||
|
elements.push(`<line x1="${padLeft}" y1="${yAvg}" x2="${padLeft + plotW}" y2="${yAvg}" stroke="#3b82f6" stroke-dasharray="4,4" stroke-opacity="0.5" />`);
|
||||||
|
elements.push(`<circle cx="${x}" cy="${yAvg}" r="5" class="drift-dot-avg"><title>Avg ΔE: ${data[0].avg_de.toFixed(2)} (${data[0].timestamp})</title></circle>`);
|
||||||
|
elements.push(`<circle cx="${x}" cy="${yMax}" r="4" class="drift-dot-max"><title>Peak ΔE: ${data[0].max_de.toFixed(2)}</title></circle>`);
|
||||||
|
} else {
|
||||||
|
// Polylines
|
||||||
|
let ptsAvg = [];
|
||||||
|
let ptsMax = [];
|
||||||
|
data.forEach((d, idx) => {
|
||||||
|
const x = getX(idx);
|
||||||
|
const yA = getY(d.avg_de);
|
||||||
|
const yM = getY(d.max_de);
|
||||||
|
ptsAvg.push(`${x.toFixed(1)},${yA.toFixed(1)}`);
|
||||||
|
ptsMax.push(`${x.toFixed(1)},${yM.toFixed(1)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.push(`<polyline points="${ptsMax.join(' ')}" class="drift-line-max" />`);
|
||||||
|
elements.push(`<polyline points="${ptsAvg.join(' ')}" class="drift-line-avg" />`);
|
||||||
|
|
||||||
|
// Draw dots
|
||||||
|
data.forEach((d, idx) => {
|
||||||
|
const x = getX(idx);
|
||||||
|
const yA = getY(d.avg_de);
|
||||||
|
const yM = getY(d.max_de);
|
||||||
|
const dateStr = d.timestamp.slice(0, 10);
|
||||||
|
elements.push(`<circle cx="${x.toFixed(1)}" cy="${yA.toFixed(1)}" r="4" class="drift-dot-avg"><title>Avg: ${d.avg_de.toFixed(2)} (${dateStr})</title></circle>`);
|
||||||
|
elements.push(`<circle cx="${x.toFixed(1)}" cy="${yM.toFixed(1)}" r="3" class="drift-dot-max"><title>Peak: ${d.max_de.toFixed(2)} (${dateStr})</title></circle>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start and End date labels on X axis
|
||||||
|
const startStr = data[0].timestamp.slice(5, 10);
|
||||||
|
const endStr = data[data.length - 1].timestamp.slice(5, 10);
|
||||||
|
elements.push(`<text x="${padLeft}" y="${height - 8}" text-anchor="start" class="drift-chart-text">${startStr}</text>`);
|
||||||
|
elements.push(`<text x="${padLeft + plotW}" y="${height - 8}" text-anchor="end" class="drift-chart-text">${endStr}</text>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
|
svg.innerHTML = elements.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initProfcheck() {
|
export function initProfcheck() {
|
||||||
@@ -25,9 +442,68 @@ export function initProfcheck() {
|
|||||||
const maxDeEl = document.getElementById("profcheckMaxDe");
|
const maxDeEl = document.getElementById("profcheckMaxDe");
|
||||||
const rmsDeEl = document.getElementById("profcheckRmsDe");
|
const rmsDeEl = document.getElementById("profcheckRmsDe");
|
||||||
const badgeEl = document.getElementById("profcheckBadge");
|
const badgeEl = document.getElementById("profcheckBadge");
|
||||||
|
const btnExport = document.getElementById("btnExportHistoryCsv");
|
||||||
|
const btnClear = document.getElementById("btnClearHistory");
|
||||||
|
const printerFilterSelect = document.getElementById("driftPrinterFilter");
|
||||||
|
|
||||||
if (!btnVerify) return;
|
if (!btnVerify) return;
|
||||||
|
|
||||||
|
// Listen for printer filter changes in drift history
|
||||||
|
if (printerFilterSelect) {
|
||||||
|
printerFilterSelect.addEventListener("change", () => {
|
||||||
|
loadVerificationHistory(printerFilterSelect.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export CSV button handler
|
||||||
|
if (btnExport) {
|
||||||
|
btnExport.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const profileName = profileBasename || wizardState.basename || "verification";
|
||||||
|
const defaultName = `${profileName}_history.csv`;
|
||||||
|
const chosenPath = await invoke("select_csv_save_path", { defaultName });
|
||||||
|
if (chosenPath) {
|
||||||
|
const printerFilter = printerFilterSelect ? printerFilterSelect.value : "";
|
||||||
|
const count = await invoke("export_verification_history_csv", {
|
||||||
|
destPath: chosenPath,
|
||||||
|
profileName: profileName || null,
|
||||||
|
printerName: printerFilter || null,
|
||||||
|
});
|
||||||
|
wizardState.showNotice(`✓ Exported ${count} verification records to ${chosenPath}`, "success");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`CSV Export failed: ${err}`, 'Stage5-Profcheck');
|
||||||
|
wizardState.showNotice(`Failed to export CSV: ${err}`, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear History button handler
|
||||||
|
if (btnClear) {
|
||||||
|
btnClear.addEventListener("click", async () => {
|
||||||
|
if (confirm("Are you sure you want to clear all verification history records? This cannot be undone.")) {
|
||||||
|
try {
|
||||||
|
await invoke("clear_verification_history");
|
||||||
|
await loadVerificationHistory();
|
||||||
|
wizardState.showNotice("Verification history cleared.", "info");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Clear history failed: ${err}`, 'Stage5-Profcheck');
|
||||||
|
wizardState.showNotice(`Failed to clear history: ${err}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for Stage 5 navigation to load history
|
||||||
|
window.addEventListener("stage-changed", (event) => {
|
||||||
|
if (event.detail && event.detail.stage === 5) {
|
||||||
|
loadVerificationHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadVerificationHistory();
|
||||||
|
|
||||||
btnVerify.addEventListener("click", async () => {
|
btnVerify.addEventListener("click", async () => {
|
||||||
const basename = profileBasename || wizardState.basename;
|
const basename = profileBasename || wizardState.basename;
|
||||||
const cwd = profileCwd || wizardState.cwd;
|
const cwd = profileCwd || wizardState.cwd;
|
||||||
@@ -94,11 +570,66 @@ export function initProfcheck() {
|
|||||||
|
|
||||||
if (event.payload.code === 0) {
|
if (event.payload.code === 0) {
|
||||||
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
|
||||||
parseAndRenderReport(stdoutAccumulator);
|
const report = parseProfcheckReport(stdoutAccumulator);
|
||||||
|
|
||||||
|
reportCard.classList.remove("hidden");
|
||||||
|
if (report.warnings.length > 0) {
|
||||||
|
logPre.textContent += `\n[WARN] ${report.warnings.join(' ')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
avgDeEl.textContent = report.avgDe.toFixed(2);
|
||||||
|
maxDeEl.textContent = report.maxDe.toFixed(2);
|
||||||
|
rmsDeEl.textContent = report.rmsDe.toFixed(2);
|
||||||
|
|
||||||
|
// Quality verdict
|
||||||
|
badgeEl.className = "report-badge";
|
||||||
|
if (report.avgDe < 1.0) {
|
||||||
|
badgeEl.textContent = "EXCELLENT";
|
||||||
|
badgeEl.classList.add("badge-excellent");
|
||||||
|
} else if (report.avgDe < 2.0) {
|
||||||
|
badgeEl.textContent = "GOOD";
|
||||||
|
badgeEl.classList.add("badge-good");
|
||||||
|
} else if (report.avgDe < 3.5) {
|
||||||
|
badgeEl.textContent = "ACCEPTABLE";
|
||||||
|
badgeEl.classList.add("badge-acceptable");
|
||||||
|
} else {
|
||||||
|
badgeEl.textContent = "POOR";
|
||||||
|
badgeEl.classList.add("badge-poor");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save record to verification history
|
||||||
|
const record = {
|
||||||
|
id: "",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
printer_name: wizardState.printerName || "Unknown",
|
||||||
|
profile_name: profileBasename || wizardState.basename || "Unknown",
|
||||||
|
avg_de: report.avgDe,
|
||||||
|
max_de: report.maxDe,
|
||||||
|
rms_de: report.rmsDe,
|
||||||
|
patch_count: report.patchCount,
|
||||||
|
status: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("save_verification_record", { record });
|
||||||
|
} catch (saveErr) {
|
||||||
|
logger.warn(`Could not auto-save verification record: ${saveErr}`, 'Stage5-Profcheck');
|
||||||
|
logPre.textContent += `\n[WARN] Could not auto-save verification record: ${saveErr}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh verification history display
|
||||||
|
await loadVerificationHistory();
|
||||||
|
|
||||||
// Ensure gamut mesh is loaded into 3D viewer
|
// Ensure gamut mesh is loaded into 3D viewer
|
||||||
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
|
const gamFilePath = cwd ? `${cwd}${sep}${basename}.gam` : `${basename}.gam`;
|
||||||
loadGamutMesh(gamFilePath, 0x3b82f6);
|
try {
|
||||||
|
const result = await loadGamutMesh(gamFilePath);
|
||||||
|
if (!result) {
|
||||||
|
logPre.textContent += `\n[WARN] Could not render 3D gamut mesh from ${gamFilePath}.\n`;
|
||||||
|
}
|
||||||
|
} catch (gamErr) {
|
||||||
|
logPre.textContent += `\n[WARN] 3D gamut render failed: ${gamErr}\n`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
|
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
|
||||||
}
|
}
|
||||||
@@ -112,54 +643,4 @@ export function initProfcheck() {
|
|||||||
btnVerify.disabled = false;
|
btnVerify.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseAndRenderReport(stdout) {
|
|
||||||
reportCard.classList.remove("hidden");
|
|
||||||
|
|
||||||
let avgDe = 0.0;
|
|
||||||
let maxDe = 0.0;
|
|
||||||
let rmsDe = 0.0;
|
|
||||||
|
|
||||||
// Check if JSON output is present
|
|
||||||
const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/);
|
|
||||||
if (jsonMatch) {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(jsonMatch[0]);
|
|
||||||
avgDe = json.avg_de || 0;
|
|
||||||
maxDe = json.max_de || json.peak_de || 0;
|
|
||||||
rmsDe = json.rms_de || 0;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("JSON parse error:", e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Regex fallbacks for standard profcheck output
|
|
||||||
const avgMatch = stdout.match(/avg\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/average\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
|
||||||
const maxMatch = stdout.match(/max\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
|
||||||
const rmsMatch = stdout.match(/RMS\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/rms\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
|
||||||
|
|
||||||
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
|
||||||
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
|
||||||
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
avgDeEl.textContent = avgDe.toFixed(2);
|
|
||||||
maxDeEl.textContent = maxDe.toFixed(2);
|
|
||||||
rmsDeEl.textContent = rmsDe.toFixed(2);
|
|
||||||
|
|
||||||
// Quality verdict
|
|
||||||
badgeEl.className = "report-badge";
|
|
||||||
if (avgDe < 1.0) {
|
|
||||||
badgeEl.textContent = "EXCELLENT";
|
|
||||||
badgeEl.classList.add("badge-excellent");
|
|
||||||
} else if (avgDe < 2.0) {
|
|
||||||
badgeEl.textContent = "GOOD";
|
|
||||||
badgeEl.classList.add("badge-good");
|
|
||||||
} else if (avgDe < 4.0) {
|
|
||||||
badgeEl.textContent = "ACCEPTABLE";
|
|
||||||
badgeEl.classList.add("badge-acceptable");
|
|
||||||
} else {
|
|
||||||
badgeEl.textContent = "POOR";
|
|
||||||
badgeEl.classList.add("badge-poor");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Unit & console tests for profcheck.js parsing and breach alert logic.
|
||||||
|
// Can be run in browser devtools console:
|
||||||
|
// import('./profcheck.test.js').then(m => m.runAll())
|
||||||
|
// Or in Node:
|
||||||
|
// node src/js/profcheck.test.js
|
||||||
|
|
||||||
|
// Node environment polyfill for browser globals
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
globalThis.window = {
|
||||||
|
__TAURI__: {
|
||||||
|
core: { invoke: () => Promise.resolve() },
|
||||||
|
event: { listen: () => Promise.resolve(() => {}) }
|
||||||
|
},
|
||||||
|
addEventListener: () => {},
|
||||||
|
dispatchEvent: () => {}
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelectorAll: () => []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parseProfcheckReport, checkBreachAlert } = await import('./profcheck.js');
|
||||||
|
|
||||||
|
export function runAll() {
|
||||||
|
console.group('Profcheck Report Parser & Drift Tests');
|
||||||
|
let passed = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
function assert(actual, expected, message) {
|
||||||
|
total++;
|
||||||
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||||||
|
if (ok) {
|
||||||
|
console.log('PASS:', message);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.error('FAIL:', message, '\nExpected:', expected, '\nGot:', actual);
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Real Argyll -u JSON payload
|
||||||
|
const realUOutput = `
|
||||||
|
profcheck: Checking profile accuracy...
|
||||||
|
No of test patches = 52
|
||||||
|
{"event": "report", "peak_de2000": 2.41, "avg_de2000": 0.85, "rms": 1.02}
|
||||||
|
Profile check complete, errors(CIEDE2000): max. = 2.41, avg. = 0.85, RMS = 1.02
|
||||||
|
`;
|
||||||
|
const res1 = parseProfcheckReport(realUOutput);
|
||||||
|
assert(res1.avgDe, 0.85, 'real -u JSON avgDe');
|
||||||
|
assert(res1.maxDe, 2.41, 'real -u JSON maxDe');
|
||||||
|
assert(res1.rmsDe, 1.02, 'real -u JSON rmsDe');
|
||||||
|
assert(res1.patchCount, 52, 'real -u patchCount');
|
||||||
|
assert(res1.warnings.length, 0, 'real -u no warnings');
|
||||||
|
|
||||||
|
// 2. Preference for *de2000 object when multiple JSON objects appear
|
||||||
|
const multiJsonOutput = `
|
||||||
|
{"event": "report", "peak_de": 3.10, "avg_de": 1.20, "rms": 1.50}
|
||||||
|
{"event": "report", "peak_de2000": 2.15, "avg_de2000": 0.72, "rms": 0.95}
|
||||||
|
`;
|
||||||
|
const res2 = parseProfcheckReport(multiJsonOutput);
|
||||||
|
assert(res2.avgDe, 0.72, 'prefers *de2000 avg');
|
||||||
|
assert(res2.maxDe, 2.15, 'prefers *de2000 peak');
|
||||||
|
assert(res2.rmsDe, 0.95, 'prefers *de2000 rms');
|
||||||
|
|
||||||
|
// 3. Standard text summary line without JSON
|
||||||
|
const textSummaryOutput = `
|
||||||
|
Header information...
|
||||||
|
No of test patches = 120
|
||||||
|
Profile check complete, errors(CIEDE2000): max. = 1.95, avg. = 0.65, RMS = 0.88
|
||||||
|
Done.
|
||||||
|
`;
|
||||||
|
const res3 = parseProfcheckReport(textSummaryOutput);
|
||||||
|
assert(res3.avgDe, 0.65, 'text summary line avgDe');
|
||||||
|
assert(res3.maxDe, 1.95, 'text summary line maxDe');
|
||||||
|
assert(res3.rmsDe, 0.88, 'text summary line rmsDe');
|
||||||
|
assert(res3.patchCount, 120, 'text summary patchCount');
|
||||||
|
|
||||||
|
// 4. Legacy regex fallbacks
|
||||||
|
const legacyOutput = `
|
||||||
|
Summary:
|
||||||
|
avg. dE = 1.15
|
||||||
|
max. dE = 3.42
|
||||||
|
rms. dE = 1.65
|
||||||
|
`;
|
||||||
|
const res4 = parseProfcheckReport(legacyOutput);
|
||||||
|
assert(res4.avgDe, 1.15, 'legacy text avgDe');
|
||||||
|
assert(res4.maxDe, 3.42, 'legacy text maxDe');
|
||||||
|
assert(res4.rmsDe, 1.65, 'legacy text rmsDe');
|
||||||
|
|
||||||
|
// 5. checkBreachAlert tests
|
||||||
|
assert(checkBreachAlert([]), null, 'empty records returns null');
|
||||||
|
assert(checkBreachAlert([{ avg_de: 4.0, timestamp: '2026-09-01T10:00:00Z' }]), null, 'single record returns null');
|
||||||
|
|
||||||
|
// Two records in same minute >= 3.5 -> no alert
|
||||||
|
const sameTimeRecords = [
|
||||||
|
{ avg_de: 3.8, timestamp: '2026-09-05T12:00:10Z' },
|
||||||
|
{ avg_de: 3.9, timestamp: '2026-09-05T12:00:45Z' },
|
||||||
|
];
|
||||||
|
assert(checkBreachAlert(sameTimeRecords), null, 'two records in same minute do not fire alert');
|
||||||
|
|
||||||
|
// Two records on distinct calendar dates >= 3.5 -> alert fires
|
||||||
|
const distinctDateRecords = [
|
||||||
|
{ avg_de: 1.0, timestamp: '2026-08-15T10:00:00Z' },
|
||||||
|
{ avg_de: 3.6, timestamp: '2026-09-01T10:00:00Z' },
|
||||||
|
{ avg_de: 3.8, timestamp: '2026-09-05T10:00:00Z' },
|
||||||
|
];
|
||||||
|
const alert = checkBreachAlert(distinctDateRecords);
|
||||||
|
assert(typeof alert === 'string' && alert.includes('Re-profiling Recommended') && alert.includes('2 consecutive'), true, 'distinct dates breach alert fires');
|
||||||
|
|
||||||
|
// Two records >= 1 hour apart on same day -> alert fires
|
||||||
|
const hourApartRecords = [
|
||||||
|
{ avg_de: 3.7, timestamp: '2026-09-05T10:00:00Z' },
|
||||||
|
{ avg_de: 3.9, timestamp: '2026-09-05T12:30:00Z' },
|
||||||
|
];
|
||||||
|
const alert2 = checkBreachAlert(hourApartRecords);
|
||||||
|
assert(typeof alert2 === 'string' && alert2.includes('Re-profiling Recommended'), true, 'records >=1 hr apart breach alert fires');
|
||||||
|
|
||||||
|
// Trailing record is good -> no alert
|
||||||
|
const recoveredRecords = [
|
||||||
|
{ avg_de: 3.7, timestamp: '2026-09-01T10:00:00Z' },
|
||||||
|
{ avg_de: 3.9, timestamp: '2026-09-02T10:00:00Z' },
|
||||||
|
{ avg_de: 0.9, timestamp: '2026-09-03T10:00:00Z' },
|
||||||
|
];
|
||||||
|
assert(checkBreachAlert(recoveredRecords), null, 'recovered profile returns null');
|
||||||
|
|
||||||
|
console.log(`Profcheck tests complete: ${passed}/${total} passed`);
|
||||||
|
console.groupEnd();
|
||||||
|
return passed === total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-run if executed directly in Node
|
||||||
|
if (typeof process !== 'undefined' && process.argv && process.argv[1] && process.argv[1].endsWith('profcheck.test.js')) {
|
||||||
|
const ok = runAll();
|
||||||
|
if (!ok) process.exit(1);
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
const { invoke } = window.__TAURI__.core;
|
const { invoke } = window.__TAURI__.core;
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
function getInputValueAsFloat(id, fallback) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return fallback;
|
||||||
|
const v = parseFloat(el.value);
|
||||||
|
return Number.isNaN(v) ? fallback : v;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initSettings() {
|
export async function initSettings() {
|
||||||
const dialog = document.getElementById('settingsDialog');
|
const dialog = document.getElementById('settingsDialog');
|
||||||
const openBtn = document.getElementById('openSettingsBtn');
|
const openBtn = document.getElementById('openSettingsBtn');
|
||||||
@@ -11,6 +18,10 @@ export async function initSettings() {
|
|||||||
const btnCopyLogPath = document.getElementById('btnCopyLogPath');
|
const btnCopyLogPath = document.getElementById('btnCopyLogPath');
|
||||||
const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt');
|
const btnCopyLogExcerpt = document.getElementById('btnCopyLogExcerpt');
|
||||||
const logPathDisplay = document.getElementById('logPathDisplay');
|
const logPathDisplay = document.getElementById('logPathDisplay');
|
||||||
|
const deltaEGoodMax = document.getElementById('deltaEGoodMax');
|
||||||
|
const deltaEWarningMax = document.getElementById('deltaEWarningMax');
|
||||||
|
const deltaEThresholdError = document.getElementById('deltaEThresholdError');
|
||||||
|
const enableI1Pro2Leds = document.getElementById('enable_i1pro2_leds');
|
||||||
|
|
||||||
if (!dialog || !openBtn) return;
|
if (!dialog || !openBtn) return;
|
||||||
|
|
||||||
@@ -24,14 +35,42 @@ export async function initSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateDeltaEThresholds() {
|
||||||
|
if (!deltaEGoodMax || !deltaEWarningMax) return true;
|
||||||
|
const good = parseFloat(deltaEGoodMax.value);
|
||||||
|
const warn = parseFloat(deltaEWarningMax.value);
|
||||||
|
const valid = !Number.isNaN(good) && !Number.isNaN(warn) && good < warn && good >= 0 && warn >= 0;
|
||||||
|
if (deltaEThresholdError) {
|
||||||
|
deltaEThresholdError.style.display = valid ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaEGoodMax) {
|
||||||
|
deltaEGoodMax.addEventListener('input', validateDeltaEThresholds);
|
||||||
|
}
|
||||||
|
if (deltaEWarningMax) {
|
||||||
|
deltaEWarningMax.addEventListener('input', validateDeltaEThresholds);
|
||||||
|
}
|
||||||
|
|
||||||
openBtn.addEventListener('click', async () => {
|
openBtn.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await invoke('load_settings');
|
const settings = await invoke('load_settings');
|
||||||
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
|
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
|
||||||
document.getElementById('default_instrument').value = settings.default_instrument || '';
|
document.getElementById('default_instrument').value = settings.default_instrument || '';
|
||||||
|
if (enableI1Pro2Leds) {
|
||||||
|
enableI1Pro2Leds.checked = Boolean(settings.enable_i1pro2_leds);
|
||||||
|
}
|
||||||
if (logLevelSelect && settings.log_level) {
|
if (logLevelSelect && settings.log_level) {
|
||||||
logLevelSelect.value = settings.log_level;
|
logLevelSelect.value = settings.log_level;
|
||||||
}
|
}
|
||||||
|
if (deltaEGoodMax) {
|
||||||
|
deltaEGoodMax.value = Number(settings.delta_e_good_max ?? 2.0).toFixed(1);
|
||||||
|
}
|
||||||
|
if (deltaEWarningMax) {
|
||||||
|
deltaEWarningMax.value = Number(settings.delta_e_warning_max ?? 5.0).toFixed(1);
|
||||||
|
}
|
||||||
|
validateDeltaEThresholds();
|
||||||
await refreshLogPath();
|
await refreshLogPath();
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -99,18 +138,27 @@ export async function initSettings() {
|
|||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
saveBtn.addEventListener('click', async () => {
|
saveBtn.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!validateDeltaEThresholds()) {
|
||||||
|
throw new Error('Invalid ΔE thresholds');
|
||||||
|
}
|
||||||
const currentSettings = await invoke('load_settings').catch(() => ({}));
|
const currentSettings = await invoke('load_settings').catch(() => ({}));
|
||||||
const settings = {
|
const settings = {
|
||||||
...currentSettings,
|
...currentSettings,
|
||||||
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
|
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
|
||||||
default_instrument: document.getElementById('default_instrument').value.trim() || null,
|
default_instrument: document.getElementById('default_instrument').value.trim() || null,
|
||||||
log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'),
|
log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'),
|
||||||
|
delta_e_good_max: getInputValueAsFloat('deltaEGoodMax', 2.0),
|
||||||
|
delta_e_warning_max: getInputValueAsFloat('deltaEWarningMax', 5.0),
|
||||||
|
enable_i1pro2_leds: enableI1Pro2Leds ? enableI1Pro2Leds.checked : false,
|
||||||
};
|
};
|
||||||
await invoke('save_settings', { settings });
|
await invoke('save_settings', { settings });
|
||||||
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
|
logger.info(`Settings saved. Log level set to: ${settings.log_level}`, 'Settings');
|
||||||
|
// Notify other UI modules (e.g. swatch grid) that settings changed.
|
||||||
|
window.dispatchEvent(new CustomEvent('settings-saved', { detail: settings }));
|
||||||
dialog.close();
|
dialog.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to save settings: ${e}`, 'Settings');
|
logger.error(`Failed to save settings: ${e}`, 'Settings');
|
||||||
|
alert(`Failed to save settings: ${e}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const wizardState = {
|
|||||||
currentStage: 1,
|
currentStage: 1,
|
||||||
basename: "",
|
basename: "",
|
||||||
cwd: "",
|
cwd: "",
|
||||||
|
printerName: "",
|
||||||
noticeTimer: null,
|
noticeTimer: null,
|
||||||
|
|
||||||
setTarget(basename, cwd) {
|
setTarget(basename, cwd) {
|
||||||
@@ -75,6 +76,8 @@ export const wizardState = {
|
|||||||
s.classList.add('hidden');
|
s.classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('stage-changed', { detail: { stage: stageNumber } }));
|
||||||
},
|
},
|
||||||
|
|
||||||
async navigateToStage(stageNumber) {
|
async navigateToStage(stageNumber) {
|
||||||
|
|||||||
+85
-12
@@ -1,10 +1,76 @@
|
|||||||
import { computeDeltaE00 } from './delta_e.js';
|
import { computeDeltaE00 } from './delta_e.js';
|
||||||
import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js';
|
import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js';
|
||||||
|
|
||||||
const { listen } = window.__TAURI__.event;
|
const { listen, emit } = window.__TAURI__.event;
|
||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
|
||||||
let unlistenJsonRow = null;
|
let unlistenJsonRow = null;
|
||||||
|
|
||||||
|
// Cached ΔE thresholds; refreshed from settings on load and when settings change.
|
||||||
|
let deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the ΔE traffic-light thresholds from persisted app settings.
|
||||||
|
* Falls back to 2.0 / 5.0 if settings are missing or malformed.
|
||||||
|
*/
|
||||||
|
async function refreshDeltaEThresholds() {
|
||||||
|
try {
|
||||||
|
const s = await invoke('load_settings');
|
||||||
|
deltaEThresholds = {
|
||||||
|
goodMax: s.delta_e_good_max ?? 2.0,
|
||||||
|
warnMax: s.delta_e_warning_max ?? 5.0,
|
||||||
|
};
|
||||||
|
// Enforce the invariant at runtime in case a user hand-edited settings.json.
|
||||||
|
if (deltaEThresholds.goodMax > deltaEThresholds.warnMax) {
|
||||||
|
console.warn('ΔE thresholds invalid (good > warning); resetting to defaults.');
|
||||||
|
deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not load ΔE thresholds, using defaults:', err);
|
||||||
|
deltaEThresholds = { goodMax: 2.0, warnMax: 5.0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a CIEDE2000 value using the current thresholds.
|
||||||
|
* @param {number} deltaE
|
||||||
|
* @returns {{ label: string, cssClass: string }}
|
||||||
|
*/
|
||||||
|
function classifyDeltaE(deltaE) {
|
||||||
|
if (deltaE < deltaEThresholds.goodMax) {
|
||||||
|
return { label: "Good", cssClass: "de-good" };
|
||||||
|
} else if (deltaE < deltaEThresholds.warnMax) {
|
||||||
|
return { label: "Warning", cssClass: "de-warning" };
|
||||||
|
} else {
|
||||||
|
return { label: "Bad", cssClass: "de-bad" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-classify all already-rendered swatches using the current thresholds.
|
||||||
|
* Call this after the user changes thresholds in Settings.
|
||||||
|
*/
|
||||||
|
function reclassifySwatches() {
|
||||||
|
document.querySelectorAll('.swatch-patch[data-delta-e]').forEach((patchEl) => {
|
||||||
|
const deltaE = parseFloat(patchEl.dataset.deltaE);
|
||||||
|
if (Number.isNaN(deltaE)) return;
|
||||||
|
|
||||||
|
patchEl.classList.remove('de-good', 'de-warning', 'de-bad');
|
||||||
|
const { cssClass, label } = classifyDeltaE(deltaE);
|
||||||
|
patchEl.classList.add(cssClass);
|
||||||
|
|
||||||
|
// Update the classification word in the title without re-parsing everything.
|
||||||
|
if (patchEl.title) {
|
||||||
|
patchEl.title = patchEl.title.replace(/\s*\([^)]*\)$/, '') + ` (${label})`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for the custom event emitted by settings.js after a successful save.
|
||||||
|
window.addEventListener('settings-saved', () => {
|
||||||
|
refreshDeltaEThresholds().then(reclassifySwatches);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start listening for row events from a chartread process.
|
* Start listening for row events from a chartread process.
|
||||||
* @param {string} processId - The process ID (e.g. "chartread_my_profile")
|
* @param {string} processId - The process ID (e.g. "chartread_my_profile")
|
||||||
@@ -16,6 +82,9 @@ export async function startSwatchListener(processId, onRowComplete) {
|
|||||||
const progressText = document.getElementById("readProgressText");
|
const progressText = document.getElementById("readProgressText");
|
||||||
const statsPanel = document.getElementById("readStats");
|
const statsPanel = document.getElementById("readStats");
|
||||||
|
|
||||||
|
// Load thresholds at listener start
|
||||||
|
await refreshDeltaEThresholds();
|
||||||
|
|
||||||
// Clear previous state
|
// Clear previous state
|
||||||
grid.innerHTML = "";
|
grid.innerHTML = "";
|
||||||
let totalPatches = 0;
|
let totalPatches = 0;
|
||||||
@@ -51,6 +120,8 @@ export async function startSwatchListener(processId, onRowComplete) {
|
|||||||
if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`;
|
if (progressText) progressText.textContent = `Strip ${data.row_id} — ${data.row_index + 1} / ${data.total_rows}`;
|
||||||
|
|
||||||
// Create row container
|
// Create row container
|
||||||
|
// Row and patch order from chartread (A->Z, 1->N) is rendered
|
||||||
|
// left-to-right / top-to-bottom to match the printtarg output.
|
||||||
const rowEl = document.createElement("div");
|
const rowEl = document.createElement("div");
|
||||||
rowEl.className = "swatch-row";
|
rowEl.className = "swatch-row";
|
||||||
|
|
||||||
@@ -63,6 +134,10 @@ export async function startSwatchListener(processId, onRowComplete) {
|
|||||||
rowPatches.className = "swatch-row-patches";
|
rowPatches.className = "swatch-row-patches";
|
||||||
|
|
||||||
for (const patch of data.patches) {
|
for (const patch of data.patches) {
|
||||||
|
// Argyll marks spacer/boundary patches with is_pad. Some printtarg
|
||||||
|
// layouts also flag white reference patches (-e white steps) as pads,
|
||||||
|
// but those carry valid expected or measured device data. Only skip
|
||||||
|
// pads that have no measurement and no non-zero device coordinates.
|
||||||
if (patch.is_pad && !patch.measured && (!patch.device || patch.device.every(v => v === 0))) continue;
|
if (patch.is_pad && !patch.measured && (!patch.device || patch.device.every(v => v === 0))) continue;
|
||||||
|
|
||||||
const patchEl = document.createElement("div");
|
const patchEl = document.createElement("div");
|
||||||
@@ -93,11 +168,12 @@ export async function startSwatchListener(processId, onRowComplete) {
|
|||||||
|
|
||||||
patchEl.appendChild(swatch);
|
patchEl.appendChild(swatch);
|
||||||
|
|
||||||
|
// Build a structured tooltip for the patch
|
||||||
let titleStr = `${patch.loc} (ID: ${patch.id})`;
|
let titleStr = `${patch.loc} (ID: ${patch.id})`;
|
||||||
if (patch.expected && patch.expected.Lab) {
|
if (patch.expected && patch.expected.Lab) {
|
||||||
titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`;
|
titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`;
|
||||||
} else if (patch.device) {
|
} else if (patch.device) {
|
||||||
titleStr += `\nDevice: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
|
titleStr += `\nIntended Device: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch.measured && patch.measured.Lab) {
|
if (patch.measured && patch.measured.Lab) {
|
||||||
@@ -107,22 +183,19 @@ export async function startSwatchListener(processId, onRowComplete) {
|
|||||||
// Compute and display ΔE₀₀ if both expected and measured Lab are present
|
// Compute and display ΔE₀₀ if both expected and measured Lab are present
|
||||||
if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) {
|
if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) {
|
||||||
const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab);
|
const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab);
|
||||||
|
const { label, cssClass } = classifyDeltaE(deltaE);
|
||||||
|
|
||||||
|
// Persist the raw ΔE on the element so re-classification works after
|
||||||
|
// the user changes thresholds in Settings.
|
||||||
|
patchEl.dataset.deltaE = deltaE.toFixed(4);
|
||||||
|
|
||||||
const deLabel = document.createElement("div");
|
const deLabel = document.createElement("div");
|
||||||
deLabel.className = "swatch-de";
|
deLabel.className = "swatch-de";
|
||||||
deLabel.textContent = deltaE.toFixed(1);
|
deLabel.textContent = deltaE.toFixed(1);
|
||||||
|
|
||||||
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)}`;
|
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)} (${label})`;
|
||||||
|
|
||||||
// Traffic light classification
|
|
||||||
if (deltaE < 2) {
|
|
||||||
patchEl.classList.add("de-good"); // Green
|
|
||||||
} else if (deltaE < 5) {
|
|
||||||
patchEl.classList.add("de-warning"); // Amber
|
|
||||||
} else {
|
|
||||||
patchEl.classList.add("de-bad"); // Red
|
|
||||||
}
|
|
||||||
|
|
||||||
|
patchEl.classList.add(cssClass);
|
||||||
patchEl.appendChild(deLabel);
|
patchEl.appendChild(deLabel);
|
||||||
|
|
||||||
// Accumulate stats
|
// Accumulate stats
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Manual smoke tests for swatch_grid.js
|
||||||
|
// Run in a browser/devtools console after the app has loaded:
|
||||||
|
// import('./swatch_grid.test.js').then(m => m.runAll())
|
||||||
|
|
||||||
|
export function runAll() {
|
||||||
|
console.group('swatch_grid smoke tests');
|
||||||
|
testClassifyDeltaE();
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function testClassifyDeltaE() {
|
||||||
|
// classifyDeltaE is currently internal to swatch_grid.js, but we can
|
||||||
|
// validate the visual classification by exercising the colour thresholds.
|
||||||
|
// This is a placeholder for a future unit-test harness.
|
||||||
|
console.log('testClassifyDeltaE: no direct export; validated by manual inspection');
|
||||||
|
}
|
||||||
+371
-23
@@ -5,6 +5,11 @@
|
|||||||
--text-color: #d4d4d4;
|
--text-color: #d4d4d4;
|
||||||
--accent-color: #007acc;
|
--accent-color: #007acc;
|
||||||
--border-color: #333333;
|
--border-color: #333333;
|
||||||
|
--btn-height-sm: 28px;
|
||||||
|
--btn-height-md: 36px;
|
||||||
|
--btn-height-lg: 40px;
|
||||||
|
--btn-radius-sm: 4px;
|
||||||
|
--btn-radius-md: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -127,25 +132,27 @@ button.primary {
|
|||||||
background: linear-gradient(135deg, var(--accent-color), #2d73a8);
|
background: linear-gradient(135deg, var(--accent-color), #2d73a8);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--btn-radius-md);
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
height: 36px;
|
height: var(--btn-height-md);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.2s, transform 0.1s;
|
transition: opacity 0.2s, transform 0.1s;
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary:hover {
|
button.primary:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button sizing system */
|
/* Button sizing system — use CSS variables for consistency across the UI */
|
||||||
.btn-sm { height: 28px; padding: 0 10px; font-size: 0.78rem; border-radius: 4px; }
|
.btn-sm { height: var(--btn-height-sm); padding: 0 10px; font-size: 0.78rem; border-radius: var(--btn-radius-sm); box-sizing: border-box; }
|
||||||
.btn-md { height: 36px; padding: 0 16px; font-size: 0.875rem; border-radius: 6px; }
|
.btn-md { height: var(--btn-height-md); padding: 0 16px; font-size: 0.875rem; border-radius: var(--btn-radius-md); box-sizing: border-box; }
|
||||||
.btn-lg { height: 40px; padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: 6px; }
|
.btn-lg { height: var(--btn-height-lg); padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: var(--btn-radius-md); box-sizing: border-box; }
|
||||||
.btn-icon-sq { width: 36px; height: 36px; min-width: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
button.primary.btn-lg { height: var(--btn-height-lg); }
|
||||||
|
.btn-icon-sq { width: var(--btn-height-md); height: var(--btn-height-md); min-width: var(--btn-height-md); padding: 0; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; }
|
||||||
|
|
||||||
/* Action containers */
|
/* Action containers */
|
||||||
.stage-actions {
|
.stage-actions {
|
||||||
@@ -163,10 +170,63 @@ button.primary:hover {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility for a horizontal row of buttons (replaces inline style="display:flex;..." on parent divs) */
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row-sm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-row.wrap,
|
||||||
|
.btn-row-sm.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin: 14px 0;
|
margin: 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-header label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-label-small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group:first-child {
|
.form-group:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
@@ -254,6 +314,17 @@ select option:checked {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-row-sm {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row-sm > input,
|
||||||
|
.input-row-sm > select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.input-row input {
|
.input-row input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -284,9 +355,10 @@ button.secondary {
|
|||||||
color: white;
|
color: white;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
height: 36px;
|
height: var(--btn-height-md);
|
||||||
border-radius: 4px;
|
border-radius: var(--btn-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.secondary:hover {
|
button.secondary:hover {
|
||||||
@@ -489,6 +561,7 @@ button:disabled {
|
|||||||
.chartread-actions {
|
.chartread-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -497,9 +570,10 @@ button.danger {
|
|||||||
background-color: #c62828;
|
background-color: #c62828;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 10px 20px;
|
padding: 0 16px;
|
||||||
font-size: 1rem;
|
height: var(--btn-height-md);
|
||||||
border-radius: 4px;
|
font-size: 0.875rem;
|
||||||
|
border-radius: var(--btn-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,12 +853,12 @@ button.danger:hover {
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 4px;
|
border-radius: var(--btn-radius-sm);
|
||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
transition: color 0.15s ease, background-color 0.15s ease;
|
transition: color 0.15s ease, background-color 0.15s ease;
|
||||||
width: 28px;
|
width: var(--btn-height-sm);
|
||||||
height: 28px;
|
height: var(--btn-height-sm);
|
||||||
min-width: 28px;
|
min-width: var(--btn-height-sm);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -973,8 +1047,9 @@ button.danger:hover {
|
|||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
padding: 6px 12px;
|
padding: 0 12px;
|
||||||
border-radius: 4px;
|
height: var(--btn-height-md);
|
||||||
|
border-radius: var(--btn-radius-sm);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -984,6 +1059,7 @@ button.danger:hover {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-properties:hover {
|
.btn-properties:hover {
|
||||||
@@ -1042,20 +1118,24 @@ button.danger:hover {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Legacy alias: .btn-icon should not be used for new buttons; prefer .btn-icon-sq */
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
|
width: var(--btn-height-md);
|
||||||
|
height: var(--btn-height-md);
|
||||||
|
min-width: var(--btn-height-md);
|
||||||
|
padding: 0;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
width: 34px;
|
border-radius: var(--btn-radius-sm);
|
||||||
height: 34px;
|
display: inline-flex;
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon:hover {
|
.btn-icon:hover {
|
||||||
@@ -1078,7 +1158,7 @@ button.danger:hover {
|
|||||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-printing {
|
.badge-printing, .badge-primary {
|
||||||
background: rgba(33, 150, 243, 0.15);
|
background: rgba(33, 150, 243, 0.15);
|
||||||
color: #64b5f6;
|
color: #64b5f6;
|
||||||
border: 1px solid rgba(33, 150, 243, 0.3);
|
border: 1px solid rgba(33, 150, 243, 0.3);
|
||||||
@@ -1450,6 +1530,43 @@ button.danger:hover {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gamut-legend-item.gamut-slider-item {
|
||||||
|
margin: -2px 0 8px 27px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamut-slider-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
min-width: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamut-slider-item input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
height: 4px;
|
||||||
|
accent-color: var(--accent-color, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamut-controls-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamut-controls-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamut-keyboard-hint {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.gamut-legend-label {
|
.gamut-legend-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1554,5 +1671,236 @@ button.danger:hover {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
Longitudinal Printer Drift Tracking & Verification History — Stage 5
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.drift-history {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-filter-row select {
|
||||||
|
max-width: 240px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 240px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#driftTrendChart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-chart-grid line {
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-chart-axis line,
|
||||||
|
.drift-chart-axis path {
|
||||||
|
stroke: rgba(255, 255, 255, 0.2);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-chart-text {
|
||||||
|
fill: #888;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-band-label {
|
||||||
|
fill: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 9px;
|
||||||
|
text-anchor: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-line-avg {
|
||||||
|
fill: none;
|
||||||
|
stroke: #3b82f6;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-line-max {
|
||||||
|
fill: none;
|
||||||
|
stroke: #f59e0b;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-dasharray: 4, 3;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-dot-avg {
|
||||||
|
fill: #3b82f6;
|
||||||
|
stroke: #1e293b;
|
||||||
|
stroke-width: 2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: r 0.15s ease, fill 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-dot-avg:hover {
|
||||||
|
r: 6;
|
||||||
|
fill: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-dot-max {
|
||||||
|
fill: #f59e0b;
|
||||||
|
stroke: #1e293b;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: r 0.15s ease, fill 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-dot-max:hover {
|
||||||
|
r: 5;
|
||||||
|
fill: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table-wrap {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-surface, #1e1e1e);
|
||||||
|
color: var(--text-muted, #aaa);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drift-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
XY Automated Scanning Table Sequence — Stage 3
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.xy-table-panel {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-table-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-steps-list {
|
||||||
|
list-style: none;
|
||||||
|
counter-reset: xy-step-counter;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step {
|
||||||
|
counter-increment: xy-step-counter;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step::before {
|
||||||
|
content: counter(xy-step-counter);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step.active {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border-color: rgba(59, 130, 246, 0.5);
|
||||||
|
color: #93c5fd;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step.active::before {
|
||||||
|
background: var(--accent-color, #3b82f6);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step.completed {
|
||||||
|
color: #86efac;
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xy-step.completed::before {
|
||||||
|
content: "✓";
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user