This commit is contained in:
parent
1d9d92341f
commit
0f955e6c82
|
|
@ -60,6 +60,12 @@ jobs:
|
|||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
||||
- name: Scorecard up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
wasm:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
name: Perf (nightly)
|
||||
|
||||
# The performance scorecard (#221): `cargo xtask perf` on a fixed macOS runner (Apple silicon,
|
||||
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
|
||||
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
|
||||
# more than 15 % against perf/baseline.json from the same machine class (other classes are
|
||||
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
|
||||
#
|
||||
# To give this runner class its own baseline: run it by hand with "update_baseline", download
|
||||
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
quick:
|
||||
description: "Quick subset (small documents, no budgets)"
|
||||
type: boolean
|
||||
default: false
|
||||
update_baseline:
|
||||
description: "Also write perf/baseline.json from this run (uploaded as an artifact, not committed)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: perf-nightly
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
perf:
|
||||
name: perf (macOS, Apple GPU)
|
||||
# Pinned image, not -latest: the machine class must stay fixed for comparisons.
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: perf-release
|
||||
|
||||
- name: Run benchmarks
|
||||
id: perf
|
||||
env:
|
||||
QUICK: ${{ inputs.quick }}
|
||||
UPDATE: ${{ inputs.update_baseline }}
|
||||
run: |
|
||||
args=()
|
||||
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
|
||||
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
|
||||
cargo xtask perf "${args[@]}"
|
||||
|
||||
- name: Job summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f target/perf/summary.md ]; then
|
||||
cat target/perf/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No perf summary: the run failed before writing one (see the log)." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-results
|
||||
path: |
|
||||
target/perf/*.json
|
||||
target/perf/summary.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload baseline
|
||||
if: always() && inputs.update_baseline
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-baseline
|
||||
path: perf/baseline.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
17
AGENTS.md
17
AGENTS.md
|
|
@ -13,6 +13,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
|||
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
|
||||
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
|
||||
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
|
||||
## 2. Workspace map
|
||||
|
|
@ -32,7 +33,7 @@ apps/
|
|||
photocraft desktop app (eframe/wgpu), TCP control server
|
||||
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
|
||||
photocraft-web the same app in the browser (trunk + wasm-bindgen)
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity | perf | scorecard
|
||||
```
|
||||
|
||||
**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd`, `codecs` and `cms` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. A new crate must be registered in `xtask/src/layers.rs`.
|
||||
|
|
@ -71,9 +72,12 @@ Priorities: important infrastructure first, then low-hanging parity, then the lo
|
|||
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
|
||||
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
|
||||
round trips, workflow tests, performance), update that section with the dated figure.
|
||||
1. `docs/roadmap.md` → **Current focus**.
|
||||
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
3. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
|
||||
the performance scenarios that are over budget or not measurable yet, and the count of
|
||||
settings that do nothing. Its numbers are measured; prefer them to estimates.
|
||||
2. `docs/roadmap.md` → **Current focus**.
|
||||
3. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
4. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
|
||||
When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it).
|
||||
|
||||
|
|
@ -86,6 +90,10 @@ cargo xtask layers
|
|||
cargo xtask wasm # if you touched L0–L6
|
||||
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
|
||||
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
|
||||
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
|
||||
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
|
||||
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
|
||||
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
|
||||
```
|
||||
|
||||
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
|
||||
|
|
@ -105,6 +113,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
|||
|
||||
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
|
||||
- `docs/parity.md`: generated Photoshop menu coverage.
|
||||
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
|
||||
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
|
||||
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
|
||||
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
|
||||
|
|
|
|||
|
|
@ -2621,6 +2621,16 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memory-stats"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
|
@ -3667,9 +3677,11 @@ dependencies = [
|
|||
name = "photocraft-testkit"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3708,6 +3720,7 @@ dependencies = [
|
|||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
|
|
@ -6258,7 +6271,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
|||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -11,3 +11,8 @@ workspace = true
|
|||
photocraft-geom = { workspace = true }
|
||||
photocraft-color = { workspace = true }
|
||||
photocraft-raster = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0).
|
||||
memory-stats = "1.2"
|
||||
|
|
|
|||
|
|
@ -1 +1,5 @@
|
|||
//! photocraft-testkit
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod perf;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
//! Benchmark helpers shared by the `--json` benches that `cargo xtask perf` runs (#221).
|
||||
//!
|
||||
//! - [`RssSampler`]: peak resident memory measured in-process. A background thread polls the
|
||||
//! process's resident set size every few milliseconds; [`RssSampler::reset`] starts a new
|
||||
//! window, so each scenario gets its own peak. Polling can miss a spike shorter than the
|
||||
//! interval; on Linux [`process_peak_rss_bytes`] also reads the kernel's exact high-water mark.
|
||||
//! - [`Summary`]: p50 / p95 / max of a scenario's samples (nearest-rank percentiles).
|
||||
//! - [`row`] / [`report`]: the JSON shape `xtask perf` reads.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Current resident set size of this process, in bytes.
|
||||
pub fn current_rss_bytes() -> Option<u64> {
|
||||
memory_stats::memory_stats().map(|m| m.physical_mem as u64)
|
||||
}
|
||||
|
||||
/// The process's peak resident set size so far: the kernel's high-water mark on Linux
|
||||
/// (`VmHWM`), `None` elsewhere (use an [`RssSampler`]).
|
||||
pub fn process_peak_rss_bytes() -> Option<u64> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
parse_vm_hwm(&status)
|
||||
}
|
||||
|
||||
fn parse_vm_hwm(status: &str) -> Option<u64> {
|
||||
let line = status.lines().find(|l| l.starts_with("VmHWM:"))?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
kb.checked_mul(1024)
|
||||
}
|
||||
|
||||
/// Polls the resident set size on a background thread and keeps the maximum.
|
||||
pub struct RssSampler {
|
||||
peak: Arc<AtomicU64>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RssSampler {
|
||||
/// Starts sampling every `interval` (2–5 ms is cheap: one syscall per sample).
|
||||
pub fn start(interval: Duration) -> Self {
|
||||
let peak = Arc::new(AtomicU64::new(current_rss_bytes().unwrap_or(0)));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (p, s) = (Arc::clone(&peak), Arc::clone(&stop));
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("rss-sampler".into())
|
||||
.spawn(move || {
|
||||
while !s.load(Ordering::Relaxed) {
|
||||
if let Some(v) = current_rss_bytes() {
|
||||
p.fetch_max(v, Ordering::Relaxed);
|
||||
}
|
||||
std::thread::sleep(interval);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
Self { peak, stop, thread }
|
||||
}
|
||||
|
||||
/// Starts a new window: the peak becomes the current resident size.
|
||||
pub fn reset(&self) {
|
||||
self.peak.store(current_rss_bytes().unwrap_or(0), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The peak resident size since the last [`reset`](Self::reset) (or start), including now.
|
||||
pub fn peak(&self) -> Option<u64> {
|
||||
if let Some(v) = current_rss_bytes() {
|
||||
self.peak.fetch_max(v, Ordering::Relaxed);
|
||||
}
|
||||
Some(self.peak.load(Ordering::Relaxed)).filter(|v| *v > 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RssSampler {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(t) = self.thread.take() {
|
||||
// A sampler thread that panicked has nothing to report; don't cascade.
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// p50 / p95 / max of a set of timings (ms).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Summary {
|
||||
pub n: usize,
|
||||
pub p50: f64,
|
||||
pub p95: f64,
|
||||
pub max: f64,
|
||||
}
|
||||
|
||||
/// Nearest-rank percentile `q` (0..=1) of sorted, non-empty `v`.
|
||||
fn rank(v: &[f64], q: f64) -> Option<f64> {
|
||||
let n = v.len();
|
||||
let k = ((q * n as f64).ceil() as usize).clamp(1, n.max(1));
|
||||
v.get(k - 1).copied()
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
/// `None` when there are no finite samples.
|
||||
pub fn of(samples: &[f64]) -> Option<Self> {
|
||||
let mut v: Vec<f64> = samples.iter().copied().filter(|x| x.is_finite()).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
Some(Self { n: v.len(), p50: rank(&v, 0.5)?, p95: rank(&v, 0.95)?, max: *v.last()? })
|
||||
}
|
||||
}
|
||||
|
||||
/// One scenario row: `name`, sample count, p50/p95/max, the raw samples, peak RSS and GPU bytes.
|
||||
pub fn row(name: &str, samples: &[f64], peak_rss: Option<u64>, gpu_bytes: Option<u64>) -> Value {
|
||||
let s = Summary::of(samples);
|
||||
json!({
|
||||
"name": name,
|
||||
"n": s.map_or(0, |s| s.n),
|
||||
"p50_ms": s.map(|s| s.p50),
|
||||
"p95_ms": s.map(|s| s.p95),
|
||||
"max_ms": s.map(|s| s.max),
|
||||
"samples_ms": samples,
|
||||
"peak_rss_bytes": peak_rss,
|
||||
"gpu_bytes": gpu_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// A bench's whole report: its name, free-form context (document size, adapter…), the rows and
|
||||
/// the process's peak RSS (the kernel's high-water mark where there is one, else the highest
|
||||
/// sampled peak).
|
||||
pub fn report(bench: &str, context: Value, rows: Vec<Value>, sampler: Option<&RssSampler>) -> Value {
|
||||
let sampled = rows.iter().filter_map(|r| r["peak_rss_bytes"].as_u64()).chain(sampler.and_then(RssSampler::peak)).max();
|
||||
let process_peak = process_peak_rss_bytes().or(sampled);
|
||||
json!({
|
||||
"bench": bench,
|
||||
"schema": 1,
|
||||
"context": context,
|
||||
"rows": rows,
|
||||
"process_peak_rss_bytes": process_peak,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `report` to `path` (pretty JSON). Errors are returned, not panicked.
|
||||
pub fn write_report(path: &str, report: &Value) -> Result<(), String> {
|
||||
let text = serde_json::to_string_pretty(report).map_err(|e| format!("encode {path}: {e}"))?;
|
||||
std::fs::write(path, text).map_err(|e| format!("write {path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn summary_nearest_rank() {
|
||||
let v: Vec<f64> = (1..=20).map(f64::from).collect();
|
||||
let s = Summary::of(&v).unwrap();
|
||||
assert_eq!((s.n, s.p50, s.p95, s.max), (20, 10.0, 19.0, 20.0));
|
||||
let s = Summary::of(&[3.0]).unwrap();
|
||||
assert_eq!((s.p50, s.p95, s.max), (3.0, 3.0, 3.0));
|
||||
assert!(Summary::of(&[]).is_none());
|
||||
assert!(Summary::of(&[f64::NAN]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vm_hwm_parses() {
|
||||
assert_eq!(parse_vm_hwm("Name:\tx\nVmHWM:\t 2048 kB\nVmRSS: 1 kB\n"), Some(2048 * 1024));
|
||||
assert_eq!(parse_vm_hwm("VmHWM: lots\n"), None);
|
||||
assert_eq!(parse_vm_hwm(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampler_reports_a_peak() {
|
||||
let s = RssSampler::start(Duration::from_millis(1));
|
||||
s.reset();
|
||||
let big = vec![1u8; 32 << 20];
|
||||
std::hint::black_box(&big);
|
||||
let p = s.peak();
|
||||
drop(big);
|
||||
// Platforms without a memory API report nothing rather than a made-up number.
|
||||
if current_rss_bytes().is_some() {
|
||||
assert!(p.unwrap() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_shape() {
|
||||
let r = row("x", &[2.0, 1.0, 3.0], Some(10), None);
|
||||
assert_eq!(r["p50_ms"], 2.0);
|
||||
assert_eq!(r["max_ms"], 3.0);
|
||||
assert_eq!(r["n"], 3);
|
||||
assert!(r["gpu_bytes"].is_null());
|
||||
let empty = row("y", &[], None, None);
|
||||
assert!(empty["p95_ms"].is_null());
|
||||
}
|
||||
}
|
||||
|
|
@ -37,3 +37,5 @@ photocraft-io = { workspace = true }
|
|||
# Only for examples/interactive_bench.rs (JPEG encode, .pcraft saves).
|
||||
photocraft-codecs = { workspace = true }
|
||||
photocraft-format = { workspace = true }
|
||||
# Benchmark helpers for the `--json` benches `cargo xtask perf` runs (in-process RSS, percentiles).
|
||||
photocraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ struct Gpu {
|
|||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
comp: photocraft_gpu::Compositor,
|
||||
adapter: String,
|
||||
}
|
||||
|
||||
fn gpu() -> Option<Gpu> {
|
||||
|
|
@ -142,7 +143,7 @@ fn gpu() -> Option<Gpu> {
|
|||
let limits = photocraft_ui_egui::gpu_canvas::device_limits(&adapter);
|
||||
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?;
|
||||
let comp = photocraft_gpu::Compositor::new(&device);
|
||||
Some(Gpu { device, queue, comp })
|
||||
Some(Gpu { device, queue, comp, adapter: adapter.get_info().name })
|
||||
}
|
||||
|
||||
fn gpu_time(g: &mut Gpu, doc: &Document, region: Rect) -> Result<f64, String> {
|
||||
|
|
@ -227,6 +228,7 @@ fn main() {
|
|||
let full = doc.bounds();
|
||||
println!("document {}x{} ({} layers)", doc.size.width, doc.size.height, doc.walk().len());
|
||||
let mut g = gpu();
|
||||
let adapter = g.as_ref().map(|g| g.adapter.clone());
|
||||
let mut gt = |doc: &Document, r: Rect| match g.as_mut() {
|
||||
Some(g) => gpu_time(g, doc, r),
|
||||
None => Err("no adapter".into()),
|
||||
|
|
@ -246,8 +248,13 @@ fn main() {
|
|||
};
|
||||
println!("{what:<42} {mp:6.2} MP cpu {c:>22} gpu {g} (min / median)");
|
||||
};
|
||||
// `--json out.json` (`cargo xtask perf`): one row per case and compositor, with samples and
|
||||
// the peak RSS while the case ran.
|
||||
let rss = photocraft_testkit::perf::RssSampler::start(std::time::Duration::from_millis(2));
|
||||
let mut json_rows: Vec<serde_json::Value> = Vec::new();
|
||||
// Each case: `edit` mutates the document, then both compositors refresh `region`.
|
||||
let mut case = |doc: &mut Document, what: &str, n: usize, edit: &mut dyn FnMut(&mut Document, usize) -> Rect, cpu_too: bool| {
|
||||
rss.reset();
|
||||
let (mut cs, mut gs, mut gerr) = (Vec::new(), Vec::new(), None);
|
||||
let mut region = full;
|
||||
for i in 0..n {
|
||||
|
|
@ -260,6 +267,13 @@ fn main() {
|
|||
Err(e) => gerr = Some(e),
|
||||
}
|
||||
}
|
||||
let peak = rss.peak();
|
||||
if !gs.is_empty() {
|
||||
json_rows.push(photocraft_testkit::perf::row(&format!("{what} (GPU)"), &gs, peak, None));
|
||||
}
|
||||
if !cs.is_empty() {
|
||||
json_rows.push(photocraft_testkit::perf::row(&format!("{what} (CPU)"), &cs, peak, None));
|
||||
}
|
||||
show(
|
||||
what,
|
||||
region,
|
||||
|
|
@ -350,4 +364,12 @@ fn main() {
|
|||
},
|
||||
true,
|
||||
);
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let context = json!({"width": w, "height": h, "gpu_adapter": adapter});
|
||||
let report = photocraft_testkit::perf::report("fx_bench", context, json_rows, Some(&rss));
|
||||
if let Err(e) = photocraft_testkit::perf::write_report(&out, &report) {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ struct Bench {
|
|||
/// state later rows need).
|
||||
only: Option<String>,
|
||||
rows: Vec<(String, f64, f64, f64, f64)>,
|
||||
/// Per row (same order as `rows`): the timed samples and the peak RSS while they ran, for
|
||||
/// `--json` (`cargo xtask perf`).
|
||||
extra: Vec<(Vec<f64>, Option<u64>)>,
|
||||
rss: photocraft_testkit::perf::RssSampler,
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
|
|
@ -116,8 +120,10 @@ impl Bench {
|
|||
f(self);
|
||||
return;
|
||||
}
|
||||
self.rss.reset();
|
||||
let cold = f(self);
|
||||
let mut v: Vec<f64> = (0..self.reps).map(|_| f(self)).collect();
|
||||
self.extra.push((v.clone(), self.rss.peak()));
|
||||
v.sort_by(f64::total_cmp);
|
||||
let med = v[v.len() / 2];
|
||||
println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1})", v[0], v[v.len() - 1], cold);
|
||||
|
|
@ -161,7 +167,14 @@ fn main() {
|
|||
let (w, h) =
|
||||
arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912));
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5).max(1);
|
||||
let mut b = Bench { gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() }, reps, only: arg(&args, "--only"), rows: Vec::new() };
|
||||
let mut b = Bench {
|
||||
gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() },
|
||||
reps,
|
||||
only: arg(&args, "--only"),
|
||||
rows: Vec::new(),
|
||||
extra: Vec::new(),
|
||||
rss: photocraft_testkit::perf::RssSampler::start(std::time::Duration::from_millis(2)),
|
||||
};
|
||||
println!("document {w}×{h} ({:.1} MP), 8-bit RGB, {} reps, {}", (w * h) as f64 / 1e6, reps, if b.gpu.is_some() { "GPU canvas" } else { "CPU canvas" });
|
||||
|
||||
// ---- open / save / export ----------------------------------------------------------------
|
||||
|
|
@ -399,8 +412,18 @@ fn main() {
|
|||
});
|
||||
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let rows: Vec<Value> =
|
||||
b.rows.iter().map(|(n, med, min, max, cold)| json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold})).collect();
|
||||
let rows: Vec<Value> = b
|
||||
.rows
|
||||
.iter()
|
||||
.zip(&b.extra)
|
||||
.map(|((n, med, min, max, cold), (samples, rss))| {
|
||||
let mut r = json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold});
|
||||
// For `cargo xtask perf`: raw samples (it computes p50 / p95) and peak RSS.
|
||||
r["samples_ms"] = json!(samples);
|
||||
r["peak_rss_bytes"] = json!(rss);
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(&out, serde_json::to_string_pretty(&json!({"width": w, "height": h, "gpu": b.gpu.is_some(), "rows": rows})).unwrap())
|
||||
.expect("write json");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,10 @@ fn main() {
|
|||
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).expect("jpeg");
|
||||
std::fs::write(&src, jpeg).expect("write cache");
|
||||
eprintln!("generated {} in {:.0} ms (run again to measure)", src.display(), ms(t));
|
||||
return;
|
||||
// `--json` (`cargo xtask perf`) measures in the same run.
|
||||
if arg(&args, "--json").is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() };
|
||||
let gpu = gpu.as_ref();
|
||||
|
|
@ -119,23 +122,37 @@ fn main() {
|
|||
let t_open = ms(t);
|
||||
let t = Instant::now();
|
||||
let path = refresh(gpu, &s, true);
|
||||
let t_first = ms(t);
|
||||
println!("open (decode + document) {t_open:>9.0} ms");
|
||||
println!("first refresh ({path:<8}) {:>9.0} ms", ms(t));
|
||||
println!("first refresh ({path:<8}) {t_first:>9.0} ms");
|
||||
// `--json out.json` (`cargo xtask perf`): open and first refresh (every op includes them),
|
||||
// the canvas texture size and the process's peak RSS.
|
||||
let json_out = arg(&args, "--json");
|
||||
let mut json_rows = vec![
|
||||
photocraft_testkit::perf::row("open (decode + document)", &[t_open], None, None),
|
||||
photocraft_testkit::perf::row("first refresh", &[t_first], None, None),
|
||||
];
|
||||
if let Some((g, _)) = gpu
|
||||
&& let Some((format, bytes)) = g.texture_info(s.active().expect("doc").doc.id.0)
|
||||
{
|
||||
println!("canvas texture {format:?}, {} MB with mips", bytes >> 20);
|
||||
if let Some(r) = json_rows.get_mut(1) {
|
||||
r["gpu_bytes"] = json!(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
let doc = || s.active().expect("doc").doc.clone();
|
||||
match op.as_str() {
|
||||
"open" => {}
|
||||
"refresh" => {
|
||||
let mut v = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let t = Instant::now();
|
||||
let path = refresh(gpu, &s, true);
|
||||
v.push(ms(t));
|
||||
println!("full refresh ({path:<8}) {:>9.0} ms", ms(t));
|
||||
}
|
||||
json_rows.push(photocraft_testkit::perf::row("full refresh", &v, None, None));
|
||||
}
|
||||
"thumbs" => {
|
||||
let d = doc();
|
||||
|
|
@ -181,4 +198,13 @@ fn main() {
|
|||
}
|
||||
other => eprintln!("unknown --op {other}"),
|
||||
}
|
||||
if let Some(out) = json_out {
|
||||
let peak = photocraft_testkit::perf::process_peak_rss_bytes().or_else(photocraft_testkit::perf::current_rss_bytes);
|
||||
let context = json!({"width": w, "height": h, "op": op, "gpu_adapter": gpu.map(|(_, rs)| rs.adapter.get_info().name)});
|
||||
let mut report = photocraft_testkit::perf::report("large_image_bench", context, json_rows, None);
|
||||
report["process_peak_rss_bytes"] = json!(peak);
|
||||
if let Err(e) = photocraft_testkit::perf::write_report(&out, &report) {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,638 @@
|
|||
//! Scorecard performance scenarios (#221): the measurable rows of the targets in #209, #210 and
|
||||
//! #211, each timed through the command path the UI, CLI and MCP share (`Session::execute`) plus
|
||||
//! the canvas refresh the app does after it (the app's own `GpuCanvas`, on a device created the
|
||||
//! way the app creates it; the CPU compositor without an adapter).
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-ui-egui --example perf_scenarios -- [--quick] [--json out.json] [--only text] [--reps N] [--cpu]
|
||||
//! ```
|
||||
//!
|
||||
//! `cargo xtask perf` runs this and maps the rows to scenario ids in `perf/budgets.toml`, so
|
||||
//! keep row names stable. `--quick` uses small synthetic documents (seconds, for smoke runs);
|
||||
//! its numbers are only comparable with other `--quick` runs. Every row records p50 / p95 / max
|
||||
//! over its samples, the peak resident memory measured in-process while it ran, and the GPU
|
||||
//! bytes the canvas holds for the document (texture + compositor) when there is a GPU.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui_wgpu::RenderState;
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_testkit::perf::{RssSampler, report, row, write_report};
|
||||
use photocraft_ui_egui::gpu_canvas::GpuCanvas;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type Res<T> = Result<T, String>;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
fn exec(s: &mut Session, id: &str, p: Value) -> Res<Value> {
|
||||
s.execute(id, p).map_err(|e| format!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn active_layer(s: &Session) -> Res<u64> {
|
||||
s.active().and_then(|d| d.active_layer).map(|l| l.0).ok_or_else(|| "no active layer".to_string())
|
||||
}
|
||||
|
||||
/// A photo-like RGB image (gradients, soft blobs, fine noise) with a high-contrast disc as the
|
||||
/// subject, encoded as JPEG so it opens like a real photo.
|
||||
fn photo_jpeg(w: u32, h: u32) -> Res<Vec<u8>> {
|
||||
use rayon::prelude::*;
|
||||
let mut px = vec![0u8; w as usize * h as usize * 3];
|
||||
let (cx, cy, r) = (w as f32 * 0.55, h as f32 * 0.5, w.min(h) as f32 / 3.0);
|
||||
px.par_chunks_mut(w as usize * 3).enumerate().for_each(|(y, row)| {
|
||||
for x in 0..w as usize {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
|
||||
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
|
||||
let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < r;
|
||||
let (r0, g0, b0) = if inside { (0.85, 0.3 + 0.2 * (fx * 40.0).sin(), 0.15) } else { (0.35, 0.45, 0.55) };
|
||||
let rgb = [r0 + 0.1 * blob + n * 0.08, g0 + 0.1 * blob + n * 0.08, b0 + 0.2 * fy + n * 0.08];
|
||||
for (c, v) in rgb.into_iter().enumerate() {
|
||||
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
let img =
|
||||
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn open_photo(w: u32, h: u32) -> Res<Session> {
|
||||
let jpeg = photo_jpeg(w, h)?;
|
||||
let doc = photocraft_io::import("photo.jpg", &jpeg).map_err(|e| e.to_string())?.document;
|
||||
let mut s = Session::new();
|
||||
s.open_document(doc, Some("photo.jpg".into()));
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// How far an edit's composite change reaches beyond its damage (layer effects); mirrors
|
||||
/// `canvas::effect_reach`.
|
||||
fn reach(layers: &[photocraft_doc::Layer]) -> i32 {
|
||||
layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let own = if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 };
|
||||
let kids = match &l.content {
|
||||
photocraft_doc::LayerContent::Group(g) => reach(&g.children),
|
||||
_ => 0,
|
||||
};
|
||||
own + kids
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
struct Bench {
|
||||
gpu: Option<(GpuCanvas, RenderState)>,
|
||||
sampler: RssSampler,
|
||||
reps: usize,
|
||||
only: Option<String>,
|
||||
rows: Vec<Value>,
|
||||
/// Rows that couldn't run: (name, error).
|
||||
errors: Vec<(String, String)>,
|
||||
/// The row being timed, so a panic inside it is reported against that row.
|
||||
current: Option<String>,
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
fn wanted(&self, name: &str) -> bool {
|
||||
self.only.as_ref().is_none_or(|o| name.contains(o.as_str()))
|
||||
}
|
||||
|
||||
/// The canvas refresh after an edit (`canvas::ensure_gpu`): the damage rect grown by the
|
||||
/// effect reach when the session reports one, else everything; waits for the GPU.
|
||||
fn refresh(&self, s: &Session, full: bool) -> Res<f64> {
|
||||
let st = s.active().ok_or("no document")?;
|
||||
let doc = &st.doc;
|
||||
let damage = if full { None } else { st.last_damage.map(|r| r.inflate(reach(&doc.layers)).intersect(&doc.bounds())) };
|
||||
let t = Instant::now();
|
||||
match &self.gpu {
|
||||
Some((g, rs)) => {
|
||||
g.refresh(doc.id.0, doc, damage, None);
|
||||
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
None => {
|
||||
let r = damage.unwrap_or(doc.bounds()).intersect(&doc.bounds());
|
||||
if !r.is_empty() {
|
||||
std::hint::black_box(photocraft_compose::render(doc, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ms(t))
|
||||
}
|
||||
|
||||
fn gpu_bytes(&self, s: &Session) -> Option<u64> {
|
||||
let (g, _) = self.gpu.as_ref()?;
|
||||
let tex = s.active().and_then(|d| g.texture_info(d.doc.id.0)).map_or(0, |(_, b)| b);
|
||||
let (resident, fx) = g.compositor_bytes().unwrap_or((0, 0));
|
||||
Some(tex + resident + fx as u64)
|
||||
}
|
||||
|
||||
/// Times `f` `reps` times after one untimed warm-up run; `f` returns its own time in ms.
|
||||
fn time(&mut self, name: &str, s: &mut Session, reps: usize, warm: bool, mut f: impl FnMut(&Self, &mut Session, usize) -> Res<f64>) {
|
||||
if !self.wanted(name) {
|
||||
return;
|
||||
}
|
||||
self.current = Some(name.to_string());
|
||||
self.sampler.reset();
|
||||
let mut samples = Vec::new();
|
||||
let mut run = || -> Res<()> {
|
||||
if warm {
|
||||
f(self, s, 0)?;
|
||||
}
|
||||
for i in 0..reps.max(1) {
|
||||
samples.push(f(self, s, i + 1)?);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let result = run();
|
||||
self.record(name, s, &samples, result);
|
||||
}
|
||||
|
||||
fn record(&mut self, name: &str, s: &Session, samples: &[f64], result: Res<()>) {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let r = row(name, samples, self.sampler.peak(), self.gpu_bytes(s));
|
||||
println!(
|
||||
"{name:<52} p50 {:>9.2} ms p95 {:>9.2} ms max {:>9.2} ms (n {}, peak RSS {} MB)",
|
||||
r["p50_ms"].as_f64().unwrap_or(f64::NAN),
|
||||
r["p95_ms"].as_f64().unwrap_or(f64::NAN),
|
||||
r["max_ms"].as_f64().unwrap_or(f64::NAN),
|
||||
samples.len(),
|
||||
r["peak_rss_bytes"].as_u64().unwrap_or(0) >> 20,
|
||||
);
|
||||
self.rows.push(r);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{name:<52} ERROR {e}");
|
||||
self.errors.push((name.to_string(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A headless GPU canvas on a device created like the app's.
|
||||
fn canvas() -> Option<(GpuCanvas, RenderState)> {
|
||||
let setup = photocraft_ui_egui::gpu_canvas::wgpu_setup();
|
||||
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(setup, Default::default()))).ok()?;
|
||||
eprintln!("adapter: {}", rs.adapter.get_info().name);
|
||||
Some((GpuCanvas::new(&rs), rs))
|
||||
}
|
||||
|
||||
/// Sizes per mode.
|
||||
struct Sizes {
|
||||
/// The layered document of #209 (20 MP).
|
||||
layered: (u32, u32),
|
||||
/// A layer of 12 MP to duplicate and paste.
|
||||
big_layer: (u32, u32),
|
||||
/// Layers in the "150-layer document".
|
||||
many_layers: usize,
|
||||
/// The 16-bit document (15000 × 10000).
|
||||
huge16: (u32, u32),
|
||||
/// The 32-bit document with 20 layers.
|
||||
float32: (u32, u32),
|
||||
/// 10 MP for Content-Aware Fill and Spot Healing.
|
||||
ten_mp: (u32, u32),
|
||||
/// 24 MP for the selection algorithms and Content-Aware Scale.
|
||||
big_photo: (u32, u32),
|
||||
/// A4 at 300 ppi.
|
||||
a4: (u32, u32),
|
||||
}
|
||||
|
||||
const FULL: Sizes = Sizes {
|
||||
layered: (5472, 3648),
|
||||
big_layer: (4243, 2828),
|
||||
many_layers: 150,
|
||||
huge16: (15000, 10000),
|
||||
float32: (4000, 3000),
|
||||
ten_mp: (3872, 2592),
|
||||
big_photo: (6000, 4000),
|
||||
a4: (2480, 3508),
|
||||
};
|
||||
|
||||
const QUICK: Sizes = Sizes {
|
||||
layered: (1600, 1066),
|
||||
big_layer: (1200, 800),
|
||||
many_layers: 40,
|
||||
huge16: (2400, 1600),
|
||||
float32: (1200, 900),
|
||||
ten_mp: (1200, 800),
|
||||
big_photo: (1600, 1066),
|
||||
a4: (1240, 1754),
|
||||
};
|
||||
|
||||
/// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke,
|
||||
/// four type layers with a drop shadow, and a Levels adjustment on top. Returns the session and
|
||||
/// the id of the styled layer the edits target.
|
||||
fn layered_doc(w: u32, h: u32) -> Res<(Session, u64)> {
|
||||
let mut s = Session::new();
|
||||
exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?;
|
||||
let mut target = 0;
|
||||
for i in 0..6u32 {
|
||||
exec(&mut s, "layer.new.layer", json!({"name": format!("panel {i}")}))?;
|
||||
let (x, y) = ((i % 3) * w / 3 + w / 24, (i / 3) * h / 2 + h / 16);
|
||||
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 4, "height": h / 3, "ellipse": i % 2 == 1}))?;
|
||||
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}6040", 40 + i * 30)}))?;
|
||||
exec(&mut s, "select.deselect", json!({}))?;
|
||||
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 12, "size": 16}))?;
|
||||
exec(&mut s, "layer.layerStyle.stroke", json!({"size": 3, "color": "#202020", "add": true}))?;
|
||||
if i == 1 {
|
||||
target = active_layer(&s)?;
|
||||
}
|
||||
}
|
||||
for i in 0..4u32 {
|
||||
exec(&mut s, "type.create", json!({"x": w / 10 + i * w / 5, "y": h * 9 / 10, "text": format!("Headline {i}"), "size": (h / 30).max(12)}))?;
|
||||
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?;
|
||||
}
|
||||
exec(&mut s, "layer.newAdjustmentLayer.levels", json!({"inBlack": 8, "inWhite": 245, "gamma": 1.05}))?;
|
||||
exec(&mut s, "layer.select", json!({"layer": target}))?;
|
||||
Ok((s, target))
|
||||
}
|
||||
|
||||
/// A document with `n` layers: small painted rects, every fifth a type layer, every seventh
|
||||
/// styled.
|
||||
fn many_layers_doc(w: u32, h: u32, n: usize) -> Res<(Session, u64)> {
|
||||
let mut s = Session::new();
|
||||
exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?;
|
||||
let mut mid = 0;
|
||||
for i in 0..n as u32 {
|
||||
let (x, y) = ((i * 397) % (w - w / 8), (i * 211) % (h - h / 8));
|
||||
if i % 5 == 4 {
|
||||
exec(&mut s, "type.create", json!({"x": x, "y": y + 40, "text": format!("Label {i}"), "size": 28}))?;
|
||||
} else {
|
||||
exec(&mut s, "layer.new.layer", json!({"name": format!("item {i}")}))?;
|
||||
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 10, "height": h / 10}))?;
|
||||
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}{:02x}80", (i * 37) % 256, (i * 91) % 256)}))?;
|
||||
exec(&mut s, "select.deselect", json!({}))?;
|
||||
}
|
||||
if i % 7 == 3 {
|
||||
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?;
|
||||
}
|
||||
if i as usize == n / 2 {
|
||||
mid = active_layer(&s)?;
|
||||
}
|
||||
}
|
||||
exec(&mut s, "layer.select", json!({"layer": mid}))?;
|
||||
Ok((s, mid))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let quick = args.iter().any(|a| a == "--quick");
|
||||
let sz = if quick { &QUICK } else { &FULL };
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).max(1);
|
||||
let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() };
|
||||
let adapter = gpu.as_ref().map(|(_, rs)| rs.adapter.get_info().name);
|
||||
let mut b = Bench {
|
||||
gpu,
|
||||
sampler: RssSampler::start(std::time::Duration::from_millis(2)),
|
||||
reps,
|
||||
only: arg(&args, "--only"),
|
||||
rows: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
current: None,
|
||||
};
|
||||
println!("perf_scenarios: {} mode, {} reps, {}", if quick { "quick" } else { "full" }, reps, adapter.as_deref().unwrap_or("CPU canvas"));
|
||||
let t_all = Instant::now();
|
||||
|
||||
// A panic in one group (a crash the scenario found) is reported against the row that was
|
||||
// running, and the other groups still run.
|
||||
type Group = fn(&mut Bench, &Sizes);
|
||||
let groups: [(&str, Group); 5] = [
|
||||
("layered", layered_scenarios),
|
||||
("many layers", many_layer_scenarios),
|
||||
("bit depths", depth_scenarios),
|
||||
("long operations", job_scenarios),
|
||||
("algorithms", algorithm_scenarios),
|
||||
];
|
||||
let use_gpu = b.gpu.is_some();
|
||||
for (i, (group, run)) in groups.into_iter().enumerate() {
|
||||
b.current = None;
|
||||
// Each group starts on a fresh canvas device (one document open, as in the app), so
|
||||
// GPU memory or an error left by one group never carries into the next.
|
||||
if use_gpu && i > 0 {
|
||||
b.gpu = None;
|
||||
b.gpu = canvas();
|
||||
}
|
||||
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&mut b, sz))) {
|
||||
let msg = e.downcast_ref::<String>().cloned().or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_else(|| "panic".into());
|
||||
let msg = format!("panicked: {}", msg.lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>().join(" / "));
|
||||
let row = b.current.take().unwrap_or_else(|| group.to_string());
|
||||
println!("{row:<52} ERROR {msg}");
|
||||
b.errors.push((row, msg));
|
||||
}
|
||||
}
|
||||
|
||||
println!("total {:.1} s", t_all.elapsed().as_secs_f64());
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let context = json!({
|
||||
"mode": if quick { "quick" } else { "full" },
|
||||
"reps": reps,
|
||||
"gpu_adapter": adapter,
|
||||
"errors": b.errors.iter().map(|(n, e)| json!({"name": n, "error": e})).collect::<Vec<_>>(),
|
||||
});
|
||||
let rep = report("perf_scenarios", context, std::mem::take(&mut b.rows), Some(&b.sampler));
|
||||
if let Err(e) = write_report(&out, &rep) {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #209 rows on the layered 20 MP document.
|
||||
fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let (w, h) = sz.layered;
|
||||
let (mut s, target) = match layered_doc(w, h) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
b.errors.push(("layered document".into(), e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = b.refresh(&s, true);
|
||||
let reps = b.reps;
|
||||
b.time("move styled layer 10 px + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let d = if i % 2 == 0 { 10 } else { -10 };
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.translate", json!({"layer": target, "dx": d, "dy": d / 2}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("set layer opacity + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "opacity": if i % 2 == 0 { 0.6 } else { 0.9 }}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("set layer fill + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "fill": if i % 2 == 0 { 0.5 } else { 1.0 }}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("set blend mode + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "blend": if i % 2 == 0 { "Multiply" } else { "Normal" }}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("toggle visibility + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "visible": i % 2 == 0}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
let _ = exec(&mut s, "layer.setProps", json!({"layer": target, "visible": true}));
|
||||
b.time("undo move + refresh", &mut s, reps, true, |b, s, _| {
|
||||
exec(s, "layer.translate", json!({"layer": target, "dx": 7, "dy": 3}))?;
|
||||
b.refresh(s, false)?;
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("redo opacity change + refresh", &mut s, reps, true, |b, s, i| {
|
||||
exec(s, "layer.setProps", json!({"layer": target, "opacity": 0.5 + 0.02 * i as f64}))?;
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
b.refresh(s, false)?;
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.redo", json!({}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
|
||||
// A 12 MP layer, duplicated and pasted: the first time (a fresh layer, cold caches) and
|
||||
// again (the same source).
|
||||
let (lw, lh) = sz.big_layer;
|
||||
let fresh_layer = |s: &mut Session| -> Res<u64> {
|
||||
exec(s, "layer.new.layer", json!({"name": "12 MP"}))?;
|
||||
exec(s, "select.rect", json!({"x": (w - lw) / 2, "y": (h - lh) / 2, "width": lw, "height": lh}))?;
|
||||
exec(s, "edit.fill", json!({"color": "#3070a0"}))?;
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
active_layer(s)
|
||||
};
|
||||
let reps_big = reps.min(5);
|
||||
let mut src = 0;
|
||||
b.time("duplicate 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| {
|
||||
src = fresh_layer(s)?;
|
||||
b.refresh(s, false)?;
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.duplicate", json!({"layer": src}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("duplicate 12 MP layer (repeat) + refresh", &mut s, reps_big, false, |b, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.duplicate", json!({"layer": src}))?;
|
||||
let v = ms(t) + b.refresh(s, false)?;
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
b.refresh(s, false)?;
|
||||
Ok(v)
|
||||
});
|
||||
b.time("paste 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| {
|
||||
let id = fresh_layer(s)?;
|
||||
exec(s, "layer.select", json!({"layer": id}))?;
|
||||
exec(s, "select.all", json!({}))?;
|
||||
exec(s, "edit.copy", json!({}))?;
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
b.refresh(s, false)?;
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.paste", json!({}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("paste 12 MP layer (repeat) + refresh", &mut s, reps_big, false, |b, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.paste", json!({}))?;
|
||||
let v = ms(t) + b.refresh(s, false)?;
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
b.refresh(s, false)?;
|
||||
Ok(v)
|
||||
});
|
||||
}
|
||||
|
||||
/// #209: arrow-key nudge in the 150-layer document.
|
||||
fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
// Row names stay stable across modes: the layer count is in the mode, not the key.
|
||||
let name = "nudge 1 px in the many-layer document + refresh".to_string();
|
||||
if !b.wanted(&name) {
|
||||
return;
|
||||
}
|
||||
// The document's first refresh is part of this row (a crash there is reported against it).
|
||||
b.current = Some(name.clone());
|
||||
let (w, h) = sz.layered;
|
||||
let (mut s, mid) = match many_layers_doc(w, h, sz.many_layers) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
b.errors.push((name, e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = b.refresh(&s, true);
|
||||
let reps = b.reps;
|
||||
b.time(&name, &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.translate", json!({"layer": mid, "dx": if i % 2 == 0 { 1 } else { -1 }, "dy": 0}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
|
||||
/// #209: edits on the 15000 × 10000 16-bit document and the 32-bit document with 20 layers.
|
||||
fn depth_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let reps = b.reps;
|
||||
for (label, (w, h), depth, layers) in [("16-bit 15000x10000", sz.huge16, 16, 1u32), ("32-bit 20 layers", sz.float32, 32, 20)] {
|
||||
let dab = format!("brush dab 60 px on {label} + refresh");
|
||||
let mv = format!("move layer on {label} + refresh");
|
||||
if !b.wanted(&dab) && !b.wanted(&mv) {
|
||||
continue;
|
||||
}
|
||||
let built = (|| -> Res<(Session, u64)> {
|
||||
let mut s = Session::new();
|
||||
exec(&mut s, "file.new", json!({"width": w, "height": h, "depth": depth, "background": "white"}))?;
|
||||
let mut first = 0;
|
||||
for i in 0..layers {
|
||||
exec(&mut s, "layer.new.layer", json!({"name": format!("layer {i}")}))?;
|
||||
let (x, y) = ((i * 173) % (w * 3 / 4), (i * 97) % (h * 3 / 4));
|
||||
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 4, "height": h / 4}))?;
|
||||
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}8060", (i * 41) % 256)}))?;
|
||||
exec(&mut s, "select.deselect", json!({}))?;
|
||||
if i == 0 {
|
||||
first = active_layer(&s)?;
|
||||
}
|
||||
}
|
||||
Ok((s, first))
|
||||
})();
|
||||
let (mut s, layer) = match built {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
b.errors.push((dab, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = b.refresh(&s, true);
|
||||
let _ = exec(&mut s, "layer.select", json!({"layer": layer}));
|
||||
b.time(&dab, &mut s, reps, true, |b, s, i| {
|
||||
let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400));
|
||||
let t = Instant::now();
|
||||
exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 60, "hardness": 0.8, "color": "#c03020"}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time(&mv, &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.translate", json!({"layer": layer, "dx": if i % 2 == 0 { 10 } else { -10 }, "dy": 0}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// #210 rows that are measurable before the job system exists.
|
||||
fn job_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let reps = b.reps.min(5);
|
||||
let open = "open 20 MP layered PSD (decode + first composite)";
|
||||
if b.wanted(open) {
|
||||
let (w, h) = sz.layered;
|
||||
match layered_doc(w, h).and_then(|(s, _)| {
|
||||
let d = s.active().ok_or("no document")?.doc.clone();
|
||||
photocraft_io::export(&d, "layered.psd", &Default::default()).map(|o| o.bytes).map_err(|e| e.to_string())
|
||||
}) {
|
||||
Ok(psd) => {
|
||||
let mut holder = Session::new();
|
||||
b.time(open, &mut holder, reps, true, |b, s, _| {
|
||||
let t = Instant::now();
|
||||
let d = photocraft_io::import("layered.psd", &psd).map_err(|e| e.to_string())?.document;
|
||||
*s = Session::new();
|
||||
s.open_document(d, Some("layered.psd".into()));
|
||||
Ok(ms(t) + b.refresh(s, true)?)
|
||||
});
|
||||
}
|
||||
Err(e) => b.errors.push((open.into(), e)),
|
||||
}
|
||||
}
|
||||
let caf = "Content-Aware Fill 10 MP (200 px hole)";
|
||||
let heal = "Spot Healing stroke 10 MP";
|
||||
if b.wanted(caf) || b.wanted(heal) {
|
||||
let (w, h) = sz.ten_mp;
|
||||
match open_photo(w, h) {
|
||||
Ok(mut s) => {
|
||||
b.time(caf, &mut s, reps, false, |_, s, i| {
|
||||
exec(s, "select.rect", json!({"x": w / 3 + i as u32 * 40, "y": h / 3, "width": 200, "height": 200, "ellipse": true}))?;
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.contentAwareFill", json!({"seed": i}))?;
|
||||
let v = ms(t);
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
b.time(heal, &mut s, reps, false, |b, s, i| {
|
||||
let (x, y) = (w / 4 + i as u32 * 60, h / 4);
|
||||
let pts: Vec<Value> = (0..10).map(|k| json!([x + k * 12, y + k * 4])).collect();
|
||||
let t = Instant::now();
|
||||
exec(s, "paint.spotHealing", json!({"points": pts, "size": 60}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
Err(e) => b.errors.push((caf.into(), e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #211 rows: selection algorithms, Content-Aware Scale and Select Subject on 24 MP, and a brush
|
||||
/// dab on an A4 300 ppi CMYK document.
|
||||
fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let reps = b.reps.min(5);
|
||||
let names = [
|
||||
"Select > Modify > Smooth r 50 on 24 MP",
|
||||
"Select > Modify > Feather r 50 on 24 MP",
|
||||
"Select Subject 24 MP (repeat)",
|
||||
"Content-Aware Scale 24 MP to 90 % width",
|
||||
];
|
||||
if names.iter().any(|n| b.wanted(n)) {
|
||||
let (w, h) = sz.big_photo;
|
||||
match open_photo(w, h) {
|
||||
Ok(mut s) => {
|
||||
for (name, op) in [(names[0], "select.modify.smooth"), (names[1], "select.modify.feather")] {
|
||||
b.time(name, &mut s, reps, false, |_, s, _| {
|
||||
exec(s, "select.rect", json!({"x": w / 6, "y": h / 6, "width": w * 2 / 3, "height": h * 2 / 3, "ellipse": true}))?;
|
||||
let t = Instant::now();
|
||||
exec(s, op, json!({"radius": 50}))?;
|
||||
let v = ms(t);
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
}
|
||||
b.time(names[2], &mut s, reps, true, |_, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "select.subject", json!({}))?;
|
||||
let v = ms(t);
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
// One run: today this takes far longer than its budget.
|
||||
b.time(names[3], &mut s, 1, false, |_, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.contentAwareScale", json!({"scaleX": 90, "scaleY": 100}))?;
|
||||
let v = ms(t);
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
}
|
||||
Err(e) => b.errors.push((names[0].into(), e)),
|
||||
}
|
||||
}
|
||||
let cmyk = "brush dab on A4 300 ppi CMYK + refresh";
|
||||
if b.wanted(cmyk) {
|
||||
let (w, h) = sz.a4;
|
||||
let mut s = Session::new();
|
||||
let setup = exec(&mut s, "file.new", json!({"width": w, "height": h, "mode": "cmyk", "resolution": 300, "background": "white"}))
|
||||
.and_then(|_| exec(&mut s, "layer.new.layer", json!({"name": "paint"})));
|
||||
if let Err(e) = setup {
|
||||
b.errors.push((cmyk.into(), e));
|
||||
return;
|
||||
}
|
||||
let _ = b.refresh(&s, true);
|
||||
let reps = b.reps;
|
||||
b.time(cmyk, &mut s, reps, true, |b, s, i| {
|
||||
let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400));
|
||||
let t = Instant::now();
|
||||
exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 40, "hardness": 0.8, "color": "#c03020"}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,20 @@ fn main() {
|
|||
while size(&app) != start && undos < steps + 1 && app.session.undo() {
|
||||
undos += 1;
|
||||
}
|
||||
// `--json out.json` (`cargo xtask perf`): the per-step samples.
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
use photocraft_testkit::perf::{process_peak_rss_bytes, report, row, write_report};
|
||||
let rss = photocraft_testkit::perf::current_rss_bytes().max(process_peak_rss_bytes());
|
||||
let rows = vec![
|
||||
row("font size step: command", &cmd, rss, None),
|
||||
row("font size step: canvas refresh", &canvas, rss, None),
|
||||
row("font size step: total", &total, rss, None),
|
||||
];
|
||||
let rep = report("type_bench", json!({"width": w, "height": h, "layers": n, "steps": steps, "effects": effects}), rows, None);
|
||||
if let Err(e) = write_report(&out, &rep) {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"size step (median of {steps}): command {:.2} ms, canvas refresh {:.2} ms, total {:.2} ms; undo steps for the drag: {undos}",
|
||||
median(cmd),
|
||||
|
|
|
|||
|
|
@ -185,6 +185,60 @@ the live command registry (`menus::is_live`) and rewrites [`docs/parity.md`](par
|
|||
- `ui.inspect` returns `perf` timings (UI ms per frame, composite ms, upload ms).
|
||||
- Never scan full surfaces per frame. Cache per document revision (`PhotocraftApp::cached_bounds`). An uncached `content_bounds()` on a 36 MP layer once cost 77 ms per frame.
|
||||
|
||||
## Scorecard and performance budgets
|
||||
|
||||
[`docs/scorecard.md`](scorecard.md) says, with numbers, where PhotoCraft stands per area. It is
|
||||
generated; never edit it by hand.
|
||||
|
||||
```sh
|
||||
cargo xtask scorecard # regenerate docs/scorecard.md
|
||||
cargo xtask scorecard --check # what CI runs: fails if the committed file is stale
|
||||
cargo xtask perf # full benchmark run (tens of minutes, release build)
|
||||
cargo xtask perf --quick # small synthetic documents (about a minute once built)
|
||||
cargo xtask perf --update-baseline # also write perf/baseline.json from this run
|
||||
```
|
||||
|
||||
**Sources.** The scorecard reads only committed files, so it is deterministic:
|
||||
- `scorecard/*.toml`: one checklist per area (tools, file compatibility, UI, type, automation,
|
||||
reliability, distribution, open bugs). Each item has an `id`, a short `target`, a `status`
|
||||
(`done`, `partial` or `missing`), the `issue` and a `note` with the evidence (file:line).
|
||||
Statuses must be true on `main`: verify against the code, and say `partial` with a note when
|
||||
unsure. Flip a row in the PR that changes it.
|
||||
- `perf/budgets.toml`: the benches `xtask perf` runs and the scenarios P1…Pn, each mapped to one
|
||||
bench row, with a budget (targets from #209, #210, #211). `enforce = false` marks a target the
|
||||
code doesn't meet yet (reported as over budget; only regressions fail); flip it to `true` in the
|
||||
PR that meets it. Scenarios the code can't support yet carry `not_measurable` and a reason, never
|
||||
a number.
|
||||
- `perf/baseline.json`: the numbers the scorecard shows, written only by
|
||||
`cargo xtask perf --update-baseline`, with the machine (CPU, RAM, GPU adapter, OS), its machine
|
||||
class, commit, date and load average.
|
||||
- `crates/io/tests/corpus.rs`: the corpus floors (every `Source { .. }` constant).
|
||||
- The prefs audit: `Preferences` fields that no code reads (target 0, #204), plus counts taken
|
||||
from the tree (never-crash attribute coverage, `docs/parity.md`).
|
||||
|
||||
**Perf runs.** `xtask perf` builds the benches in release (`perf_scenarios`, `interactive_bench`,
|
||||
`fx_bench`, `type_bench`, `large_image_bench`, and `layout_bench` once it exists), runs each with
|
||||
`--json`, and merges the reports into `target/perf/results.json` keyed by scenario id, with p50,
|
||||
p95 and max (nearest rank over the samples), peak RSS measured in-process (`photocraft-testkit`'s
|
||||
`perf::RssSampler`), GPU bytes held by the canvas, and the load average before and after each
|
||||
bench. `target/perf/summary.md` is the Markdown table. It exits non-zero when an enforced budget
|
||||
breaks, when a scenario's p50 regresses more than 15 % (`--threshold`, or `regression_pct`)
|
||||
against a baseline from the same machine class and mode, or when a bench fails. Runs from
|
||||
different machine classes are never compared, and `--quick` numbers are only compared with a
|
||||
quick baseline. Machines that run other work give noisy numbers: check the load average in the
|
||||
summary before trusting a regression, and record it next to any number you quote.
|
||||
|
||||
**Adding a scenario.** Add a row to a bench (keep its name stable: it is the key), give the
|
||||
bench `--json` support through `photocraft_testkit::perf::{row, report, write_report}`, then add a
|
||||
`[[scenario]]` to `perf/budgets.toml` and run `cargo xtask scorecard`.
|
||||
|
||||
**CI.** `ci.yml` runs `cargo xtask scorecard --check`. `perf-nightly.yml` runs `cargo xtask perf`
|
||||
on a fixed macOS runner every night (and on demand), posts the table as the job summary, uploads
|
||||
`results.json`, and fails on a broken budget or regression. It never runs on pull requests: the
|
||||
release build of the benches alone takes longer than PR CI should. To give the nightly runner
|
||||
its own baseline, run the workflow by hand with `update_baseline` and commit the `perf-baseline`
|
||||
artifact as `perf/baseline.json`.
|
||||
|
||||
## Web build
|
||||
|
||||
`apps/photocraft-web` runs the same `PhotocraftApp` in the browser through eframe's web runner. The renderer is wgpu: WebGPU where the browser has it, WebGL2 otherwise. It is Rust only. The only JavaScript is the glue that wasm-bindgen generates.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,13 @@ and ids stay lowercase (`photocraft-<version>-<platform>-<arch>.<ext>`, `ai.stor
|
|||
every artifact and `SHA256SUMS.txt`. The notes are generated from the merged PRs.
|
||||
4. **Check it.** Download an installer or two and look at the job summaries. Any
|
||||
`::warning::` there means a signing secret was missing and that artifact is unsigned.
|
||||
5. **Publish** the draft in the GitHub UI. Publishing creates the `v0.2.0` tag. Versions with a
|
||||
5. **Add the scorecard deltas** to the draft's notes: what moved in
|
||||
[`docs/scorecard.md`](scorecard.md) since the previous release. Compare the summary table
|
||||
and the performance table of both tags (`git diff v<previous> v<this> -- docs/scorecard.md`)
|
||||
and list each area's done / partial / missing change, the scenarios that newly meet their
|
||||
budget or got slower, corpus floors raised, and the change in settings that do nothing.
|
||||
Quote numbers with the baseline's machine and load average.
|
||||
6. **Publish** the draft in the GitHub UI. Publishing creates the `v0.2.0` tag. Versions with a
|
||||
pre-release suffix (`-rc.1`) are marked as pre-releases.
|
||||
|
||||
Pushing to `release` again before you publish rebuilds the same draft and replaces its assets.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ This is the reference answer to "how close are we to Photoshop parity, really".
|
|||
before picking work. Update it (with dated measurements) when the numbers move; don't restate the
|
||||
menu-parity number in its place.
|
||||
|
||||
**Measured numbers live in the [scorecard](scorecard.md)** (`cargo xtask scorecard`, kept current
|
||||
by CI): performance scenarios against their budgets, corpus floors, per-area checklists with
|
||||
done / partial / missing counts, and the number of settings that do nothing. Where the scorecard
|
||||
and the estimates below disagree, the scorecard's numbers supersede them. As of 2026-10-05 it
|
||||
counts 67 of 129 preferences that nothing reads; 3 of the 25 budgeted performance scenarios meet
|
||||
their #209–#211 targets (first baseline, taken on a heavily loaded machine), and the 150-layer
|
||||
nudge scenario crashes the GPU compositor; the open area issues' checklists (#203–#220) are
|
||||
almost entirely missing or partial.
|
||||
|
||||
**Bottom line.** Two days after 0.2.0 we had merged ~96 PRs and closed ~48 issues, but **real
|
||||
Photoshop parity is still well below 50%**. The biggest gaps are AI, missing tools, professional
|
||||
workflow depth and the plug-in ecosystem. Most fixes since 0.2.0 have passed our tests but have
|
||||
|
|
|
|||
|
|
@ -0,0 +1,279 @@
|
|||
# PhotoCraft scorecard
|
||||
|
||||
Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, `crates/io/tests/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers here supersede estimates elsewhere.
|
||||
|
||||
## Summary
|
||||
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 10 | 44 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 0 not run, 10 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 0 | 6 | 21 | io corpus: oracle ≥ 145/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 228/309, round trip ≥ 307/309 |
|
||||
| [Tools](#tools) | 0 | 16 | 14 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 3 | 20 | settings that do nothing: 66 of 129 (target 0) |
|
||||
| [Type](#type) | 0 | 3 | 6 | |
|
||||
| [Automation](#automation) | 1 | 3 | 7 | 626 of 626 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 2 | 1 | 4 | never-crash lints on 25 of 25 crates; panic_hunt present |
|
||||
| [Distribution](#distribution) | 4 | 2 | 4 | |
|
||||
| [Open bugs](#open-bugs) | 0 | 3 | 29 | |
|
||||
|
||||
## Performance
|
||||
|
||||
Budgets: `perf/budgets.toml` (targets from #209, #210, #211). Numbers: the committed baseline (`perf/baseline.json`, full run), updated only by `cargo xtask perf --update-baseline`. The nightly `perf-nightly` workflow runs `cargo xtask perf` on a fixed macOS runner and fails on a broken enforced budget or a p50 regression over 15 % against a baseline from the same machine class. "Over budget" rows are targets the code doesn't meet yet: they are reported, and only their regressions fail.
|
||||
|
||||
Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pro, macOS 15.6; load average 224.90 / 226.31 / 196.03 (1/5/15 min) at the start of the run.
|
||||
|
||||
| Id | Scenario | Target | p50 ms | p95 ms | max ms | Peak RSS | GPU | Status | Issue |
|
||||
|---|---|---|---:|---:|---:|---:|---:|---|---|
|
||||
| P1 | Move a layer with layer styles (per drag update) | ≤ 25 ms p95 | 321 | 535 | 535 | 825 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P2 | Change layer opacity | ≤ 20 ms p95 | 276 | 443 | 443 | 639 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P3 | Change layer fill | ≤ 20 ms p95 | 279 | 467 | 467 | 506 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P4 | Change blend mode | ≤ 20 ms p95 | 206 | 423 | 423 | 579 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P5 | Toggle visibility | ≤ 10 ms p95 | 178 | 425 | 425 | 668 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P6 | Undo a move | ≤ 25 ms p95 | 256 | 530 | 530 | 650 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P7 | Redo a property change | ≤ 25 ms p95 | 144 | 212 | 212 | 669 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P8 | Duplicate a 12 MP layer (first) | ≤ 100 ms p95 | 508 | 699 | 699 | 907 MB | 919 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P9 | Duplicate a 12 MP layer (repeat) | ≤ 20 ms p95 | 406 | 627 | 627 | 749 MB | 919 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P10 | Paste a 12 MP layer (first) | ≤ 100 ms p95 | 1232 | 3136 | 3136 | 1.3 GB | 1.4 GB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P11 | Paste a 12 MP layer (repeat) | ≤ 20 ms p95 | 445 | 1174 | 1174 | 958 MB | 1.4 GB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P12 | Arrow-key nudge in a 150-layer document | ≤ 30 ms p95 | – | – | – | – | – | **missing**: panicked: wgpu error: Validation Error / Caused by: / In Queue::write_buffer / Buffer with 'pc_compose_uniforms' label is invalid | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P13 | Brush dab on a 15000×10000 16-bit document | ≤ 80 ms p95 | 6.42 | 146 | 146 | 839 MB | 2.0 GB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P14 | Move a layer on a 15000×10000 16-bit document | ≤ 80 ms p95 | 2698 | 4318 | 4318 | 1.5 GB | 2.0 GB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P16 | Brush dab on a 32-bit document with 20 layers | ≤ 100 ms p95 | 21.5 | 161 | 161 | 793 MB | 1.6 GB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P17 | Move a layer on a 32-bit document with 20 layers | ≤ 100 ms p95 | 386 | 681 | 681 | 1.2 GB | 1.6 GB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P18 | Open a 20 MP layered PSD: first pixels (today: fully opened) | ≤ 1000 ms p95 | 1753 | 2794 | 2794 | 2.2 GB | 1.9 GB | over budget (target not met yet) | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P19 | Open a 20 MP layered PSD: editable | ≤ 3000 ms p95 | 1753 | 2794 | 2794 | 2.2 GB | 1.9 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P21 | Content-Aware Fill on 10 MP | ≤ 1500 ms p95 | 218 | 256 | 256 | 1.8 GB | 1.8 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P22 | Spot Healing stroke on 10 MP | ≤ 500 ms p95 | 157 | 247 | 247 | 1.8 GB | 1.9 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P26 | Select › Modify › Smooth on 24 MP (radius 50) | ≤ 100 ms p95 | 19296 | 22051 | 22051 | 1.9 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P27 | Select › Modify › Feather on 24 MP (radius 50) | ≤ 100 ms p95 | 42668 | 52100 | 52100 | 650 MB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P28 | Content-Aware Scale 24 MP (to 90 % width) | ≤ 3000 ms p95 | 954192 | 954192 | 954192 | 2.1 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P29 | Select Subject on 24 MP, repeated | ≤ 100 ms p95 | 559 | 693 | 693 | 1.4 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P33 | Brush dab on A4 300 ppi CMYK (dab to screen) | ≤ 6 ms p95 | 34.7 | 235 | 235 | 775 MB | 87 MB | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P40 | Brush dab 60 px on 36 MP | track only | 1.45 | 3.19 | 3.19 | 1.3 GB | – | tracked | |
|
||||
| P41 | Gaussian Blur r 40 on 36 MP | track only | 2963 | 3069 | 3069 | 1.9 GB | – | tracked | |
|
||||
| P42 | Curves dialog change, layer preview, 36 MP | track only | 38.7 | 158 | 158 | 1.1 GB | – | tracked | |
|
||||
| P43 | Save .psd, 36 MP, 3 layers | track only | 3047 | 3446 | 3446 | 1.2 GB | – | tracked | |
|
||||
| P44 | Open .psd, 36 MP, 3 layers | track only | 3535 | 4547 | 4547 | 1.6 GB | – | tracked | |
|
||||
| P45 | Full refresh with layer effects, warm caches, 36 MP (GPU) | track only | 105 | 160 | 160 | 714 MB | – | tracked | |
|
||||
| P46 | Brush dab on a layer with effects, 36 MP (GPU) | track only | 30.6 | 83.3 | 83.3 | 863 MB | – | tracked | |
|
||||
| P47 | Font-size drag step, 50 type layers | track only | 23.3 | 60.5 | 71.2 | 600 MB | – | tracked | |
|
||||
| P48 | Full refresh of a 14000×14000 document | track only | 8715 | 9897 | 9897 | – | – | tracked | |
|
||||
|
||||
**Not measurable yet** (the code can't do it yet; no number is recorded):
|
||||
|
||||
| Id | Scenario | Target | Why | Issue |
|
||||
|---|---|---|---|---|
|
||||
| P15 | Zoom / pan frame time on a 15000×10000 16-bit document | ≤ 16 ms per frame | no headless canvas frame harness yet (zoom and pan sample the GPU texture in the egui paint callback) | [#209](https://github.com/storytold/photocraft/issues/209) |
|
||||
| P20 | Open a 2 GB PSD / 4–5 GB PSB | opens with progress, editable, never blank | no progress or background open (#210) and no generated large-file fixtures (#216) | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P23 | Warp / Puppet / Perspective preview at 4K | ≤ 16 ms per frame, commit in the background | the previews run inside the UI tools; no headless frame harness, no background commit | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P24 | Import 500 brushes | no UI pause over 100 ms | import runs synchronously on the UI thread (no job system, #210) | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P25 | Cancel any long job | takes effect within 200 ms, document unchanged | no operation can be cancelled yet (#210) | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P30 | Liquify 800 px brush on 3500×2200 at full resolution | ≤ 1 ms per update | the Liquify proxy is capped at 1600 px and the stroke path lives in the UI tool | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P31 | Camera Raw exposure change (proxy) | ≤ 10 ms | the proxy is built inside the dialog; no headless entry point yet | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P32 | Brush release on a long stroke | no hitch at mouse-up | the live renderer and the release re-render are UI-side; needs a frame harness | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P34 | Brush preview memory, small dab on an 8K viewport | ≤ 2 MiB | the live brush preview buffers are UI-side; no headless counter yet | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P35 | Marquee drag on 24 MP with Alt/Shift | ≤ 17 ms p95 | Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
|
||||
## File compatibility
|
||||
|
||||
Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus test fails below them when the corpus is present.
|
||||
|
||||
| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |
|
||||
|---|---|---:|---:|---:|
|
||||
| io corpus | `corpus/psd` | 170 | 145 | 169 |
|
||||
| psd-tools corpus | `corpus/psd-tools` | 309 | 228 | 307 |
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:415 detach_psd drops SoLd; psd_export.rs:276 re-emits imported raw only |
|
||||
| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | psd_import.rs:259 smart_filters: Vec::new(); no filterFX in crates |
|
||||
| FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection |
|
||||
| FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | missing | [#215](https://github.com/storytold/photocraft/issues/215) | add_smart_filter callers: filters, lens, distort, plug-ins only |
|
||||
| FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu |
|
||||
| FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc lib.rs:312 SmartObject.transform is an Affine |
|
||||
| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No 37724 / ImageSourceData in crates |
|
||||
| FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | missing | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff.rs:3 'Only the first IFD is read' |
|
||||
| FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No SVG codec or import |
|
||||
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) |
|
||||
| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp |
|
||||
| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | No histogram; lens_cmds.rs:268 stores an in-app Smart Filter, PSD drops it |
|
||||
| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping |
|
||||
| FILE-215-14 | User swatch model with ACO/ASE import and export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | panels.rs:954 const SWATCHES: [[u8; 3]; 40] |
|
||||
| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc effects.rs:57 Contour model only |
|
||||
| FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No knockout / blend_interior / transparency_shapes in doc, compose or io |
|
||||
| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only |
|
||||
| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults |
|
||||
| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) |
|
||||
| FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_ui.rs:1 names/properties only; no tool, + buttons or duplicate |
|
||||
| FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) |
|
||||
| FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | missing | [#216](https://github.com/storytold/photocraft/issues/216) | No corpus step in ci.yml; in progress in #190 |
|
||||
| FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | missing | [#216](https://github.com/storytold/photocraft/issues/216) | Only banded.rs covers a >8 MP synthetic export |
|
||||
| FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | missing | [#216](https://github.com/storytold/photocraft/issues/216) | corpus/tiff, raw and exr only listed by xtask corpus; no test reads them |
|
||||
| FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import |
|
||||
| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | missing | [#216](https://github.com/storytold/photocraft/issues/216) | Features absent; no atomic-save failure-injection test |
|
||||
| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 10 MCP tasks |
|
||||
|
||||
## Tools
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.pencil exists (brush_cmds.rs:322) with an aliasing test; no Tool::Pencil (state.rs:7) |
|
||||
| TOOL-213-2 | Mixer Brush tool on the toolbar | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.mixerBrush (brush_cmds.rs:337) + panel options; no Tool variant |
|
||||
| TOOL-213-3 | Patch tool with a live preview of the patched area | missing | [#213](https://github.com/storytold/photocraft/issues/213) | No patch command or tool |
|
||||
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | missing | [#213](https://github.com/storytold/photocraft/issues/213) | inpaint::complete only used by spot healing (retouch_cmds.rs:386) |
|
||||
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Rasterize only in the layer menus (layer_menu_ui.rs:40) |
|
||||
| TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | partial | [#213](https://github.com/storytold/photocraft/issues/213) | edit.fill: color or pattern + opacity, Shift+F5 (commands.rs:263-267); no blend/history/CA/persist |
|
||||
| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Live stroke only for Brush and Eraser (canvas.rs:1684) |
|
||||
| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Panel W/H + overlay settings (preset_panels.rs:946-1015); no link, overlay never drawn |
|
||||
| TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | missing | [#213](https://github.com/storytold/photocraft/issues/213) | sample_eyedropper (canvas.rs:1552) draws no ring |
|
||||
| TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Only [ ] resize (shortcuts.rs:270) |
|
||||
| TOOL-213-11 | Editable pressure curve, global and per control | missing | [#213](https://github.com/storytold/photocraft/issues/213) | No pressure curve in engine, paint or ui-egui |
|
||||
| TOOL-213-12 | Import an image file as a brush tip | missing | [#213](https://github.com/storytold/photocraft/issues/213) | Only brush.defineFromSelection and brush.presets.importAbr |
|
||||
| TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Missing-pattern ABR test (abr_map/tests.rs:181), hardness test; no seam/black tests |
|
||||
| TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS |
|
||||
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Opens the generic select.refineEdge dialog (menus.rs:308) |
|
||||
| TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | partial | [#214](https://github.com/storytold/photocraft/issues/214) | marquee_end supports shift (chrome_ui.rs:167) but canvas.rs passes false (#208) |
|
||||
| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) |
|
||||
| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | missing | [#214](https://github.com/storytold/photocraft/issues/214) | move_mods.rs covers Shift/Alt/arrows only |
|
||||
| TOOL-214-5 | Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | No context menu in canvas.rs or transform_tool.rs |
|
||||
| TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs:136 builds a block-grid mesh |
|
||||
| TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | partial | [#214](https://github.com/storytold/photocraft/issues/214) | edit.contentAwareScale (edit_menu_cmds.rs:899) behind a parameter dialog |
|
||||
| TOOL-214-8 | A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | Arbitrary rotation is a generic dialog; ruler only feeds straighten |
|
||||
| TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields only (panels.rs:551-579) |
|
||||
| TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | missing | [#214](https://github.com/storytold/photocraft/issues/214) | pen_down gets no modifiers (canvas.rs:1632) |
|
||||
| TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog |
|
||||
| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only |
|
||||
| TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls |
|
||||
| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog |
|
||||
| TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog |
|
||||
| TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs has undo only |
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 66 of 129** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `general.beep_when_done`, `general.export_clipboard`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `general.use_legacy_free_transform`, `general.auto_show_home_screen`, `interface.ui_font_size`, `interface.show_channels_in_color`, `interface.dynamic_color_sliders`, `interface.show_tooltips`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.open_documents_as_tabs`, `workspace.enable_floating_document_window_docking`, `workspace.large_tabs`, `workspace.enable_narrow_options_bar`, `tools.show_tooltips`, `tools.zoom_clicked_point_to_center`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `tools.overscroll`, `tools.double_click_layer_mask_launches_select_and_mask`, `file_handling.image_previews`, `file_handling.lowercase_extension`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.ask_before_saving_layered_tiff`, `file_handling.maximize_psd_compatibility`, `file_handling.recent_file_count`, `file_handling.recent_files`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `units_and_rulers.screen_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.smart_quotes`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.use_esc_to_commit`, `type_.text_engine`, `type_.font_preview`, `type_.fill_new_type_layers_with_placeholder`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `enhanced_controls.rotate_view_with_trackpad`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No tool_options/export fields in Preferences (prefs.rs:694); export_dialog.rs:21 png/85 |
|
||||
| UI-217-2 | Window size and dock width remembered across restarts | missing | [#217](https://github.com/storytold/photocraft/issues/217) | main.rs:108 fixed 1440x900; dock.rs:529 snapshot omits the width |
|
||||
| UI-217-3 | File > Open starts in the last-used folder | missing | [#217](https://github.com/storytold/photocraft/issues/217) | services.rs:110 FileDialog::new() without set_directory |
|
||||
| UI-217-4 | Preferences all take effect; recent files persist and honour recent_file_count (#204) | missing | [#217](https://github.com/storytold/photocraft/issues/217) | See the prefs audit and BUG-204-* |
|
||||
| UI-217-5 | Panel groups can float (menu + drag out of the dock) and are restored at launch | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No floating groups in dock.rs/panels.rs |
|
||||
| UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:674-770 tabs click/close only |
|
||||
| UI-217-7 | Command palette shows recently run commands on an empty query | missing | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs:83-108 fuzzy hits only |
|
||||
| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs:67 right-click opens the brush picker only |
|
||||
| UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | missing | [#217](https://github.com/storytold/photocraft/issues/217) | prefs.rs:61 UiScale unused; input_tests.rs:71 asserts zoom_factor stays 1.0 |
|
||||
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No such prefs; start screen (canvas.rs:825) has no recents |
|
||||
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs:28 five themes |
|
||||
| UI-217-12 | Save custom New Document presets; a real Recent category | missing | [#217](https://github.com/storytold/photocraft/issues/217) | new_doc_ui.rs:17 'Recent' is a static list |
|
||||
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | partial | [#217](https://github.com/storytold/photocraft/issues/217) | widgets.rs:131 DragValue scrubs; no expressions, label scrub or reset |
|
||||
| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1108 plain dropdown; trash is click-only (panels.rs:1213) |
|
||||
| UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | missing | [#217](https://github.com/storytold/photocraft/issues/217) | adjust_editors.rs:397 has none of them |
|
||||
| UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | missing | [#217](https://github.com/storytold/photocraft/issues/217) | Only the Hue slider has a gradient |
|
||||
| UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout |
|
||||
| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling |
|
||||
| UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | missing | [#217](https://github.com/storytold/photocraft/issues/217) | ~33 hard-coded modifier glyph strings in ui-egui; menu_catalog.rs:341 one label |
|
||||
| UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No rulers pref; doc lib.rs:523 guides are Vec<f32> |
|
||||
| UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder |
|
||||
| UI-217-22 | Stamp Visible into a new layer keeping originals; Alt+Merge keeps originals | missing | [#217](https://github.com/storytold/photocraft/issues/217) | Only layer.mergeVisible (edit_cmds.rs:416) |
|
||||
| UI-217-23 | Layer clipboard: copy/paste whole layers (masks, styles, type) between documents | missing | [#217](https://github.com/storytold/photocraft/issues/217) | Only copy/paste layer style (extra_cmds.rs:592) |
|
||||
|
||||
## Type
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | missing | [#218](https://github.com/storytold/photocraft/issues/218) | doc text.rs:209 TextShape has Point and Box only |
|
||||
| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI |
|
||||
| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | missing | [#218](https://github.com/storytold/photocraft/issues/218) | fonts.rs:28 fallback covers Arabic/Hebrew; no per-script golden tests (only a bidi test) |
|
||||
| TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | partial | [#218](https://github.com/storytold/photocraft/issues/218) | type_cmds.rs:184 accepts variations; FaceInfo.axes unused in the UI |
|
||||
| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus |
|
||||
| TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Optical renders as Metrics (text.rs:21); Alt+arrow moves by word |
|
||||
| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | missing | [#218](https://github.com/storytold/photocraft/issues/218) | view_cmds.rs:834 always seeds arc, bend 50 |
|
||||
| TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Cmd+A, paste and double-click exist; no triple/quadruple click, no tests |
|
||||
| TYPE-218-9 | Offer 'Rasterize and continue?' for Distort/Perspective/Warp on live type | missing | [#218](https://github.com/storytold/photocraft/issues/218) | No such prompt |
|
||||
|
||||
## Automation
|
||||
|
||||
Menu wiring (`docs/parity.md`): 626 of 626 menu items live (100.0%). This counts dispatch, not behaviour.
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| AUTO-1 | Every menu item dispatches a command id (UI, CLI, control channel and MCP share them) | done | | docs/parity.md: all menu items live; menus.rs is_live |
|
||||
| AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids |
|
||||
| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | missing | [#219](https://github.com/storytold/photocraft/issues/219) | Panel deletes whole actions only (actions.rs:146) |
|
||||
| AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | missing | [#219](https://github.com/storytold/photocraft/issues/219) | Recording is UI-only (actions.rs:37-60) |
|
||||
| AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | missing | [#219](https://github.com/storytold/photocraft/issues/219) | One replay test (actions.rs:171: new layer, rect select, fill) |
|
||||
| AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel |
|
||||
| AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only |
|
||||
| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency |
|
||||
| AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check |
|
||||
| AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | partial | [#219](https://github.com/storytold/photocraft/issues/219) | filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform; no caret API |
|
||||
| AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP |
|
||||
|
||||
## Reliability
|
||||
|
||||
Never-crash attribute on 25 of 25 crate roots.
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| REL-1 | panic_hunt fuzzes every command with adversarial params | done | | engine/tests/panic_hunt.rs: every command, own thread, 4 s timeout; #[ignore], run in the gate |
|
||||
| REL-2 | Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute | done | | Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221 |
|
||||
| REL-3 | The app catches escaped panics around command dispatch and import/export | partial | | engine lib.rs:336 dispatch; crash_guard.rs:21 import/export; the dispatch guard is untested |
|
||||
| REL-4 | Atomic save (temp, fsync, rename) for every document write | missing | [#203](https://github.com/storytold/photocraft/issues/203) | Documents use fs::write in place; prefs/presets/bundles use tmp + rename without fsync |
|
||||
| REL-212-1 | Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error | missing | [#212](https://github.com/storytold/photocraft/issues/212) | try_reserve only in io/flat.rs:221; codec max_alloc limits cover decode only |
|
||||
| REL-212-2 | Export switches to PSB when the estimated file size exceeds 2 GB | missing | [#212](https://github.com/storytold/photocraft/issues/212) | io/psd_export.rs:558 switches only when a side exceeds 30000 px |
|
||||
| REL-212-3 | Tests: an allocation-limit injection makes each command Err with the document unchanged | missing | [#212](https://github.com/storytold/photocraft/issues/212) | Only codec/raw max_alloc tests (codecs/tests/limits.rs:48) |
|
||||
|
||||
## Distribution
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| DIST-1 | macOS universal build (aarch64 + x86_64), signed and notarized | done | | release.yml:66,75,93 (macos-15); 0.2.0 shipped notarized |
|
||||
| DIST-2 | Windows x64 and x86 builds | partial | | release.yml:113-114; signing skipped with a warning until the signing material exists |
|
||||
| DIST-3 | Linux x86_64 and aarch64: AppImage, deb, rpm, tar.gz | done | | release.yml:162-163,186 |
|
||||
| DIST-4 | Flatpak single-file bundles, x86_64 and aarch64 | done | | release.yml:204-216 (#163) |
|
||||
| DIST-5 | Web build (wasm32, trunk) | done | | release.yml:255 |
|
||||
| DIST-220-1 | Help > Check for Updates plus an opt-out periodic release-feed check on all desktops | missing | [#220](https://github.com/storytold/photocraft/issues/220) | No update check in crates |
|
||||
| DIST-220-2 | Windows ARM64 (aarch64-pc-windows-msvc) in the release matrix, signed like x64 | missing | [#220](https://github.com/storytold/photocraft/issues/220) | release.yml:113-114 only x86_64 and i686 msvc |
|
||||
| DIST-220-3 | In-app bug report with reviewable diagnostics that opens a prefilled issue | missing | [#220](https://github.com/storytold/photocraft/issues/220) | No bug report dialog in crates |
|
||||
| DIST-220-4 | Translation coverage per language reported by xtask; RTL layout for Arabic and Hebrew | missing | [#220](https://github.com/storytold/photocraft/issues/220) | No i18n in crates or xtask (waits on #169) |
|
||||
| DIST-220-5 | Linux AppImage x86_64 + aarch64 ship; Flatpak kept; Flathub later (#173) | partial | [#220](https://github.com/storytold/photocraft/issues/220) | AppImage and Flatpak ship (release.yml:162-216); Flathub not done |
|
||||
|
||||
## Open bugs
|
||||
|
||||
| Id | Target | Status | Issue | Note |
|
||||
|---|---|---|---|---|
|
||||
| BUG-203-1 | Every document write goes through one atomic_write (temp, fsync, rename) | missing | [#203](https://github.com/storytold/photocraft/issues/203) | services.rs:125 std::fs::write; file_cmds.rs:91 fs::write; automation/files.rs:67 in place |
|
||||
| BUG-203-2 | Temp file in the same dir, sync_all, rename over target, fsync dir on Unix | missing | [#203](https://github.com/storytold/photocraft/issues/203) | No sync_all anywhere; services.rs:71 write_atomic (prefs only) does tmp + rename, no fsync |
|
||||
| BUG-203-3 | Windows: retry the rename with backoff on PermissionDenied | missing | [#203](https://github.com/storytold/photocraft/issues/203) | No retry around fs::rename (services.rs:77, preset_store.rs:189, format/store.rs:457) |
|
||||
| BUG-203-4 | Unwritable directory errors clearly, never falls back to writing in place | missing | [#203](https://github.com/storytold/photocraft/issues/203) | No atomic path exists for documents; all document writes are in place |
|
||||
| BUG-203-5 | On any failure the original file is untouched and the temp file removed | missing | [#203](https://github.com/storytold/photocraft/issues/203) | services.rs:76 write_atomic leaves .tmp behind if rename fails; documents write in place |
|
||||
| BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | missing | [#203](https://github.com/storytold/photocraft/issues/203) | No such tests under crates/ or apps/ |
|
||||
| BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | prefs.rs:196-202; no reader outside prefs.rs (see the prefs audit) |
|
||||
| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | prefs.rs:163,166,229; no reader outside prefs.rs |
|
||||
| BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | missing | [#204](https://github.com/storytold/photocraft/issues/204) | raw_defaults (prefs.rs:712), ask_before_saving_layered_tiff (prefs.rs:312) have no readers |
|
||||
| BUG-204-4 | Recent files persisted through prefs across restarts | missing | [#204](https://github.com/storytold/photocraft/issues/204) | ui-egui lib.rs:461 push_recent only touches app.ui.recent_files |
|
||||
| BUG-204-5 | Recent-files list honours recent_file_count (default 20) | missing | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs:465 truncate(10) hard-coded; menus.rs:1065 test asserts 'capped at 10' |
|
||||
| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | partial | [#204](https://github.com/storytold/photocraft/issues/204) | The scorecard's prefs audit counts unread fields; no engine test or allowlist yet |
|
||||
| BUG-205-1 | Free Transform with a selection moves only the selected part of a linked mask | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_cmds.rs:127-130 warps the whole linked mask even when the selection split pixels |
|
||||
| BUG-205-2 | Live transform preview draws the layer with its mask applied | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_tool.rs:173 preview_image uses the raw surface |
|
||||
| BUG-205-3 | edit.transform honours target mask: transforms only a targeted, unlinked mask | missing | [#205](https://github.com/storytold/photocraft/issues/205) | transform_cmds.rs:214-270 never reads a target param |
|
||||
| BUG-205-4 | Test: a linked raster mask with no selection moves with the pixels | partial | [#205](https://github.com/storytold/photocraft/issues/205) | Implemented at transform_cmds.rs:127; no raster-mask test |
|
||||
| BUG-205-5 | Tests: linked mask + selection stays aligned; unlinked stays; targeted mask only | missing | [#205](https://github.com/storytold/photocraft/issues/205) | Unlinked case honoured (m.linked check, l.128) but untested; the other two not implemented |
|
||||
| BUG-206-1 | CharStyle models per-character manual kerning separately from the auto-kern mode | missing | [#206](https://github.com/storytold/photocraft/issues/206) | doc/text.rs:76 only `kerning: Kerning` enum (Metrics/Optical/Off) |
|
||||
| BUG-206-2 | PSD import keeps Optical and manual Kerning values | missing | [#206](https://github.com/storytold/photocraft/issues/206) | text/psd.rs:264 maps anything not Off to Metrics; Kerning value ignored |
|
||||
| BUG-206-3 | PSD export writes the real AutoKerning mode and Kerning values | missing | [#206](https://github.com/storytold/photocraft/issues/206) | text/psd.rs:380-381 AutoKerning bool only; Kerning hard-coded 0 |
|
||||
| BUG-206-4 | Alt+Left/Right at a collapsed caret adjusts pair kerning; Character panel field | missing | [#206](https://github.com/storytold/photocraft/issues/206) | type_tool.rs:896-912 only a mode combo |
|
||||
| BUG-206-5 | Tests: PSD round trip of manual and Optical kerning, a real file in the oracle corpus | missing | [#206](https://github.com/storytold/photocraft/issues/206) | No kerning round-trip test |
|
||||
| BUG-207-1 | Retouch tools respect the paint target (mask, alpha channel, Quick Mask) | missing | [#207](https://github.com/storytold/photocraft/issues/207) | retouch_cmds.rs:83 run_stroke always uses l.surface_mut(); no paint_target() call |
|
||||
| BUG-207-2 | Sample All Layers applies to Blur, Sharpen and Smudge | missing | [#207](https://github.com/storytold/photocraft/issues/207) | Checkbox at retouch_ui.rs:171-190 is not passed to the stroke commands |
|
||||
| BUG-207-3 | Smudge mixes premultiplied RGBA (no dark fringe at transparent edges) | missing | [#207](https://github.com/storytold/photocraft/issues/207) | No premultiply in paint/src/retouch.rs |
|
||||
| BUG-207-4 | edit.transform transforms a targeted alpha channel | missing | [#207](https://github.com/storytold/photocraft/issues/207) | transform_cmds.rs transform() has no target handling |
|
||||
| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | missing | [#207](https://github.com/storytold/photocraft/issues/207) | Only smudge_drags_colour_along_the_stroke (retouch.rs:407) |
|
||||
| BUG-208-1 | Shift while dragging a Rect/Elliptical Marquee constrains to a square/circle | missing | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs:1498 and :1769 pass shift=false to marquee_end |
|
||||
| BUG-208-2 | Alt while dragging a marquee draws from the centre | missing | [#208](https://github.com/storytold/photocraft/issues/208) | marquee_end (chrome_ui.rs:157) has no centre mode |
|
||||
| BUG-208-3 | W x H px readout beside the cursor while dragging a marquee | missing | [#208](https://github.com/storytold/photocraft/issues/208) | No size readout in canvas.rs or tool_feedback.rs |
|
||||
| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | partial | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press (canvas.rs:1679, used :1779); only a badge test (tool_feedback.rs:195) |
|
||||
| BUG-208-5 | Kittest: Shift drag gives a square, Alt drag is centred, readout matches the size | missing | [#208](https://github.com/storytold/photocraft/issues/208) | No marquee drag tests |
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
{
|
||||
"machine": {
|
||||
"arch": "aarch64",
|
||||
"class": "macos-aarch64-apple-m4-pro-apple-m4-pro",
|
||||
"cores": 14,
|
||||
"cpu": "Apple M4 Pro",
|
||||
"gpu_adapter": "Apple M4 Pro",
|
||||
"os": "macos",
|
||||
"os_version": "macOS 15.6",
|
||||
"ram_bytes": 51539607552
|
||||
},
|
||||
"machine_class": "macos-aarch64-apple-m4-pro-apple-m4-pro",
|
||||
"modes": {
|
||||
"full": {
|
||||
"commit": "f5d2bcb54abd0f45e50f5822e869ea5da9b06b91",
|
||||
"date": "2026-10-05T14:17:51Z",
|
||||
"load_avg_end": [
|
||||
289.86,
|
||||
276.99,
|
||||
270.86
|
||||
],
|
||||
"load_avg_start": [
|
||||
224.9,
|
||||
226.31,
|
||||
196.03
|
||||
],
|
||||
"scenarios": {
|
||||
"P1": {
|
||||
"gpu_bytes": 429194378,
|
||||
"max_ms": 535.0277920000001,
|
||||
"n": 15,
|
||||
"p50_ms": 321.068333,
|
||||
"p95_ms": 535.0277920000001,
|
||||
"peak_rss_bytes": 865124352
|
||||
},
|
||||
"P10": {
|
||||
"gpu_bytes": 1498741898,
|
||||
"max_ms": 3135.981125,
|
||||
"n": 5,
|
||||
"p50_ms": 1232.297418,
|
||||
"p95_ms": 3135.981125,
|
||||
"peak_rss_bytes": 1390755840
|
||||
},
|
||||
"P11": {
|
||||
"gpu_bytes": 1498741898,
|
||||
"max_ms": 1174.1605009999998,
|
||||
"n": 5,
|
||||
"p50_ms": 445.372791,
|
||||
"p95_ms": 1174.1605009999998,
|
||||
"peak_rss_bytes": 1005027328
|
||||
},
|
||||
"P13": {
|
||||
"gpu_bytes": 2115958908,
|
||||
"max_ms": 145.70308400000002,
|
||||
"n": 15,
|
||||
"p50_ms": 6.421208999999999,
|
||||
"p95_ms": 145.70308400000002,
|
||||
"peak_rss_bytes": 880541696
|
||||
},
|
||||
"P14": {
|
||||
"gpu_bytes": 2115958908,
|
||||
"max_ms": 4318.054,
|
||||
"n": 15,
|
||||
"p50_ms": 2698.3841249999996,
|
||||
"p95_ms": 4318.054,
|
||||
"peak_rss_bytes": 1557413888
|
||||
},
|
||||
"P16": {
|
||||
"gpu_bytes": 1750668536,
|
||||
"max_ms": 160.640333,
|
||||
"n": 15,
|
||||
"p50_ms": 21.536042,
|
||||
"p95_ms": 160.640333,
|
||||
"peak_rss_bytes": 831586304
|
||||
},
|
||||
"P17": {
|
||||
"gpu_bytes": 1750668536,
|
||||
"max_ms": 681.0902500000001,
|
||||
"n": 15,
|
||||
"p50_ms": 385.865959,
|
||||
"p95_ms": 681.0902500000001,
|
||||
"peak_rss_bytes": 1300987904
|
||||
},
|
||||
"P18": {
|
||||
"gpu_bytes": 2042852008,
|
||||
"max_ms": 2794.2514169999995,
|
||||
"n": 5,
|
||||
"p50_ms": 1752.639541,
|
||||
"p95_ms": 2794.2514169999995,
|
||||
"peak_rss_bytes": 2316730368
|
||||
},
|
||||
"P19": {
|
||||
"gpu_bytes": 2042852008,
|
||||
"max_ms": 2794.2514169999995,
|
||||
"n": 5,
|
||||
"p50_ms": 1752.639541,
|
||||
"p95_ms": 2794.2514169999995,
|
||||
"peak_rss_bytes": 2316730368
|
||||
},
|
||||
"P2": {
|
||||
"gpu_bytes": 429194378,
|
||||
"max_ms": 443.161208,
|
||||
"n": 15,
|
||||
"p50_ms": 276.31104200000004,
|
||||
"p95_ms": 443.161208,
|
||||
"peak_rss_bytes": 670842880
|
||||
},
|
||||
"P21": {
|
||||
"gpu_bytes": 1936389156,
|
||||
"max_ms": 255.857875,
|
||||
"n": 5,
|
||||
"p50_ms": 218.395042,
|
||||
"p95_ms": 255.857875,
|
||||
"peak_rss_bytes": 1913143296
|
||||
},
|
||||
"P22": {
|
||||
"gpu_bytes": 2036052728,
|
||||
"max_ms": 246.851125,
|
||||
"n": 5,
|
||||
"p50_ms": 157.088292,
|
||||
"p95_ms": 246.851125,
|
||||
"peak_rss_bytes": 1959444480
|
||||
},
|
||||
"P26": {
|
||||
"gpu_bytes": 0,
|
||||
"max_ms": 22050.895333,
|
||||
"n": 5,
|
||||
"p50_ms": 19295.759749999997,
|
||||
"p95_ms": 22050.895333,
|
||||
"peak_rss_bytes": 1998864384
|
||||
},
|
||||
"P27": {
|
||||
"gpu_bytes": 0,
|
||||
"max_ms": 52099.803917000005,
|
||||
"n": 5,
|
||||
"p50_ms": 42667.745542,
|
||||
"p95_ms": 52099.803917000005,
|
||||
"peak_rss_bytes": 682262528
|
||||
},
|
||||
"P28": {
|
||||
"gpu_bytes": 0,
|
||||
"max_ms": 954192.103709,
|
||||
"n": 1,
|
||||
"p50_ms": 954192.103709,
|
||||
"p95_ms": 954192.103709,
|
||||
"peak_rss_bytes": 2214494208
|
||||
},
|
||||
"P29": {
|
||||
"gpu_bytes": 0,
|
||||
"max_ms": 692.8735409999999,
|
||||
"n": 5,
|
||||
"p50_ms": 559.221084,
|
||||
"p95_ms": 692.8735409999999,
|
||||
"peak_rss_bytes": 1474887680
|
||||
},
|
||||
"P3": {
|
||||
"gpu_bytes": 429194378,
|
||||
"max_ms": 466.9662080000001,
|
||||
"n": 15,
|
||||
"p50_ms": 279.122417,
|
||||
"p95_ms": 466.9662080000001,
|
||||
"peak_rss_bytes": 531382272
|
||||
},
|
||||
"P33": {
|
||||
"gpu_bytes": 92272684,
|
||||
"max_ms": 234.867418,
|
||||
"n": 15,
|
||||
"p50_ms": 34.655457999999996,
|
||||
"p95_ms": 234.867418,
|
||||
"peak_rss_bytes": 813088768
|
||||
},
|
||||
"P4": {
|
||||
"gpu_bytes": 429194378,
|
||||
"max_ms": 422.791916,
|
||||
"n": 15,
|
||||
"p50_ms": 205.565041,
|
||||
"p95_ms": 422.791916,
|
||||
"peak_rss_bytes": 607289344
|
||||
},
|
||||
"P40": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 3.1855830000000003,
|
||||
"n": 5,
|
||||
"p50_ms": 1.451375,
|
||||
"p95_ms": 3.1855830000000003,
|
||||
"peak_rss_bytes": 1445150720
|
||||
},
|
||||
"P41": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 3069.238875,
|
||||
"n": 5,
|
||||
"p50_ms": 2962.736792,
|
||||
"p95_ms": 3069.238875,
|
||||
"peak_rss_bytes": 2064908288
|
||||
},
|
||||
"P42": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 158.405874,
|
||||
"n": 5,
|
||||
"p50_ms": 38.686333000000005,
|
||||
"p95_ms": 158.405874,
|
||||
"peak_rss_bytes": 1216888832
|
||||
},
|
||||
"P43": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 3446.197583,
|
||||
"n": 5,
|
||||
"p50_ms": 3047.266834,
|
||||
"p95_ms": 3446.197583,
|
||||
"peak_rss_bytes": 1325137920
|
||||
},
|
||||
"P44": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 4546.568376,
|
||||
"n": 5,
|
||||
"p50_ms": 3534.9131660000003,
|
||||
"p95_ms": 4546.568376,
|
||||
"peak_rss_bytes": 1764016128
|
||||
},
|
||||
"P45": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 160.32670900000002,
|
||||
"n": 3,
|
||||
"p50_ms": 105.263791,
|
||||
"p95_ms": 160.32670900000002,
|
||||
"peak_rss_bytes": 749600768
|
||||
},
|
||||
"P46": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 83.28816599999999,
|
||||
"n": 5,
|
||||
"p50_ms": 30.59725,
|
||||
"p95_ms": 83.28816599999999,
|
||||
"peak_rss_bytes": 905560064
|
||||
},
|
||||
"P47": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 71.153042,
|
||||
"n": 30,
|
||||
"p50_ms": 23.329875,
|
||||
"p95_ms": 60.499292,
|
||||
"peak_rss_bytes": 630161408
|
||||
},
|
||||
"P48": {
|
||||
"gpu_bytes": null,
|
||||
"max_ms": 9896.922,
|
||||
"n": 3,
|
||||
"p50_ms": 8714.82725,
|
||||
"p95_ms": 9896.922,
|
||||
"peak_rss_bytes": null
|
||||
},
|
||||
"P5": {
|
||||
"gpu_bytes": 429194378,
|
||||
"max_ms": 424.806751,
|
||||
"n": 15,
|
||||
"p50_ms": 177.937708,
|
||||
"p95_ms": 424.806751,
|
||||
"peak_rss_bytes": 700907520
|
||||
},
|
||||
"P6": {
|
||||
"gpu_bytes": 429194378,
|
||||
"max_ms": 530.064251,
|
||||
"n": 15,
|
||||
"p50_ms": 255.51825,
|
||||
"p95_ms": 530.064251,
|
||||
"peak_rss_bytes": 682541056
|
||||
},
|
||||
"P7": {
|
||||
"gpu_bytes": 429194378,
|
||||
"max_ms": 211.910958,
|
||||
"n": 15,
|
||||
"p50_ms": 144.162875,
|
||||
"p95_ms": 211.910958,
|
||||
"peak_rss_bytes": 701497344
|
||||
},
|
||||
"P8": {
|
||||
"gpu_bytes": 963968138,
|
||||
"max_ms": 699.380084,
|
||||
"n": 5,
|
||||
"p50_ms": 508.15320899999995,
|
||||
"p95_ms": 699.380084,
|
||||
"peak_rss_bytes": 951123968
|
||||
},
|
||||
"P9": {
|
||||
"gpu_bytes": 963968138,
|
||||
"max_ms": 626.701542,
|
||||
"n": 5,
|
||||
"p50_ms": 405.743043,
|
||||
"p95_ms": 626.701542,
|
||||
"peak_rss_bytes": 785924096
|
||||
}
|
||||
},
|
||||
"failed": {
|
||||
"P12": "panicked: wgpu error: Validation Error / Caused by: / In Queue::write_buffer / Buffer with 'pc_compose_uniforms' label is invalid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,411 @@
|
|||
# Performance budgets for `cargo xtask perf` and the scorecard (#221).
|
||||
#
|
||||
# Targets come from #209 (interactive edits), #210 (long operations) and #211 (slow algorithms).
|
||||
# Each [[scenario]] is measured by one row of one bench (`bench` + `row`, the row's exact name
|
||||
# in the bench's `--json` report), judged on `metric` (p50 | p95 | max; default p95) against
|
||||
# `budget_ms`. `enforce = false` marks a target the code doesn't meet yet: it is reported as
|
||||
# "over budget" but only a regression fails the run; flip it to true in the PR that meets it.
|
||||
# Scenarios the code can't support yet carry `not_measurable` (why) instead of a bench: never
|
||||
# invent a number for them.
|
||||
#
|
||||
# Benches run in release with `--json <file>` appended. `quick_args` = also runs in
|
||||
# `cargo xtask perf --quick` (small synthetic documents; budgets don't apply there).
|
||||
|
||||
[settings]
|
||||
# A scenario regresses when its p50 grows by more than this share of the baseline…
|
||||
regression_pct = 15.0
|
||||
# …and by more than this many milliseconds (timer noise on very fast rows).
|
||||
regression_min_ms = 1.0
|
||||
|
||||
[[bench]]
|
||||
name = "perf_scenarios"
|
||||
package = "photocraft-ui-egui"
|
||||
quick_args = ["--quick"]
|
||||
note = "the #209/#210/#211 rows: layered 20 MP, 150 layers, 16/32-bit, open, retouch, selection"
|
||||
|
||||
[[bench]]
|
||||
name = "interactive_bench"
|
||||
package = "photocraft-ui-egui"
|
||||
args = ["--reps", "5"]
|
||||
note = "36 MP photo: dabs, filters, adjustment previews, saves"
|
||||
|
||||
[[bench]]
|
||||
name = "fx_bench"
|
||||
package = "photocraft-ui-egui"
|
||||
args = ["--no-cpu", "--reps", "5"]
|
||||
note = "36 MP document with layer effects, GPU compositor"
|
||||
|
||||
[[bench]]
|
||||
name = "type_bench"
|
||||
package = "photocraft-ui-egui"
|
||||
quick_args = ["--size", "2000x1500", "--layers", "20", "--steps", "15"]
|
||||
note = "font-size drag on 50 type layers"
|
||||
|
||||
[[bench]]
|
||||
name = "large_image_bench"
|
||||
package = "photocraft-ui-egui"
|
||||
args = ["--size", "14000x14000", "--op", "refresh"]
|
||||
note = "196 MP: open and full refresh"
|
||||
|
||||
# Hook: the layout-document bench lands with perf/layout-documents (#125/#128). It runs (and its
|
||||
# rows are kept in results.json) as soon as the example exists; add scenarios for its rows then.
|
||||
[[bench]]
|
||||
name = "layout_bench"
|
||||
package = "photocraft-ui-egui"
|
||||
requires = "crates/ui-egui/examples/layout_bench.rs"
|
||||
quick_args = []
|
||||
note = "layout PSDs with many styled layers (#125/#128)"
|
||||
|
||||
# ---- #209: interactive edits on layered documents (20 MP, styled layers) ---------------------
|
||||
|
||||
[[scenario]]
|
||||
id = "P1"
|
||||
title = "Move a layer with layer styles (per drag update)"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "move styled layer 10 px + refresh"
|
||||
budget_ms = 25
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P2"
|
||||
title = "Change layer opacity"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "set layer opacity + refresh"
|
||||
budget_ms = 20
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P3"
|
||||
title = "Change layer fill"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "set layer fill + refresh"
|
||||
budget_ms = 20
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P4"
|
||||
title = "Change blend mode"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "set blend mode + refresh"
|
||||
budget_ms = 20
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P5"
|
||||
title = "Toggle visibility"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "toggle visibility + refresh"
|
||||
budget_ms = 10
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P6"
|
||||
title = "Undo a move"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "undo move + refresh"
|
||||
budget_ms = 25
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P7"
|
||||
title = "Redo a property change"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "redo opacity change + refresh"
|
||||
budget_ms = 25
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P8"
|
||||
title = "Duplicate a 12 MP layer (first)"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "duplicate 12 MP layer (first) + refresh"
|
||||
budget_ms = 100
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P9"
|
||||
title = "Duplicate a 12 MP layer (repeat)"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "duplicate 12 MP layer (repeat) + refresh"
|
||||
budget_ms = 20
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P10"
|
||||
title = "Paste a 12 MP layer (first)"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "paste 12 MP layer (first) + refresh"
|
||||
budget_ms = 100
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P11"
|
||||
title = "Paste a 12 MP layer (repeat)"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "paste 12 MP layer (repeat) + refresh"
|
||||
budget_ms = 20
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P12"
|
||||
title = "Arrow-key nudge in a 150-layer document"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "nudge 1 px in the many-layer document + refresh"
|
||||
budget_ms = 30
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P13"
|
||||
title = "Brush dab on a 15000×10000 16-bit document"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "brush dab 60 px on 16-bit 15000x10000 + refresh"
|
||||
budget_ms = 80
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P14"
|
||||
title = "Move a layer on a 15000×10000 16-bit document"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "move layer on 16-bit 15000x10000 + refresh"
|
||||
budget_ms = 80
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P15"
|
||||
title = "Zoom / pan frame time on a 15000×10000 16-bit document"
|
||||
issue = 209
|
||||
target = "≤ 16 ms per frame"
|
||||
not_measurable = "no headless canvas frame harness yet (zoom and pan sample the GPU texture in the egui paint callback)"
|
||||
|
||||
[[scenario]]
|
||||
id = "P16"
|
||||
title = "Brush dab on a 32-bit document with 20 layers"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "brush dab 60 px on 32-bit 20 layers + refresh"
|
||||
budget_ms = 100
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P17"
|
||||
title = "Move a layer on a 32-bit document with 20 layers"
|
||||
issue = 209
|
||||
bench = "perf_scenarios"
|
||||
row = "move layer on 32-bit 20 layers + refresh"
|
||||
budget_ms = 100
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
# ---- #210: long operations ---------------------------------------------------------------------
|
||||
|
||||
[[scenario]]
|
||||
id = "P18"
|
||||
title = "Open a 20 MP layered PSD: first pixels (today: fully opened)"
|
||||
issue = 210
|
||||
bench = "perf_scenarios"
|
||||
row = "open 20 MP layered PSD (decode + first composite)"
|
||||
budget_ms = 1000
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P19"
|
||||
title = "Open a 20 MP layered PSD: editable"
|
||||
issue = 210
|
||||
bench = "perf_scenarios"
|
||||
row = "open 20 MP layered PSD (decode + first composite)"
|
||||
budget_ms = 3000
|
||||
|
||||
[[scenario]]
|
||||
id = "P20"
|
||||
title = "Open a 2 GB PSD / 4–5 GB PSB"
|
||||
issue = 210
|
||||
target = "opens with progress, editable, never blank"
|
||||
not_measurable = "no progress or background open (#210) and no generated large-file fixtures (#216)"
|
||||
|
||||
[[scenario]]
|
||||
id = "P21"
|
||||
title = "Content-Aware Fill on 10 MP"
|
||||
issue = 210
|
||||
bench = "perf_scenarios"
|
||||
row = "Content-Aware Fill 10 MP (200 px hole)"
|
||||
budget_ms = 1500
|
||||
|
||||
[[scenario]]
|
||||
id = "P22"
|
||||
title = "Spot Healing stroke on 10 MP"
|
||||
issue = 210
|
||||
bench = "perf_scenarios"
|
||||
row = "Spot Healing stroke 10 MP"
|
||||
budget_ms = 500
|
||||
|
||||
[[scenario]]
|
||||
id = "P23"
|
||||
title = "Warp / Puppet / Perspective preview at 4K"
|
||||
issue = 210
|
||||
target = "≤ 16 ms per frame, commit in the background"
|
||||
not_measurable = "the previews run inside the UI tools; no headless frame harness, no background commit"
|
||||
|
||||
[[scenario]]
|
||||
id = "P24"
|
||||
title = "Import 500 brushes"
|
||||
issue = 210
|
||||
target = "no UI pause over 100 ms"
|
||||
not_measurable = "import runs synchronously on the UI thread (no job system, #210)"
|
||||
|
||||
[[scenario]]
|
||||
id = "P25"
|
||||
title = "Cancel any long job"
|
||||
issue = 210
|
||||
target = "takes effect within 200 ms, document unchanged"
|
||||
not_measurable = "no operation can be cancelled yet (#210)"
|
||||
|
||||
# ---- #211: slow algorithms ---------------------------------------------------------------------
|
||||
|
||||
[[scenario]]
|
||||
id = "P26"
|
||||
title = "Select › Modify › Smooth on 24 MP (radius 50)"
|
||||
issue = 211
|
||||
bench = "perf_scenarios"
|
||||
row = "Select > Modify > Smooth r 50 on 24 MP"
|
||||
budget_ms = 100
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P27"
|
||||
title = "Select › Modify › Feather on 24 MP (radius 50)"
|
||||
issue = 211
|
||||
bench = "perf_scenarios"
|
||||
row = "Select > Modify > Feather r 50 on 24 MP"
|
||||
budget_ms = 100
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P28"
|
||||
title = "Content-Aware Scale 24 MP (to 90 % width)"
|
||||
issue = 211
|
||||
bench = "perf_scenarios"
|
||||
row = "Content-Aware Scale 24 MP to 90 % width"
|
||||
budget_ms = 3000
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P29"
|
||||
title = "Select Subject on 24 MP, repeated"
|
||||
issue = 211
|
||||
bench = "perf_scenarios"
|
||||
row = "Select Subject 24 MP (repeat)"
|
||||
budget_ms = 100
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P30"
|
||||
title = "Liquify 800 px brush on 3500×2200 at full resolution"
|
||||
issue = 211
|
||||
target = "≤ 1 ms per update"
|
||||
not_measurable = "the Liquify proxy is capped at 1600 px and the stroke path lives in the UI tool"
|
||||
|
||||
[[scenario]]
|
||||
id = "P31"
|
||||
title = "Camera Raw exposure change (proxy)"
|
||||
issue = 211
|
||||
target = "≤ 10 ms"
|
||||
not_measurable = "the proxy is built inside the dialog; no headless entry point yet"
|
||||
|
||||
[[scenario]]
|
||||
id = "P32"
|
||||
title = "Brush release on a long stroke"
|
||||
issue = 211
|
||||
target = "no hitch at mouse-up"
|
||||
not_measurable = "the live renderer and the release re-render are UI-side; needs a frame harness"
|
||||
|
||||
[[scenario]]
|
||||
id = "P33"
|
||||
title = "Brush dab on A4 300 ppi CMYK (dab to screen)"
|
||||
issue = 211
|
||||
bench = "perf_scenarios"
|
||||
row = "brush dab on A4 300 ppi CMYK + refresh"
|
||||
budget_ms = 6
|
||||
enforce = false # over budget in the 2026-10-05 baseline
|
||||
|
||||
[[scenario]]
|
||||
id = "P34"
|
||||
title = "Brush preview memory, small dab on an 8K viewport"
|
||||
issue = 211
|
||||
target = "≤ 2 MiB"
|
||||
not_measurable = "the live brush preview buffers are UI-side; no headless counter yet"
|
||||
|
||||
[[scenario]]
|
||||
id = "P35"
|
||||
title = "Marquee drag on 24 MP with Alt/Shift"
|
||||
issue = 211
|
||||
target = "≤ 17 ms p95"
|
||||
not_measurable = "Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side"
|
||||
|
||||
# ---- tracked (no budget yet): regressions only -------------------------------------------------
|
||||
|
||||
[[scenario]]
|
||||
id = "P40"
|
||||
title = "Brush dab 60 px on 36 MP"
|
||||
bench = "interactive_bench"
|
||||
row = "brush dab 60 px (command + refresh)"
|
||||
|
||||
[[scenario]]
|
||||
id = "P41"
|
||||
title = "Gaussian Blur r 40 on 36 MP"
|
||||
bench = "interactive_bench"
|
||||
row = "Gaussian Blur r 40 (photo layer) + refresh"
|
||||
|
||||
[[scenario]]
|
||||
id = "P42"
|
||||
title = "Curves dialog change, layer preview, 36 MP"
|
||||
bench = "interactive_bench"
|
||||
row = "Curves dialog change, layer preview"
|
||||
|
||||
[[scenario]]
|
||||
id = "P43"
|
||||
title = "Save .psd, 36 MP, 3 layers"
|
||||
bench = "interactive_bench"
|
||||
row = "save .psd"
|
||||
|
||||
[[scenario]]
|
||||
id = "P44"
|
||||
title = "Open .psd, 36 MP, 3 layers"
|
||||
bench = "interactive_bench"
|
||||
row = "open .psd (3 layers) + refresh"
|
||||
|
||||
[[scenario]]
|
||||
id = "P45"
|
||||
title = "Full refresh with layer effects, warm caches, 36 MP (GPU)"
|
||||
bench = "fx_bench"
|
||||
row = "full refresh, warm caches (GPU)"
|
||||
|
||||
[[scenario]]
|
||||
id = "P46"
|
||||
title = "Brush dab on a layer with effects, 36 MP (GPU)"
|
||||
bench = "fx_bench"
|
||||
row = "dab on the effect layer (damage + reach) (GPU)"
|
||||
|
||||
[[scenario]]
|
||||
id = "P47"
|
||||
title = "Font-size drag step, 50 type layers"
|
||||
bench = "type_bench"
|
||||
row = "font size step: total"
|
||||
|
||||
[[scenario]]
|
||||
id = "P48"
|
||||
title = "Full refresh of a 14000×14000 document"
|
||||
bench = "large_image_bench"
|
||||
row = "full refresh"
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# Scorecard checklist: automation (#219). The command count and the menu wiring number come from
|
||||
# docs/parity.md.
|
||||
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05.
|
||||
area = "automation"
|
||||
title = "Automation"
|
||||
order = 6
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-1"
|
||||
target = "Every menu item dispatches a command id (UI, CLI, control channel and MCP share them)"
|
||||
status = "done"
|
||||
note = "docs/parity.md: all menu items live; menus.rs is_live"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-1"
|
||||
target = "Record actions in a document-independent form (implicit layer, merged type keystrokes)"
|
||||
status = "missing"
|
||||
issue = 219
|
||||
note = "actions.rs:47 copies raw journal entries with explicit layer ids"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-2"
|
||||
target = "Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import"
|
||||
status = "missing"
|
||||
issue = 219
|
||||
note = "Panel deletes whole actions only (actions.rs:146)"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-3"
|
||||
target = "actions.record/stop/play/list commands for agents and the CLI"
|
||||
status = "missing"
|
||||
issue = 219
|
||||
note = "Recording is UI-only (actions.rs:37-60)"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-4"
|
||||
target = "Replay tests for crop, filters, selections and styled text on a second document"
|
||||
status = "missing"
|
||||
issue = 219
|
||||
note = "One replay test (actions.rs:171: new layer, rect select, fill)"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-5"
|
||||
target = "Batch: subfolders, open documents, naming template, progress/cancel, error log, stop"
|
||||
status = "partial"
|
||||
issue = 219
|
||||
note = "file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-6"
|
||||
target = "Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats"
|
||||
status = "missing"
|
||||
issue = 219
|
||||
note = "runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-7"
|
||||
target = "Local ONNX models behind a flag, managed in Preferences; subject select, background removal"
|
||||
status = "missing"
|
||||
issue = 219
|
||||
note = "No ONNX runtime dependency"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-8"
|
||||
target = "xtask check that every panel button routes through a command id"
|
||||
status = "missing"
|
||||
issue = 219
|
||||
note = "No such xtask check"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-9"
|
||||
target = "Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles"
|
||||
status = "partial"
|
||||
issue = 219
|
||||
note = "filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform; no caret API"
|
||||
|
||||
[[item]]
|
||||
id = "AUTO-219-10"
|
||||
target = "Agent acceptance suite grown to 30-50 workflows"
|
||||
status = "partial"
|
||||
issue = 219
|
||||
note = "automation/tests/agent_tasks.rs runs 10 workflows over MCP"
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
# Scorecard checklist: open bugs (#203–#208).
|
||||
# Each item: id, short target, status (done | partial | missing), issue, note (evidence).
|
||||
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05.
|
||||
area = "bugs"
|
||||
title = "Open bugs"
|
||||
order = 9
|
||||
|
||||
[[item]]
|
||||
id = "BUG-203-1"
|
||||
target = "Every document write goes through one atomic_write (temp, fsync, rename)"
|
||||
status = "missing"
|
||||
issue = 203
|
||||
note = "services.rs:125 std::fs::write; file_cmds.rs:91 fs::write; automation/files.rs:67 in place"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-203-2"
|
||||
target = "Temp file in the same dir, sync_all, rename over target, fsync dir on Unix"
|
||||
status = "missing"
|
||||
issue = 203
|
||||
note = "No sync_all anywhere; services.rs:71 write_atomic (prefs only) does tmp + rename, no fsync"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-203-3"
|
||||
target = "Windows: retry the rename with backoff on PermissionDenied"
|
||||
status = "missing"
|
||||
issue = 203
|
||||
note = "No retry around fs::rename (services.rs:77, preset_store.rs:189, format/store.rs:457)"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-203-4"
|
||||
target = "Unwritable directory errors clearly, never falls back to writing in place"
|
||||
status = "missing"
|
||||
issue = 203
|
||||
note = "No atomic path exists for documents; all document writes are in place"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-203-5"
|
||||
target = "On any failure the original file is untouched and the temp file removed"
|
||||
status = "missing"
|
||||
issue = 203
|
||||
note = "services.rs:76 write_atomic leaves .tmp behind if rename fails; documents write in place"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-203-6"
|
||||
target = "Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes"
|
||||
status = "missing"
|
||||
issue = 203
|
||||
note = "No such tests under crates/ or apps/"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-1"
|
||||
target = "Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden"
|
||||
status = "missing"
|
||||
issue = 204
|
||||
note = "prefs.rs:196-202; no reader outside prefs.rs (see the prefs audit)"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-2"
|
||||
target = "workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden"
|
||||
status = "missing"
|
||||
issue = 204
|
||||
note = "prefs.rs:163,166,229; no reader outside prefs.rs"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-3"
|
||||
target = "RAW prefs and ask_before_saving_layered_tiff read, or hidden"
|
||||
status = "missing"
|
||||
issue = 204
|
||||
note = "raw_defaults (prefs.rs:712), ask_before_saving_layered_tiff (prefs.rs:312) have no readers"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-4"
|
||||
target = "Recent files persisted through prefs across restarts"
|
||||
status = "missing"
|
||||
issue = 204
|
||||
note = "ui-egui lib.rs:461 push_recent only touches app.ui.recent_files"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-5"
|
||||
target = "Recent-files list honours recent_file_count (default 20)"
|
||||
status = "missing"
|
||||
issue = 204
|
||||
note = "lib.rs:465 truncate(10) hard-coded; menus.rs:1065 test asserts 'capped at 10'"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-204-6"
|
||||
target = "Test: every Prefs field is read somewhere or on a hidden allowlist"
|
||||
status = "partial"
|
||||
issue = 204
|
||||
note = "The scorecard's prefs audit counts unread fields; no engine test or allowlist yet"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-205-1"
|
||||
target = "Free Transform with a selection moves only the selected part of a linked mask"
|
||||
status = "missing"
|
||||
issue = 205
|
||||
note = "transform_cmds.rs:127-130 warps the whole linked mask even when the selection split pixels"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-205-2"
|
||||
target = "Live transform preview draws the layer with its mask applied"
|
||||
status = "missing"
|
||||
issue = 205
|
||||
note = "transform_tool.rs:173 preview_image uses the raw surface"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-205-3"
|
||||
target = "edit.transform honours target mask: transforms only a targeted, unlinked mask"
|
||||
status = "missing"
|
||||
issue = 205
|
||||
note = "transform_cmds.rs:214-270 never reads a target param"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-205-4"
|
||||
target = "Test: a linked raster mask with no selection moves with the pixels"
|
||||
status = "partial"
|
||||
issue = 205
|
||||
note = "Implemented at transform_cmds.rs:127; no raster-mask test"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-205-5"
|
||||
target = "Tests: linked mask + selection stays aligned; unlinked stays; targeted mask only"
|
||||
status = "missing"
|
||||
issue = 205
|
||||
note = "Unlinked case honoured (m.linked check, l.128) but untested; the other two not implemented"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-206-1"
|
||||
target = "CharStyle models per-character manual kerning separately from the auto-kern mode"
|
||||
status = "missing"
|
||||
issue = 206
|
||||
note = "doc/text.rs:76 only `kerning: Kerning` enum (Metrics/Optical/Off)"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-206-2"
|
||||
target = "PSD import keeps Optical and manual Kerning values"
|
||||
status = "missing"
|
||||
issue = 206
|
||||
note = "text/psd.rs:264 maps anything not Off to Metrics; Kerning value ignored"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-206-3"
|
||||
target = "PSD export writes the real AutoKerning mode and Kerning values"
|
||||
status = "missing"
|
||||
issue = 206
|
||||
note = "text/psd.rs:380-381 AutoKerning bool only; Kerning hard-coded 0"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-206-4"
|
||||
target = "Alt+Left/Right at a collapsed caret adjusts pair kerning; Character panel field"
|
||||
status = "missing"
|
||||
issue = 206
|
||||
note = "type_tool.rs:896-912 only a mode combo"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-206-5"
|
||||
target = "Tests: PSD round trip of manual and Optical kerning, a real file in the oracle corpus"
|
||||
status = "missing"
|
||||
issue = 206
|
||||
note = "No kerning round-trip test"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-207-1"
|
||||
target = "Retouch tools respect the paint target (mask, alpha channel, Quick Mask)"
|
||||
status = "missing"
|
||||
issue = 207
|
||||
note = "retouch_cmds.rs:83 run_stroke always uses l.surface_mut(); no paint_target() call"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-207-2"
|
||||
target = "Sample All Layers applies to Blur, Sharpen and Smudge"
|
||||
status = "missing"
|
||||
issue = 207
|
||||
note = "Checkbox at retouch_ui.rs:171-190 is not passed to the stroke commands"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-207-3"
|
||||
target = "Smudge mixes premultiplied RGBA (no dark fringe at transparent edges)"
|
||||
status = "missing"
|
||||
issue = 207
|
||||
note = "No premultiply in paint/src/retouch.rs"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-207-4"
|
||||
target = "edit.transform transforms a targeted alpha channel"
|
||||
status = "missing"
|
||||
issue = 207
|
||||
note = "transform_cmds.rs transform() has no target handling"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-207-5"
|
||||
target = "Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe"
|
||||
status = "missing"
|
||||
issue = 207
|
||||
note = "Only smudge_drags_colour_along_the_stroke (retouch.rs:407)"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-208-1"
|
||||
target = "Shift while dragging a Rect/Elliptical Marquee constrains to a square/circle"
|
||||
status = "missing"
|
||||
issue = 208
|
||||
note = "canvas.rs:1498 and :1769 pass shift=false to marquee_end"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-208-2"
|
||||
target = "Alt while dragging a marquee draws from the centre"
|
||||
status = "missing"
|
||||
issue = 208
|
||||
note = "marquee_end (chrome_ui.rs:157) has no centre mode"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-208-3"
|
||||
target = "W x H px readout beside the cursor while dragging a marquee"
|
||||
status = "missing"
|
||||
issue = 208
|
||||
note = "No size readout in canvas.rs or tool_feedback.rs"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-208-4"
|
||||
target = "Add/subtract/intersect from modifiers pressed before the drag, kept by a test"
|
||||
status = "partial"
|
||||
issue = 208
|
||||
note = "Mods captured at press (canvas.rs:1679, used :1779); only a badge test (tool_feedback.rs:195)"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-208-5"
|
||||
target = "Kittest: Shift drag gives a square, Alt drag is centred, readout matches the size"
|
||||
status = "missing"
|
||||
issue = 208
|
||||
note = "No marquee drag tests"
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# Scorecard checklist: distribution (#220) and what ships today (.github/workflows/release.yml).
|
||||
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05.
|
||||
area = "distribution"
|
||||
title = "Distribution"
|
||||
order = 8
|
||||
|
||||
[[item]]
|
||||
id = "DIST-1"
|
||||
target = "macOS universal build (aarch64 + x86_64), signed and notarized"
|
||||
status = "done"
|
||||
note = "release.yml:66,75,93 (macos-15); 0.2.0 shipped notarized"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-2"
|
||||
target = "Windows x64 and x86 builds"
|
||||
status = "partial"
|
||||
note = "release.yml:113-114; signing skipped with a warning until the signing material exists"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-3"
|
||||
target = "Linux x86_64 and aarch64: AppImage, deb, rpm, tar.gz"
|
||||
status = "done"
|
||||
note = "release.yml:162-163,186"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-4"
|
||||
target = "Flatpak single-file bundles, x86_64 and aarch64"
|
||||
status = "done"
|
||||
note = "release.yml:204-216 (#163)"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-5"
|
||||
target = "Web build (wasm32, trunk)"
|
||||
status = "done"
|
||||
note = "release.yml:255"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-220-1"
|
||||
target = "Help > Check for Updates plus an opt-out periodic release-feed check on all desktops"
|
||||
status = "missing"
|
||||
issue = 220
|
||||
note = "No update check in crates"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-220-2"
|
||||
target = "Windows ARM64 (aarch64-pc-windows-msvc) in the release matrix, signed like x64"
|
||||
status = "missing"
|
||||
issue = 220
|
||||
note = "release.yml:113-114 only x86_64 and i686 msvc"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-220-3"
|
||||
target = "In-app bug report with reviewable diagnostics that opens a prefilled issue"
|
||||
status = "missing"
|
||||
issue = 220
|
||||
note = "No bug report dialog in crates"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-220-4"
|
||||
target = "Translation coverage per language reported by xtask; RTL layout for Arabic and Hebrew"
|
||||
status = "missing"
|
||||
issue = 220
|
||||
note = "No i18n in crates or xtask (waits on #169)"
|
||||
|
||||
[[item]]
|
||||
id = "DIST-220-5"
|
||||
target = "Linux AppImage x86_64 + aarch64 ship; Flatpak kept; Flathub later (#173)"
|
||||
status = "partial"
|
||||
issue = 220
|
||||
note = "AppImage and Flatpak ship (release.yml:162-216); Flathub not done"
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
# Scorecard checklist: file compatibility (#215 formats, #216 measurement).
|
||||
# Corpus numbers come from crates/io/tests/corpus.rs, not from here.
|
||||
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05.
|
||||
area = "file"
|
||||
title = "File compatibility"
|
||||
order = 2
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-1"
|
||||
target = "Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "smart_cmds.rs:415 detach_psd drops SoLd; psd_export.rs:276 re-emits imported raw only"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-2"
|
||||
target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "psd_import.rs:259 smart_filters: Vec::new(); no filterFX in crates"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-3"
|
||||
target = "Filter mask can be painted directly, with undo"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "smart_cmds.rs:382 mask only from the selection"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-4"
|
||||
target = "Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "add_smart_filter callers: filters, lens, distort, plug-ins only"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-5"
|
||||
target = "Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview"
|
||||
status = "partial"
|
||||
issue = 215
|
||||
note = "smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-6"
|
||||
target = "Smart Object corner skew and perspective (quad or homography transform)"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "doc lib.rs:312 SmartObject.transform is an Affine"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-7"
|
||||
target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "No 37724 / ImageSourceData in crates"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-8"
|
||||
target = "Large and BigTIFF import with banded decode across all IFDs"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "codecs tiff.rs:3 'Only the first IFD is read'"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-9"
|
||||
target = "SVG opens and places as shape layers or a vector Smart Object"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "No SVG codec or import"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-10"
|
||||
target = "Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "Only print_cmds.rs:133 print_pdf (raster print page)"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-11"
|
||||
target = "RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-12"
|
||||
target = "Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter"
|
||||
status = "partial"
|
||||
issue = 215
|
||||
note = "No histogram; lens_cmds.rs:268 stores an in-app Smart Filter, PSD drops it"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-13"
|
||||
target = "Video layer frames persist in .pcraft; PSD video layers mapped"
|
||||
status = "partial"
|
||||
issue = 215
|
||||
note = "format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-14"
|
||||
target = "User swatch model with ACO/ASE import and export"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "panels.rs:954 const SWATCHES: [[u8; 3]; 40]"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-15"
|
||||
target = "Layer Style contour picker, curve editor, presets, .shc import/export"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "doc effects.rs:57 Contour model only"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-16"
|
||||
target = "Advanced Blending (knockout, blend as group, mask hides effects) end to end"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "No knockout / blend_interior / transparency_shapes in doc, compose or io"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-17"
|
||||
target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "compose multichannel.rs:1 spot preview for Multichannel only"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-18"
|
||||
target = "Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling"
|
||||
status = "partial"
|
||||
issue = 215
|
||||
note = "file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-19"
|
||||
target = "Save As JPEG opens a JPEG Options dialog (quality 0-12, format options)"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "ExportSettings has jpeg_quality 1-100 only (lib.rs:118)"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-20"
|
||||
target = "Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages"
|
||||
status = "partial"
|
||||
issue = 215
|
||||
note = "artboard_ui.rs:1 names/properties only; no tool, + buttons or duplicate"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-21"
|
||||
target = "Local libraries of colours, patterns, images and layers, as linked items or copies"
|
||||
status = "missing"
|
||||
issue = 215
|
||||
note = "Only preset libraries (preset_store.rs)"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-216-1"
|
||||
target = "Every corpus fetched and enforced in CI, cached by pin"
|
||||
status = "missing"
|
||||
issue = 216
|
||||
note = "No corpus step in ci.yml; in progress in #190"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-216-2"
|
||||
target = "Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests"
|
||||
status = "missing"
|
||||
issue = 216
|
||||
note = "Only banded.rs covers a >8 MP synthetic export"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-216-3"
|
||||
target = "TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported"
|
||||
status = "missing"
|
||||
issue = 216
|
||||
note = "corpus/tiff, raw and exr only listed by xtask corpus; no test reads them"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-216-4"
|
||||
target = "Exports re-open in a third-party oracle editor"
|
||||
status = "missing"
|
||||
issue = 216
|
||||
note = "The round trip is our export -> our import"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-216-5"
|
||||
target = "Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure"
|
||||
status = "missing"
|
||||
issue = 216
|
||||
note = "Features absent; no atomic-save failure-injection test"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-216-6"
|
||||
target = "30-50 end-to-end workflow acceptance tasks driven through MCP or commands"
|
||||
status = "partial"
|
||||
issue = 216
|
||||
note = "automation/tests/agent_tasks.rs: 10 MCP tasks"
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# Scorecard checklist: reliability (#212, #203, never-crash).
|
||||
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05.
|
||||
area = "reliability"
|
||||
title = "Reliability"
|
||||
order = 7
|
||||
|
||||
[[item]]
|
||||
id = "REL-1"
|
||||
target = "panic_hunt fuzzes every command with adversarial params"
|
||||
status = "done"
|
||||
note = "engine/tests/panic_hunt.rs: every command, own thread, 4 s timeout; #[ignore], run in the gate"
|
||||
|
||||
[[item]]
|
||||
id = "REL-2"
|
||||
target = "Every workspace crate carries the never-crash deny(clippy::unwrap_used, ...) attribute"
|
||||
status = "done"
|
||||
note = "Counted automatically above (crate roots under crates/ and apps/); testkit gained it in #221"
|
||||
|
||||
[[item]]
|
||||
id = "REL-3"
|
||||
target = "The app catches escaped panics around command dispatch and import/export"
|
||||
status = "partial"
|
||||
note = "engine lib.rs:336 dispatch; crash_guard.rs:21 import/export; the dispatch guard is untested"
|
||||
|
||||
[[item]]
|
||||
id = "REL-4"
|
||||
target = "Atomic save (temp, fsync, rename) for every document write"
|
||||
status = "missing"
|
||||
issue = 203
|
||||
note = "Documents use fs::write in place; prefs/presets/bundles use tmp + rename without fsync"
|
||||
|
||||
[[item]]
|
||||
id = "REL-212-1"
|
||||
target = "Fallible allocation for paste/erase/fill/transform/save/import, clear out-of-memory error"
|
||||
status = "missing"
|
||||
issue = 212
|
||||
note = "try_reserve only in io/flat.rs:221; codec max_alloc limits cover decode only"
|
||||
|
||||
[[item]]
|
||||
id = "REL-212-2"
|
||||
target = "Export switches to PSB when the estimated file size exceeds 2 GB"
|
||||
status = "missing"
|
||||
issue = 212
|
||||
note = "io/psd_export.rs:558 switches only when a side exceeds 30000 px"
|
||||
|
||||
[[item]]
|
||||
id = "REL-212-3"
|
||||
target = "Tests: an allocation-limit injection makes each command Err with the document unchanged"
|
||||
status = "missing"
|
||||
issue = 212
|
||||
note = "Only codec/raw max_alloc tests (codecs/tests/limits.rs:48)"
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
# Scorecard checklist: tools (#213 painting and retouching, #214 selection, transform, drawing).
|
||||
# An item is done when it has a command, a UI and tests.
|
||||
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05.
|
||||
area = "tools"
|
||||
title = "Tools"
|
||||
order = 3
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-1"
|
||||
target = "Pencil tool: aliased 1 px painting, square pixel-aligned cursor"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "paint.pencil exists (brush_cmds.rs:322) with an aliasing test; no Tool::Pencil (state.rs:7)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-2"
|
||||
target = "Mixer Brush tool on the toolbar"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "paint.mixerBrush (brush_cmds.rs:337) + panel options; no Tool variant"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-3"
|
||||
target = "Patch tool with a live preview of the patched area"
|
||||
status = "missing"
|
||||
issue = 213
|
||||
note = "No patch command or tool"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-4"
|
||||
target = "Content-Aware Move tool reusing inpaint completion"
|
||||
status = "missing"
|
||||
issue = 213
|
||||
note = "inpaint::complete only used by spot healing (retouch_cmds.rs:386)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-5"
|
||||
target = "Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke"
|
||||
status = "missing"
|
||||
issue = 213
|
||||
note = "Rasterize only in the layer menus (layer_menu_ui.rs:40)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-6"
|
||||
target = "Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "edit.fill: color or pattern + opacity, Shift+F5 (commands.rs:263-267); no blend/history/CA/persist"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-7"
|
||||
target = "Live stroke preview for every brush-based retouch tool"
|
||||
status = "missing"
|
||||
issue = 213
|
||||
note = "Live stroke only for Brush and Eraser (canvas.rs:1684)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-8"
|
||||
target = "Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "Panel W/H + overlay settings (preset_panels.rs:946-1015); no link, overlay never drawn"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-9"
|
||||
target = "Eyedropper comparison ring (new colour above, current below) while sampling"
|
||||
status = "missing"
|
||||
issue = 213
|
||||
note = "sample_eyedropper (canvas.rs:1552) draws no ring"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-10"
|
||||
target = "Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout"
|
||||
status = "missing"
|
||||
issue = 213
|
||||
note = "Only [ ] resize (shortcuts.rs:270)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-11"
|
||||
target = "Editable pressure curve, global and per control"
|
||||
status = "missing"
|
||||
issue = 213
|
||||
note = "No pressure curve in engine, paint or ui-egui"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-12"
|
||||
target = "Import an image file as a brush tip"
|
||||
status = "missing"
|
||||
issue = 213
|
||||
note = "Only brush.defineFromSelection and brush.presets.importAbr"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-13"
|
||||
target = "Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "Missing-pattern ABR test (abr_map/tests.rs:181), hardness test; no seam/black tests"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-213-14"
|
||||
target = "Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux"
|
||||
status = "partial"
|
||||
issue = 213
|
||||
note = "Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-1"
|
||||
target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "Opens the generic select.refineEdge dialog (menus.rs:308)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-2"
|
||||
target = "Marquee: Shift square, Alt from centre, live W x H readout"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "marquee_end supports shift (chrome_ui.rs:167) but canvas.rs passes false (#208)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-3"
|
||||
target = "Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-4"
|
||||
target = "Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab"
|
||||
status = "missing"
|
||||
issue = 214
|
||||
note = "move_mods.rs covers Shift/Alt/arrows only"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-5"
|
||||
target = "Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180)"
|
||||
status = "missing"
|
||||
issue = 214
|
||||
note = "No context menu in canvas.rs or transform_tool.rs"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-6"
|
||||
target = "Puppet Warp mesh follows the object outline (constrained triangulation)"
|
||||
status = "missing"
|
||||
issue = 214
|
||||
note = "algo/puppet.rs:136 builds a block-grid mesh"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-7"
|
||||
target = "Content-Aware Scale through the Free Transform box with a live proxy preview"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "edit.contentAwareScale (edit_menu_cmds.rs:899) behind a parameter dialog"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-8"
|
||||
target = "A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg)"
|
||||
status = "missing"
|
||||
issue = 214
|
||||
note = "Arbitrary rotation is a generic dialog; ruler only feeds straighten"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-9"
|
||||
target = "Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "Ratio dropdown and fields only (panels.rs:551-579)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-10"
|
||||
target = "Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle"
|
||||
status = "missing"
|
||||
issue = 214
|
||||
note = "pen_down gets no modifiers (canvas.rs:1632)"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-11"
|
||||
target = "Stroke Path dialog: pick any brush-based tool, Simulate Pressure"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-12"
|
||||
target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-13"
|
||||
target = "Mask Properties: Density and Feather controls"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-14"
|
||||
target = "Vanishing Point window: draw and edit planes, paste, clone in perspective"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-15"
|
||||
target = "Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer"
|
||||
status = "partial"
|
||||
issue = 214
|
||||
note = "Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog"
|
||||
|
||||
[[item]]
|
||||
id = "TOOL-214-16"
|
||||
target = "Liquify: redo (Cmd+Shift+Z)"
|
||||
status = "missing"
|
||||
issue = 214
|
||||
note = "liquify_ui.rs has undo only"
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# Scorecard checklist: type (#218, #206).
|
||||
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05.
|
||||
area = "type"
|
||||
title = "Type"
|
||||
order = 5
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-1"
|
||||
target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip"
|
||||
status = "missing"
|
||||
issue = 218
|
||||
note = "doc text.rs:209 TextShape has Point and Box only"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-2"
|
||||
target = "Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret"
|
||||
status = "missing"
|
||||
issue = 218
|
||||
note = "Engine has TextDirection (text.rs:155); no UI"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-3"
|
||||
target = "Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL"
|
||||
status = "missing"
|
||||
issue = 218
|
||||
note = "fonts.rs:28 fallback covers Arabic/Hebrew; no per-script golden tests (only a bidi test)"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-4"
|
||||
target = "Variable font axis sliders from the font's axes; opsz follows the point size"
|
||||
status = "partial"
|
||||
issue = 218
|
||||
note = "type_cmds.rs:184 accepts variations; FaceInfo.axes unused in the UI"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-5"
|
||||
target = "OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus"
|
||||
status = "partial"
|
||||
issue = 218
|
||||
note = "Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-6"
|
||||
target = "Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206)"
|
||||
status = "missing"
|
||||
issue = 218
|
||||
note = "Optical renders as Metrics (text.rs:21); Alt+arrow moves by word"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-7"
|
||||
target = "Warp Text dialog previews live and opens with the layer's current warp"
|
||||
status = "missing"
|
||||
issue = 218
|
||||
note = "view_cmds.rs:834 always seeds arc, bend 50"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-8"
|
||||
target = "Text editing: select all, double/triple/quadruple-click selection, paste, with tests"
|
||||
status = "partial"
|
||||
issue = 218
|
||||
note = "Cmd+A, paste and double-click exist; no triple/quadruple click, no tests"
|
||||
|
||||
[[item]]
|
||||
id = "TYPE-218-9"
|
||||
target = "Offer 'Rasterize and continue?' for Distort/Perspective/Warp on live type"
|
||||
status = "missing"
|
||||
issue = 218
|
||||
note = "No such prompt"
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
# Scorecard checklist: workspace and UI (#217). The "settings that do nothing" count comes from
|
||||
# the prefs audit (#204), not from here.
|
||||
# Statuses must be true on main. Verified against 4b53f62 on 2026-10-05.
|
||||
area = "ui"
|
||||
title = "Workspace and UI"
|
||||
order = 4
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-1"
|
||||
target = "Tool options, last Export As / Save for Web / Fill settings and font recents persist"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "No tool_options/export fields in Preferences (prefs.rs:694); export_dialog.rs:21 png/85"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-2"
|
||||
target = "Window size and dock width remembered across restarts"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "main.rs:108 fixed 1440x900; dock.rs:529 snapshot omits the width"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-3"
|
||||
target = "File > Open starts in the last-used folder"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "services.rs:110 FileDialog::new() without set_directory"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-4"
|
||||
target = "Preferences all take effect; recent files persist and honour recent_file_count (#204)"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "See the prefs audit and BUG-204-*"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-5"
|
||||
target = "Panel groups can float (menu + drag out of the dock) and are restored at launch"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "No floating groups in dock.rs/panels.rs"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-6"
|
||||
target = "Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "canvas.rs:674-770 tabs click/close only"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-7"
|
||||
target = "Command palette shows recently run commands on an empty query"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "palette.rs:83-108 fuzzy hits only"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-8"
|
||||
target = "Tool-specific canvas right-click menu; right-click spelling suggestions in text fields"
|
||||
status = "partial"
|
||||
issue = 217
|
||||
note = "paint_mouse.rs:67 right-click opens the brush picker only"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-9"
|
||||
target = "UI scale presets 100/115/125/150/200 % through zoom_factor, remembered"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "prefs.rs:61 UiScale unused; input_tests.rs:71 asserts zoom_factor stays 1.0"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-10"
|
||||
target = "Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "No such prefs; start screen (canvas.rs:825) has no recents"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-11"
|
||||
target = "Soft dark and soft light themes with picker previews; outlined knobs in light themes"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "theme.rs:28 five themes"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-12"
|
||||
target = "Save custom New Document presets; a real Recent category"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "new_doc_ui.rs:17 'Recent' is a static list"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-13"
|
||||
target = "Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider"
|
||||
status = "partial"
|
||||
issue = 217
|
||||
note = "widgets.rs:131 DragValue scrubs; no expressions, label scrub or reset"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-14"
|
||||
target = "Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "panels.rs:1108 plain dropdown; trash is click-only (panels.rs:1213)"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-15"
|
||||
target = "Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "adjust_editors.rs:397 has none of them"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-16"
|
||||
target = "Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "Only the Hue slider has a gradient"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-17"
|
||||
target = "Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "panels.rs:1837 single field + hex/RGB readout"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-18"
|
||||
target = "Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "canvas.rs:1107-1125 no Alt handling"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-19"
|
||||
target = "Shortcut hints rendered per platform; Reveal label per OS"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "~33 hard-coded modifier glyph strings in ui-egui; menu_catalog.rs:341 one label"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-20"
|
||||
target = "'Show rulers in new documents' preference; guides stored as f64"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "No rulers pref; doc lib.rs:523 guides are Vec<f32>"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-21"
|
||||
target = "One Preset Manager for every preset kind with folders, search, reorder, load/save sets"
|
||||
status = "partial"
|
||||
issue = 217
|
||||
note = "edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-22"
|
||||
target = "Stamp Visible into a new layer keeping originals; Alt+Merge keeps originals"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "Only layer.mergeVisible (edit_cmds.rs:416)"
|
||||
|
||||
[[item]]
|
||||
id = "UI-217-23"
|
||||
target = "Layer clipboard: copy/paste whole layers (masks, styles, type) between documents"
|
||||
status = "missing"
|
||||
issue = 217
|
||||
note = "Only copy/paste layer style (extra_cmds.rs:592)"
|
||||
|
|
@ -10,3 +10,6 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
# perf/budgets.toml and scorecard/*.toml (MIT OR Apache-2.0).
|
||||
toml = "1"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod perf;
|
||||
mod psd_tools;
|
||||
mod scorecard;
|
||||
mod sha256;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
|
@ -26,6 +28,11 @@ commands:
|
|||
into corpus/psd-tools
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
parity Photoshop menu parity; rewrites docs/parity.md
|
||||
perf [--quick] [--update-baseline] [--threshold PCT] [--bench NAME]... [--skip-build] [--reuse]
|
||||
run the release benches, merge them by scenario id into target/perf/results.json,
|
||||
check perf/budgets.toml and perf/baseline.json (non-zero on a broken budget or regression)
|
||||
scorecard [--check]
|
||||
regenerate docs/scorecard.md (--check: fail if it is stale)
|
||||
version [set X.Y.Z[-pre]]
|
||||
print the workspace version, or set it (Cargo.toml + Cargo.lock)
|
||||
ico <out.ico> <in.png>...
|
||||
|
|
@ -43,6 +50,8 @@ fn main() -> ExitCode {
|
|||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("parity") => cmd_parity(),
|
||||
Some("perf") => perf::run(&root(), &rest),
|
||||
Some("scorecard") => scorecard::run(&root(), &rest),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,821 @@
|
|||
//! `cargo xtask scorecard [--check]`: generate `docs/scorecard.md` (#221).
|
||||
//!
|
||||
//! Sources, all committed, so the output is deterministic and CI can check it:
|
||||
//! - `perf/budgets.toml` + `perf/baseline.json`: the performance table (the baseline's full-run
|
||||
//! numbers against the budgets). A fresh `cargo xtask perf` result in `target/` is not used:
|
||||
//! publish it with `cargo xtask perf --update-baseline`.
|
||||
//! - `crates/io/tests/corpus.rs`: the corpus floors (`Source { .. }` constants).
|
||||
//! - `scorecard/*.toml`: per-area checklists (id, target, status, issue, note).
|
||||
//! - the prefs audit: `Preferences` fields that no code outside `prefs.rs` / `prefs_ui.rs` reads.
|
||||
//! - counted from the tree: never-crash attribute coverage, `docs/parity.md`'s live count.
|
||||
//!
|
||||
//! `--check` fails when `docs/scorecard.md` differs from what would be generated.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::perf;
|
||||
|
||||
pub const OUT: &str = "docs/scorecard.md";
|
||||
const CORPUS_RS: &str = "crates/io/tests/corpus.rs";
|
||||
const PREFS_RS: &str = "crates/engine/src/prefs.rs";
|
||||
|
||||
// ---- checklists ------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ItemStatus {
|
||||
Done,
|
||||
Partial,
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Item {
|
||||
pub id: String,
|
||||
pub target: String,
|
||||
pub status: ItemStatus,
|
||||
pub issue: Option<u32>,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Checklist {
|
||||
pub area: String,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub order: u32,
|
||||
#[serde(default)]
|
||||
pub item: Vec<Item>,
|
||||
}
|
||||
|
||||
impl Checklist {
|
||||
pub fn counts(&self) -> (usize, usize, usize) {
|
||||
let n = |s| self.item.iter().filter(|i| i.status == s).count();
|
||||
(n(ItemStatus::Done), n(ItemStatus::Partial), n(ItemStatus::Missing))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_checklist(name: &str, text: &str) -> Result<Checklist, String> {
|
||||
let c: Checklist = toml::from_str(text).map_err(|e| format!("{name}: {e}"))?;
|
||||
for i in &c.item {
|
||||
if i.id.trim().is_empty() || i.target.trim().is_empty() {
|
||||
return Err(format!("{name}: every item needs an id and a target"));
|
||||
}
|
||||
if i.target.contains('|') || i.note.as_deref().is_some_and(|n| n.contains('|')) {
|
||||
return Err(format!("{name}: {}: `|` breaks the Markdown table", i.id));
|
||||
}
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
fn load_checklists(root: &Path) -> Result<Vec<Checklist>, String> {
|
||||
let dir = root.join("scorecard");
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| format!("read {}: {e}", dir.display()))?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut out = Vec::new();
|
||||
let mut ids = BTreeSet::new();
|
||||
for f in files {
|
||||
let name = format!("scorecard/{}", f.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
|
||||
let text = std::fs::read_to_string(&f).map_err(|e| format!("read {name}: {e}"))?;
|
||||
let c = parse_checklist(&name, &text)?;
|
||||
for i in &c.item {
|
||||
if !ids.insert(i.id.clone()) {
|
||||
return Err(format!("{name}: duplicate item id {}", i.id));
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.area.cmp(&b.area)));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- corpus floors ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Corpus {
|
||||
pub label: String,
|
||||
pub dir: String,
|
||||
pub files: Option<u32>,
|
||||
pub pass_floor: Option<u32>,
|
||||
pub roundtrip_floor: Option<u32>,
|
||||
pub doc: String,
|
||||
}
|
||||
|
||||
/// Every `const X: Source = Source { .. };` in `corpus.rs`, with its doc comment. Fields are
|
||||
/// read by name, so new fields or new sources (more corpora) need no change here.
|
||||
pub fn parse_corpus(src: &str) -> Vec<Corpus> {
|
||||
let lines: Vec<&str> = src.lines().collect();
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim_start();
|
||||
if !(t.starts_with("const ") && t.contains(": Source")) {
|
||||
continue;
|
||||
}
|
||||
// The literal may continue on the next lines.
|
||||
let mut lit = String::new();
|
||||
for l in lines.iter().skip(i) {
|
||||
lit.push_str(l.trim());
|
||||
lit.push(' ');
|
||||
if l.contains("};") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(body) = lit.split_once("Source {").map(|(_, b)| b.split('}').next().unwrap_or("")) else { continue };
|
||||
let mut c = Corpus::default();
|
||||
for field in body.split(',') {
|
||||
let Some((k, v)) = field.split_once(':') else { continue };
|
||||
let v = v.trim().trim_matches('"').to_string();
|
||||
match k.trim() {
|
||||
"label" => c.label = v,
|
||||
"default_dir" => c.dir = v,
|
||||
"pass_floor" => c.pass_floor = v.parse().ok(),
|
||||
"roundtrip_floor" => c.roundtrip_floor = v.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut doc: Vec<&str> =
|
||||
lines.iter().take(i).rev().take_while(|l| l.trim_start().starts_with("///")).map(|l| l.trim_start().trim_start_matches('/').trim()).collect();
|
||||
doc.reverse();
|
||||
c.doc = doc.join(" ");
|
||||
c.files = c
|
||||
.doc
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.windows(2)
|
||||
.find(|w| w.get(1).is_some_and(|x| x.starts_with("files")))
|
||||
.and_then(|w| w.first()?.trim_start_matches('(').parse().ok());
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- prefs audit -----------------------------------------------------------------------------
|
||||
|
||||
/// A preference: `section.field` (`section` is the `Preferences` field holding a section
|
||||
/// struct), or a top-level `Preferences` field when `section` is `None`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct PrefField {
|
||||
pub section: Option<String>,
|
||||
pub field: String,
|
||||
/// Methods of the section type (in `prefs.rs`) that read this field through `self.field`:
|
||||
/// calling `section.method()` reads it too.
|
||||
pub helpers: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl PrefField {
|
||||
pub fn path(&self) -> String {
|
||||
match &self.section {
|
||||
Some(s) => format!("{s}.{}", self.field),
|
||||
None => self.field.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `pub struct` name → its `pub` fields `(name, type)`, for structs written at column 0.
|
||||
fn structs(src: &str) -> BTreeMap<String, Vec<(String, String)>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut cur: Option<(String, Vec<(String, String)>)> = None;
|
||||
for line in src.lines() {
|
||||
if let Some(rest) = line.strip_prefix("pub struct ")
|
||||
&& let Some(name) = rest.strip_suffix(" {")
|
||||
{
|
||||
cur = Some((name.trim().to_string(), Vec::new()));
|
||||
continue;
|
||||
}
|
||||
if line == "}" {
|
||||
if let Some((n, f)) = cur.take() {
|
||||
out.insert(n, f);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((_, fields)) = cur.as_mut()
|
||||
&& let Some(rest) = line.trim_start().strip_prefix("pub ")
|
||||
&& let Some((name, ty)) = rest.split_once(':')
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
{
|
||||
fields.push((name.to_string(), ty.trim().trim_end_matches(',').trim().to_string()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Top-level blocks (`header {` at column 0 up to the next `}` at column 0): `(header, body)`.
|
||||
fn blocks<'a>(src: &'a str, starts: &str) -> Vec<(&'a str, String)> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<(&str, String)> = None;
|
||||
for line in src.lines() {
|
||||
if cur.is_none() && line.starts_with(starts) && line.ends_with('{') {
|
||||
cur = Some((line.trim_end_matches('{').trim(), String::new()));
|
||||
continue;
|
||||
}
|
||||
if line == "}" {
|
||||
if let Some(b) = cur.take() {
|
||||
out.push(b);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some((_, body)) = cur.as_mut() {
|
||||
body.push_str(line);
|
||||
body.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Methods of `impl <ty>` blocks → the `self.` fields each one reads.
|
||||
fn helper_methods(src: &str, ty: &str) -> BTreeMap<String, BTreeSet<String>> {
|
||||
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for (_, body) in blocks(src, "impl ").into_iter().filter(|(h, _)| *h == format!("impl {ty}")) {
|
||||
let mut name: Option<String> = None;
|
||||
for line in body.lines() {
|
||||
let t = line.trim_start();
|
||||
if let Some(rest) = t.strip_prefix("pub fn ").or_else(|| t.strip_prefix("fn ")) {
|
||||
name = Some(rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect());
|
||||
}
|
||||
if let Some(n) = &name {
|
||||
let a = accesses(line);
|
||||
out.entry(n.clone()).or_default().extend(a.pairs.iter().filter_map(|p| p.strip_prefix("self.")).map(str::to_string));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The leaves of `pub struct Preferences` in `prefs.rs`, with the section helper methods that
|
||||
/// read each one.
|
||||
pub fn pref_fields(src: &str) -> Vec<PrefField> {
|
||||
let s = structs(src);
|
||||
let mut out = Vec::new();
|
||||
for (name, ty) in s.get("Preferences").cloned().unwrap_or_default() {
|
||||
match s.get(&ty) {
|
||||
Some(sub) => {
|
||||
let helpers = helper_methods(src, &ty);
|
||||
out.extend(sub.iter().map(|(f, _)| PrefField {
|
||||
section: Some(name.clone()),
|
||||
field: f.clone(),
|
||||
helpers: helpers.iter().filter(|(_, used)| used.contains(f)).map(|(m, _)| m.clone()).collect(),
|
||||
}))
|
||||
}
|
||||
None => out.push(PrefField { section: None, field: name, helpers: BTreeSet::new() }),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The engine code inside `prefs.rs`: its `impl Session` blocks (e.g. `apply_prefs`, which
|
||||
/// pushes preferences into live state). Everything else there is the model itself.
|
||||
pub fn session_code(src: &str) -> String {
|
||||
blocks(src, "impl Session").into_iter().map(|(_, b)| b).collect()
|
||||
}
|
||||
|
||||
fn ident_char(c: u8) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == b'_'
|
||||
}
|
||||
|
||||
/// Field accesses in one source file: every `.ident` and every `a.b` pair.
|
||||
#[derive(Default)]
|
||||
pub struct Accesses {
|
||||
singles: BTreeSet<String>,
|
||||
pairs: BTreeSet<String>,
|
||||
mentions_prefs: bool,
|
||||
}
|
||||
|
||||
pub fn accesses(src: &str) -> Accesses {
|
||||
let b = src.as_bytes();
|
||||
let mut a = Accesses { mentions_prefs: src.contains("prefs"), ..Default::default() };
|
||||
for (i, &c) in b.iter().enumerate() {
|
||||
if c != b'.' {
|
||||
continue;
|
||||
}
|
||||
let end = b.iter().skip(i + 1).take_while(|&&x| ident_char(x)).count();
|
||||
let Some(name) = src.get(i + 1..i + 1 + end).filter(|n| n.bytes().next().is_some_and(|x| x.is_ascii_alphabetic() || x == b'_')) else { continue };
|
||||
a.singles.insert(name.to_string());
|
||||
let start = b.iter().take(i).rev().take_while(|&&x| ident_char(x)).count();
|
||||
if let Some(prev) = src.get(i - start..i).filter(|p| !p.is_empty()) {
|
||||
a.pairs.insert(format!("{prev}.{name}"));
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Whether some file reads `f`: `section.field` appears, or both `.section` and `.field` appear
|
||||
/// in the same file (a section bound to a variable first). Top-level fields: `.field` in a file
|
||||
/// that mentions `prefs`. Generic names (`enabled`) can be counted as read by accident, never
|
||||
/// the other way round, so the count is a lower bound.
|
||||
pub fn is_read(f: &PrefField, files: &[Accesses]) -> bool {
|
||||
let reads = |a: &Accesses, s: &str, name: &str| a.pairs.contains(&format!("{s}.{name}")) || (a.singles.contains(s) && a.singles.contains(name));
|
||||
files.iter().any(|a| match &f.section {
|
||||
Some(s) => reads(a, s, &f.field) || f.helpers.iter().any(|m| reads(a, s, m)),
|
||||
None => a.mentions_prefs && a.singles.contains(&f.field),
|
||||
})
|
||||
}
|
||||
|
||||
/// `src` without its top-level `#[cfg(test)]` items (a `mod tests { .. }` block, a `mod x;`
|
||||
/// line or a test helper function), so unit tests don't count as reads.
|
||||
pub fn strip_tests(src: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut lines = src.lines();
|
||||
while let Some(line) = lines.next() {
|
||||
if line != "#[cfg(test)]" {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
continue;
|
||||
}
|
||||
// Further attributes, then the item: one line (`mod x;`) or a block up to `}` at column 0.
|
||||
for item in lines.by_ref() {
|
||||
if item.starts_with("#[") {
|
||||
continue;
|
||||
}
|
||||
if !item.trim_end().ends_with(';') {
|
||||
for l in lines.by_ref() {
|
||||
if l == "}" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Source files the audit scans: `.rs` under `crates/` and `apps/`, minus the prefs model
|
||||
/// (`prefs.rs` and its tests), tests, examples, benches and fuzz targets, with `#[cfg(test)]`
|
||||
/// items removed. `prefs_ui.rs` is scanned: the dialog is schema-driven and never names a field,
|
||||
/// so its field accesses are the appliers (canvas colours, autosave, history log).
|
||||
fn audit_sources(root: &Path) -> Vec<(PathBuf, String)> {
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if p.is_dir() {
|
||||
if !matches!(name.as_str(), "tests" | "examples" | "benches" | "fuzz" | "target") && !name.starts_with('.') {
|
||||
walk(&p, out);
|
||||
}
|
||||
} else if name.ends_with(".rs") && name != "tests.rs" {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk(&root.join("crates"), &mut files);
|
||||
walk(&root.join("apps"), &mut files);
|
||||
files.sort();
|
||||
let model = root.join(PREFS_RS);
|
||||
let prefs_dir = root.join("crates/engine/src/prefs");
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|p| *p != model && !p.starts_with(&prefs_dir))
|
||||
.filter_map(|p| {
|
||||
let s = std::fs::read_to_string(&p).ok()?;
|
||||
Some((p, strip_tests(&s.replace("\r\n", "\n"))))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Preferences that nothing reads (sorted paths).
|
||||
pub fn prefs_audit(root: &Path) -> Result<(usize, Vec<String>), String> {
|
||||
let src = std::fs::read_to_string(root.join(PREFS_RS)).map_err(|e| format!("read {PREFS_RS}: {e}"))?;
|
||||
let fields = pref_fields(&src);
|
||||
if fields.is_empty() {
|
||||
return Err(format!("{PREFS_RS}: no `pub struct Preferences` fields found (did the struct move?)"));
|
||||
}
|
||||
let mut files: Vec<Accesses> = audit_sources(root).iter().map(|(_, s)| accesses(s)).collect();
|
||||
files.push(accesses(&session_code(&strip_tests(&src))));
|
||||
let unread: Vec<String> = fields.iter().filter(|f| !is_read(f, &files)).map(PrefField::path).collect();
|
||||
Ok((fields.len(), unread))
|
||||
}
|
||||
|
||||
// ---- counted from the tree -------------------------------------------------------------------
|
||||
|
||||
const NEVER_CRASH: &str = "deny(clippy::unwrap_used";
|
||||
|
||||
/// Crate roots (`crates/*/src/lib.rs`; an app's `lib.rs`, else its `main.rs`) and the ones
|
||||
/// missing the never-crash attribute.
|
||||
fn never_crash(root: &Path) -> (usize, Vec<String>) {
|
||||
let mut roots = Vec::new();
|
||||
for (dir, lib_only) in [("crates", true), ("apps", false)] {
|
||||
let Ok(rd) = std::fs::read_dir(root.join(dir)) else { continue };
|
||||
let mut pkgs: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.join("Cargo.toml").exists()).collect();
|
||||
pkgs.sort();
|
||||
for p in pkgs {
|
||||
let lib = p.join("src/lib.rs");
|
||||
let main = p.join("src/main.rs");
|
||||
let r = if lib.exists() {
|
||||
lib
|
||||
} else if !lib_only && main.exists() {
|
||||
main
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
roots.push(r);
|
||||
}
|
||||
}
|
||||
let missing = roots
|
||||
.iter()
|
||||
.filter(|r| !std::fs::read_to_string(r).is_ok_and(|s| s.contains(NEVER_CRASH)))
|
||||
.map(|r| r.strip_prefix(root).unwrap_or(r).to_string_lossy().replace('\\', "/"))
|
||||
.collect();
|
||||
(roots.len(), missing)
|
||||
}
|
||||
|
||||
/// `**626 of 626 menu items live (100.0%).**` from `docs/parity.md`.
|
||||
fn parity_line(root: &Path) -> Option<String> {
|
||||
let s = std::fs::read_to_string(root.join("docs/parity.md")).ok()?;
|
||||
s.lines().find(|l| l.contains("menu items live")).map(|l| l.trim().trim_matches('*').trim_end_matches('.').to_string())
|
||||
}
|
||||
|
||||
// ---- rendering -------------------------------------------------------------------------------
|
||||
|
||||
fn status_word(s: ItemStatus) -> &'static str {
|
||||
match s {
|
||||
ItemStatus::Done => "done",
|
||||
ItemStatus::Partial => "partial",
|
||||
ItemStatus::Missing => "missing",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_checklist(c: &Checklist) -> String {
|
||||
let mut s = String::from("| Id | Target | Status | Issue | Note |\n|---|---|---|---|---|\n");
|
||||
for i in &c.item {
|
||||
s.push_str(&format!("| {} | {} | {} | {} | {} |\n", i.id, i.target, status_word(i.status), perf::issue_link(i.issue), i.note.as_deref().unwrap_or("")));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Everything `render` needs, gathered from the tree.
|
||||
pub struct Inputs {
|
||||
pub budgets: perf::Budgets,
|
||||
pub baseline: Option<Value>,
|
||||
pub corpora: Vec<Corpus>,
|
||||
pub checklists: Vec<Checklist>,
|
||||
pub prefs: (usize, Vec<String>),
|
||||
pub never_crash: (usize, Vec<String>),
|
||||
pub parity: Option<String>,
|
||||
pub panic_hunt: bool,
|
||||
}
|
||||
|
||||
pub fn gather(root: &Path) -> Result<Inputs, String> {
|
||||
let budgets = perf::parse_budgets(&std::fs::read_to_string(root.join(perf::BUDGETS)).map_err(|e| format!("read {}: {e}", perf::BUDGETS))?)?;
|
||||
let baseline = match std::fs::read_to_string(root.join(perf::BASELINE)) {
|
||||
Ok(t) => Some(serde_json::from_str(&t).map_err(|e| format!("{}: {e}", perf::BASELINE))?),
|
||||
Err(_) => None,
|
||||
};
|
||||
let corpora = parse_corpus(&std::fs::read_to_string(root.join(CORPUS_RS)).map_err(|e| format!("read {CORPUS_RS}: {e}"))?);
|
||||
Ok(Inputs {
|
||||
budgets,
|
||||
baseline,
|
||||
corpora,
|
||||
checklists: load_checklists(root)?,
|
||||
prefs: prefs_audit(root)?,
|
||||
never_crash: never_crash(root),
|
||||
parity: parity_line(root),
|
||||
panic_hunt: root.join("crates/engine/tests/panic_hunt.rs").exists(),
|
||||
})
|
||||
}
|
||||
|
||||
fn checklist_row(c: &Checklist, key: &str) -> String {
|
||||
let (d, p, m) = c.counts();
|
||||
format!("| [{}](#{}) | {d} | {p} | {m} | {key} |\n", c.title, anchor(&c.title))
|
||||
}
|
||||
|
||||
/// GitHub's heading anchor: lower-case, spaces to `-`, punctuation dropped.
|
||||
pub fn anchor(title: &str) -> String {
|
||||
title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
if c == ' ' {
|
||||
Some('-')
|
||||
} else if c.is_alphanumeric() || c == '-' {
|
||||
Some(c)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn render(inp: &Inputs) -> String {
|
||||
let outcomes = perf::outcomes_from_baseline(&inp.budgets, inp.baseline.as_ref());
|
||||
let (pass, over, tracked, nm) = perf::counts(&outcomes);
|
||||
let not_run = outcomes.iter().filter(|o| o.status == perf::Status::NotRun).count();
|
||||
let full = inp.baseline.as_ref().and_then(|b| b.get("modes")?.get("full"));
|
||||
let machine = inp.baseline.as_ref().and_then(|b| b.get("machine"));
|
||||
let ms = |k: &str| machine.and_then(|m| m.get(k)).and_then(Value::as_str).unwrap_or("unknown").to_string();
|
||||
let base_date = full.and_then(|f| f.get("date")).and_then(Value::as_str).map(|d| d.get(..10).unwrap_or(d).to_string());
|
||||
let (unread_n, total_prefs) = (inp.prefs.1.len(), inp.prefs.0);
|
||||
let (roots, missing_nc) = (&inp.never_crash.0, &inp.never_crash.1);
|
||||
|
||||
let mut s = String::from(
|
||||
"# PhotoCraft scorecard\n\nGenerated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, \
|
||||
`crates/io/tests/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and \
|
||||
regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers \
|
||||
here supersede estimates elsewhere.\n\n## Summary\n\n| Area | Done | Partial | Missing | Key numbers |\n|---|---:|---:|---:|---|\n",
|
||||
);
|
||||
let perf_key = match &base_date {
|
||||
Some(d) => format!(
|
||||
"{} scenarios: {pass} within budget, {over} over budget or failing, {tracked} tracked only, {not_run} not run, {nm} not measurable yet (baseline {d})",
|
||||
outcomes.len()
|
||||
),
|
||||
None => format!("{} scenarios, not run yet (no baseline); {nm} not measurable yet", outcomes.len()),
|
||||
};
|
||||
s.push_str(&format!("| [Performance](#performance) | {pass} | {over} | {} | {perf_key} |\n", nm + not_run));
|
||||
let corpus_key = inp
|
||||
.corpora
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let of = c.files.map_or_else(String::new, |f| format!("/{f}"));
|
||||
format!("{}: oracle ≥ {}{of}, round trip ≥ {}{of}", c.label, c.pass_floor.unwrap_or(0), c.roundtrip_floor.unwrap_or(0))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
for c in &inp.checklists {
|
||||
let key = match c.area.as_str() {
|
||||
"file" => corpus_key.clone(),
|
||||
"ui" => format!("settings that do nothing: {unread_n} of {total_prefs} (target 0)"),
|
||||
"automation" => inp.parity.clone().unwrap_or_default(),
|
||||
"reliability" => format!(
|
||||
"never-crash lints on {} of {roots} crates; panic_hunt {}",
|
||||
roots - missing_nc.len(),
|
||||
if inp.panic_hunt { "present" } else { "missing" }
|
||||
),
|
||||
_ => String::new(),
|
||||
};
|
||||
s.push_str(&checklist_row(c, &key));
|
||||
}
|
||||
|
||||
// Performance.
|
||||
s.push_str("\n## Performance\n\n");
|
||||
s.push_str(&format!(
|
||||
"Budgets: `perf/budgets.toml` (targets from #209, #210, #211). Numbers: the committed baseline \
|
||||
(`perf/baseline.json`, full run), updated only by `cargo xtask perf --update-baseline`. The nightly \
|
||||
`perf-nightly` workflow runs `cargo xtask perf` on a fixed macOS runner and fails on a broken enforced \
|
||||
budget or a p50 regression over {:.0} % against a baseline from the same machine class. \"Over budget\" \
|
||||
rows are targets the code doesn't meet yet: they are reported, and only their regressions fail.\n\n",
|
||||
inp.budgets.settings.regression_pct
|
||||
));
|
||||
match (&base_date, full) {
|
||||
(Some(d), Some(f)) => {
|
||||
let load = perf::fmt_load(f.get("load_avg_start"));
|
||||
let commit = f.get("commit").and_then(Value::as_str).map_or_else(|| "unknown".into(), |c| c.get(..10).unwrap_or(c).to_string());
|
||||
let ram = machine.and_then(|m| m.get("ram_bytes")).and_then(Value::as_u64).map_or_else(|| "?".into(), |b| format!("{} GB", b >> 30));
|
||||
s.push_str(&format!(
|
||||
"Baseline: {d}, commit `{commit}`, {} ({ram}), GPU {}, {}; load average {load} (1/5/15 min) at the start of the run.\n\n",
|
||||
ms("cpu"),
|
||||
ms("gpu_adapter"),
|
||||
ms("os_version")
|
||||
));
|
||||
}
|
||||
_ => s.push_str("Baseline: **not run** (no `perf/baseline.json` full run yet).\n\n"),
|
||||
}
|
||||
s.push_str(&perf::render_table(&outcomes, false));
|
||||
|
||||
// Corpora.
|
||||
for c in &inp.checklists {
|
||||
s.push_str(&format!("\n## {}\n\n", c.title));
|
||||
match c.area.as_str() {
|
||||
"file" => {
|
||||
s.push_str("Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus test fails below them when the corpus is present.\n\n| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |\n|---|---|---:|---:|---:|\n");
|
||||
for k in &inp.corpora {
|
||||
let n = |v: Option<u32>| v.map_or_else(|| "–".into(), |x| x.to_string());
|
||||
s.push_str(&format!("| {} | `{}` | {} | {} | {} |\n", k.label, k.dir, n(k.files), n(k.pass_floor), n(k.roundtrip_floor)));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
"ui" => {
|
||||
s.push_str(&format!(
|
||||
"**Settings that do nothing: {unread_n} of {total_prefs}** (target 0, #204). Counted by the prefs audit \
|
||||
(`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` \
|
||||
except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses \
|
||||
`section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. \
|
||||
It is a heuristic lower bound: a generic name can look read when it isn't.\n\n"
|
||||
));
|
||||
if !inp.prefs.1.is_empty() {
|
||||
s.push_str(&format!("Unread: {}.\n\n", inp.prefs.1.iter().map(|p| format!("`{p}`")).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
}
|
||||
"automation" => {
|
||||
if let Some(p) = &inp.parity {
|
||||
s.push_str(&format!("Menu wiring (`docs/parity.md`): {p}. This counts dispatch, not behaviour.\n\n"));
|
||||
}
|
||||
}
|
||||
"reliability" => {
|
||||
s.push_str(&format!("Never-crash attribute on {} of {roots} crate roots", roots - missing_nc.len()));
|
||||
if missing_nc.is_empty() {
|
||||
s.push_str(".\n\n");
|
||||
} else {
|
||||
s.push_str(&format!("; missing: {}.\n\n", missing_nc.iter().map(|m| format!("`{m}`")).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s.push_str(&render_checklist(c));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> {
|
||||
let check = rest.contains(&"--check");
|
||||
if let Some(bad) = rest.iter().find(|a| **a != "--check") {
|
||||
return Err(format!("scorecard: unknown option `{bad}`"));
|
||||
}
|
||||
let inp = gather(root)?;
|
||||
let text = render(&inp);
|
||||
let path = root.join(OUT);
|
||||
if check {
|
||||
let current = std::fs::read_to_string(&path).unwrap_or_default().replace("\r\n", "\n");
|
||||
if current != text {
|
||||
return Err(format!("{OUT} is stale: run `cargo xtask scorecard` and commit the result"));
|
||||
}
|
||||
println!("{OUT} is up to date");
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::write(&path, &text).map_err(|e| format!("write {OUT}: {e}"))?;
|
||||
println!("wrote {OUT}: settings that do nothing {} of {}", inp.prefs.1.len(), inp.prefs.0);
|
||||
if let Ok(t) = std::fs::read_to_string(crate::root().join("target/perf/results.json"))
|
||||
&& let Ok(v) = serde_json::from_str::<Value>(&t)
|
||||
{
|
||||
println!(
|
||||
"note: target/perf/results.json ({} run, {}) is not in the scorecard; publish it with `cargo xtask perf --update-baseline`",
|
||||
v.get("mode").and_then(Value::as_str).unwrap_or("?"),
|
||||
v.get("date").and_then(Value::as_str).unwrap_or("?")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PREFS: &str = r#"
|
||||
pub struct General {
|
||||
pub beep_when_done: bool,
|
||||
/// doc
|
||||
pub animated_zoom: bool,
|
||||
}
|
||||
|
||||
pub struct Interface {
|
||||
pub ui_scale: UiScale,
|
||||
pub grid_colors: Colors,
|
||||
pub grid_size: Size,
|
||||
}
|
||||
|
||||
impl Interface {
|
||||
pub fn colors(&self) -> [u8; 2] {
|
||||
match self.grid_colors {
|
||||
_ => [0, 1],
|
||||
}
|
||||
}
|
||||
fn size(&self) -> f32 {
|
||||
self.grid_size.px()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Interface {
|
||||
fn default() -> Self {
|
||||
Self { ui_scale: 1, grid_colors: x, grid_size: y }
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn apply_prefs(&mut self) {
|
||||
let n = self.prefs().general.beep_when_done;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Preferences {
|
||||
pub general: General,
|
||||
pub interface: Interface,
|
||||
pub shortcuts: BTreeMap<String, String>,
|
||||
pub workspace_locked: bool,
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn pref_fields_walk_sections() {
|
||||
let fields = pref_fields(PREFS);
|
||||
let f: Vec<String> = fields.iter().map(PrefField::path).collect();
|
||||
assert_eq!(
|
||||
f,
|
||||
[
|
||||
"general.beep_when_done",
|
||||
"general.animated_zoom",
|
||||
"interface.ui_scale",
|
||||
"interface.grid_colors",
|
||||
"interface.grid_size",
|
||||
"shortcuts",
|
||||
"workspace_locked"
|
||||
]
|
||||
);
|
||||
// Section helper methods: `colors()` reads grid_colors, `size()` reads grid_size.
|
||||
let helpers: Vec<Vec<String>> = fields.iter().map(|f| f.helpers.iter().cloned().collect()).collect();
|
||||
assert_eq!(helpers[3], ["colors"]);
|
||||
assert_eq!(helpers[4], ["size"]);
|
||||
assert!(helpers[2].is_empty());
|
||||
assert!(pref_fields("pub struct Other {\n pub x: u8,\n}\n").is_empty());
|
||||
// Only `impl Session` counts as engine code inside prefs.rs.
|
||||
let code = session_code(PREFS);
|
||||
assert!(code.contains("general.beep_when_done") && !code.contains("grid_colors"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_audit_rules() {
|
||||
let fields = pref_fields(PREFS);
|
||||
let files = [
|
||||
accesses(&session_code(PREFS)),
|
||||
// Reads through a section helper.
|
||||
accesses("let [a, b] = app.session.prefs().interface.colors();"),
|
||||
// Section bound first, field read later in the same file.
|
||||
accesses("let i = &prefs.interface; let s = i.ui_scale;"),
|
||||
// A top-level field read in a file that mentions prefs.
|
||||
accesses("let p = session.prefs(); p.shortcuts.get(id)"),
|
||||
// Not prefs: `.workspace_locked` in a file that never mentions prefs doesn't count.
|
||||
accesses("state.workspace_locked = true;"),
|
||||
];
|
||||
let unread: Vec<String> = fields.iter().filter(|f| !is_read(f, &files)).map(PrefField::path).collect();
|
||||
assert_eq!(unread, ["general.animated_zoom", "interface.grid_size", "workspace_locked"]);
|
||||
// A word inside an identifier or a string is not an access.
|
||||
let a = accesses("let animated_zoom = 1; \"general.animated_zoom\".len(); x.animated_zoomed");
|
||||
assert!(a.pairs.contains("general.animated_zoom"), "string paths look like accesses; documented lower bound");
|
||||
assert!(a.singles.contains("animated_zoom"), "and so do their parts");
|
||||
assert!(!a.singles.contains("general"), "`let animated_zoom` and `\"general` are not accesses");
|
||||
assert!(a.singles.contains("animated_zoomed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_items_are_stripped() {
|
||||
let src = "fn a() { p.general.x }\n#[cfg(test)]\nfn helper() {\n p.general.y\n}\n\nfn b() { p.general.z }\n#[cfg(test)]\nmod more;\n#[cfg(test)]\n#[allow(clippy::unreachable)]\nmod tests {\n fn t() { p.general.w }\n}\n";
|
||||
let s = strip_tests(src);
|
||||
assert!(s.contains("general.x") && s.contains("general.z"));
|
||||
assert!(!s.contains("general.y") && !s.contains("general.w") && !s.contains("mod more"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_floors_parse() {
|
||||
let src = r#"
|
||||
/// Hand-picked mix in `corpus/psd` (170 files: 121 vs the merged image + 12 vs the thumbnail).
|
||||
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 133, roundtrip_floor: 169 };
|
||||
|
||||
/// The full psd-tools test set (309 files at the pinned commit):
|
||||
/// 202 vs the merged image.
|
||||
const PSD_TOOLS: Source =
|
||||
Source { label: "psd-tools corpus", env: "X", default_dir: "corpus/psd-tools", pass_floor: 212, roundtrip_floor: 307, extra: 5 };
|
||||
"#;
|
||||
let c = parse_corpus(src);
|
||||
assert_eq!(c.len(), 2);
|
||||
assert_eq!(
|
||||
(c[0].label.as_str(), c[0].dir.as_str(), c[0].files, c[0].pass_floor, c[0].roundtrip_floor),
|
||||
("io corpus", "corpus/psd", Some(170), Some(133), Some(169))
|
||||
);
|
||||
assert_eq!((c[1].files, c[1].pass_floor, c[1].roundtrip_floor), (Some(309), Some(212), Some(307)));
|
||||
assert!(parse_corpus("const X: u32 = 1;").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checklists_validate() {
|
||||
let ok = "area = \"tools\"\ntitle = \"Tools\"\norder = 3\n[[item]]\nid = \"T-1\"\ntarget = \"Pencil\"\nstatus = \"partial\"\nissue = 213\n";
|
||||
let c = parse_checklist("t.toml", ok).unwrap();
|
||||
assert_eq!(c.counts(), (0, 1, 0));
|
||||
assert!(render_checklist(&c).contains("| T-1 | Pencil | partial | [#213](https://github.com/storytold/photocraft/issues/213) | |"));
|
||||
assert!(parse_checklist("t.toml", &ok.replace("partial", "almost")).is_err(), "status must be done|partial|missing");
|
||||
assert!(parse_checklist("t.toml", &ok.replace("Pencil", "a | b")).is_err());
|
||||
assert!(parse_checklist("t.toml", &format!("{ok}bogus = 1\n")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors() {
|
||||
assert_eq!(anchor("Workspace and UI"), "workspace-and-ui");
|
||||
assert_eq!(anchor("File compatibility"), "file-compatibility");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staleness_check_and_determinism() {
|
||||
let root = crate::root();
|
||||
let inp = gather(&root).unwrap_or_else(|e| panic!("{e}"));
|
||||
let a = render(&inp);
|
||||
assert_eq!(a, render(&gather(&root).unwrap()), "rendering is deterministic");
|
||||
// The committed file is current (what `--check` enforces in CI).
|
||||
let committed = std::fs::read_to_string(root.join(OUT)).unwrap_or_default().replace("\r\n", "\n");
|
||||
assert!(committed == a, "{OUT} is stale: run `cargo xtask scorecard`");
|
||||
// Every scenario and every checklist area shows up.
|
||||
for sc in &inp.budgets.scenario {
|
||||
assert!(a.contains(&format!("| {} |", sc.id)), "{} missing from the scorecard", sc.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue