aboutsummaryrefslogtreecommitdiff
path: root/crates/render-parity/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/render-parity/src
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/render-parity/src')
-rw-r--r--crates/render-parity/src/lib.rs212
-rw-r--r--crates/render-parity/src/main.rs405
2 files changed, 0 insertions, 617 deletions
diff --git a/crates/render-parity/src/lib.rs b/crates/render-parity/src/lib.rs
deleted file mode 100644
index cb412e9..0000000
--- a/crates/render-parity/src/lib.rs
+++ /dev/null
@@ -1,212 +0,0 @@
-use image::{ImageBuffer, Rgba, RgbaImage};
-use serde::Deserialize;
-
-#[derive(Debug, Clone, Deserialize, Default)]
-pub struct ManifestMeta {
- pub width: Option<u32>,
- pub height: Option<u32>,
- pub lod: Option<usize>,
- pub group: Option<usize>,
- pub angle: Option<f32>,
- pub diff_threshold: Option<u8>,
- pub max_mean_abs: Option<f32>,
- pub max_changed_ratio: Option<f32>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct CaseSpec {
- pub id: String,
- pub archive: String,
- pub model: Option<String>,
- pub reference: String,
- pub width: Option<u32>,
- pub height: Option<u32>,
- pub lod: Option<usize>,
- pub group: Option<usize>,
- pub angle: Option<f32>,
- pub diff_threshold: Option<u8>,
- pub max_mean_abs: Option<f32>,
- pub max_changed_ratio: Option<f32>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct ParityManifest {
- #[serde(default)]
- pub meta: ManifestMeta,
- #[serde(rename = "case", default)]
- pub cases: Vec<CaseSpec>,
-}
-
-#[derive(Debug, Clone)]
-pub struct DiffMetrics {
- pub width: u32,
- pub height: u32,
- pub mean_abs: f32,
- pub max_abs: u8,
- pub changed_pixels: u64,
- pub changed_ratio: f32,
-}
-
-pub fn compare_images(
- reference: &RgbaImage,
- actual: &RgbaImage,
- diff_threshold: u8,
-) -> Result<DiffMetrics, String> {
- let (rw, rh) = reference.dimensions();
- let (aw, ah) = actual.dimensions();
- if rw != aw || rh != ah {
- return Err(format!(
- "image size mismatch: reference={}x{}, actual={}x{}",
- rw, rh, aw, ah
- ));
- }
-
- let mut diff_sum = 0u64;
- let mut max_abs = 0u8;
- let mut changed_pixels = 0u64;
- let pixel_count = u64::from(rw).saturating_mul(u64::from(rh));
-
- for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) {
- let mut pixel_changed = false;
- for chan in 0..3 {
- let a = i16::from(ref_px[chan]);
- let b = i16::from(act_px[chan]);
- let diff = (a - b).unsigned_abs() as u8;
- diff_sum = diff_sum.saturating_add(u64::from(diff));
- if diff > max_abs {
- max_abs = diff;
- }
- if diff > diff_threshold {
- pixel_changed = true;
- }
- }
- if pixel_changed {
- changed_pixels = changed_pixels.saturating_add(1);
- }
- }
-
- let channels = pixel_count.saturating_mul(3);
- let mean_abs = if channels == 0 {
- 0.0
- } else {
- diff_sum as f32 / channels as f32
- };
- let changed_ratio = if pixel_count == 0 {
- 0.0
- } else {
- changed_pixels as f32 / pixel_count as f32
- };
-
- Ok(DiffMetrics {
- width: rw,
- height: rh,
- mean_abs,
- max_abs,
- changed_pixels,
- changed_ratio,
- })
-}
-
-pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> {
- let (rw, rh) = reference.dimensions();
- let (aw, ah) = actual.dimensions();
- if rw != aw || rh != ah {
- return Err(format!(
- "image size mismatch: reference={}x{}, actual={}x{}",
- rw, rh, aw, ah
- ));
- }
-
- let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh);
- for (dst, (ref_px, act_px)) in out
- .pixels_mut()
- .zip(reference.pixels().zip(actual.pixels()))
- {
- let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8;
- let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8;
- let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8;
- *dst = Rgba([dr, dg, db, 255]);
- }
- Ok(out)
-}
-
-pub fn evaluate_metrics(
- metrics: &DiffMetrics,
- max_mean_abs: f32,
- max_changed_ratio: f32,
-) -> Vec<String> {
- let mut violations = Vec::new();
- if metrics.mean_abs > max_mean_abs {
- violations.push(format!(
- "mean_abs {:.4} > allowed {:.4}",
- metrics.mean_abs, max_mean_abs
- ));
- }
- if metrics.changed_ratio > max_changed_ratio {
- violations.push(format!(
- "changed_ratio {:.4}% > allowed {:.4}%",
- metrics.changed_ratio * 100.0,
- max_changed_ratio * 100.0
- ));
- }
- violations
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage {
- let mut img = RgbaImage::new(w, h);
- for px in img.pixels_mut() {
- *px = Rgba([r, g, b, 255]);
- }
- img
- }
-
- #[test]
- fn compare_identical_images() {
- let ref_img = solid(4, 3, 10, 20, 30);
- let act_img = solid(4, 3, 10, 20, 30);
- let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed");
- assert_eq!(metrics.width, 4);
- assert_eq!(metrics.height, 3);
- assert_eq!(metrics.max_abs, 0);
- assert_eq!(metrics.changed_pixels, 0);
- assert_eq!(metrics.mean_abs, 0.0);
- assert_eq!(metrics.changed_ratio, 0.0);
- }
-
- #[test]
- fn compare_detects_changes_and_thresholds() {
- let mut ref_img = solid(2, 2, 100, 100, 100);
- let mut act_img = solid(2, 2, 100, 100, 100);
- ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255]));
- act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255]));
-
- let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed");
- assert_eq!(metrics.max_abs, 20);
- assert_eq!(metrics.changed_pixels, 1);
- assert!((metrics.changed_ratio - 0.25).abs() < 1e-6);
- assert!(metrics.mean_abs > 0.0);
-
- let violations = evaluate_metrics(&metrics, 2.0, 0.20);
- assert_eq!(violations.len(), 1);
- assert!(violations[0].contains("changed_ratio"));
- }
-
- #[test]
- fn build_diff_image_returns_per_channel_abs_diff() {
- let mut ref_img = solid(1, 1, 100, 150, 200);
- let mut act_img = solid(1, 1, 90, 180, 170);
- ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255]));
- act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255]));
-
- let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build");
- let px = diff.get_pixel(0, 0);
- assert_eq!(px[0], 10);
- assert_eq!(px[1], 30);
- assert_eq!(px[2], 30);
- assert_eq!(px[3], 255);
- }
-}
diff --git a/crates/render-parity/src/main.rs b/crates/render-parity/src/main.rs
deleted file mode 100644
index 22795bc..0000000
--- a/crates/render-parity/src/main.rs
+++ /dev/null
@@ -1,405 +0,0 @@
-use image::RgbaImage;
-use render_parity::{
- build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest,
-};
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::process::Command;
-
-const DEFAULT_MANIFEST: &str = "parity/cases.toml";
-const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current";
-const DEFAULT_WIDTH: u32 = 1280;
-const DEFAULT_HEIGHT: u32 = 720;
-const DEFAULT_LOD: usize = 0;
-const DEFAULT_GROUP: usize = 0;
-const DEFAULT_ANGLE: f32 = 0.0;
-const DEFAULT_DIFF_THRESHOLD: u8 = 8;
-const DEFAULT_MAX_MEAN_ABS: f32 = 2.0;
-const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01;
-
-struct Args {
- manifest: PathBuf,
- output_dir: PathBuf,
- demo_bin: Option<PathBuf>,
- keep_going: bool,
-}
-
-#[derive(Debug, Clone)]
-struct EffectiveCase {
- id: String,
- archive: PathBuf,
- model: Option<String>,
- reference: PathBuf,
- width: u32,
- height: u32,
- lod: usize,
- group: usize,
- angle: f32,
- diff_threshold: u8,
- max_mean_abs: f32,
- max_changed_ratio: f32,
-}
-
-fn main() {
- let args = match parse_args() {
- Ok(v) => v,
- Err(err) => {
- eprintln!("{err}");
- print_help();
- std::process::exit(2);
- }
- };
-
- if let Err(err) = run(args) {
- eprintln!("{err}");
- std::process::exit(1);
- }
-}
-
-fn parse_args() -> Result<Args, String> {
- let mut manifest = PathBuf::from(DEFAULT_MANIFEST);
- let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR);
- let mut demo_bin = None;
- let mut keep_going = false;
-
- let mut it = std::env::args().skip(1);
- while let Some(arg) = it.next() {
- match arg.as_str() {
- "--manifest" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --manifest"))?;
- manifest = PathBuf::from(value);
- }
- "--output-dir" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --output-dir"))?;
- output_dir = PathBuf::from(value);
- }
- "--demo-bin" => {
- let value = it
- .next()
- .ok_or_else(|| String::from("missing value for --demo-bin"))?;
- demo_bin = Some(PathBuf::from(value));
- }
- "--keep-going" => {
- keep_going = true;
- }
- "--help" | "-h" => {
- print_help();
- std::process::exit(0);
- }
- other => {
- return Err(format!("unknown argument: {other}"));
- }
- }
- }
-
- Ok(Args {
- manifest,
- output_dir,
- demo_bin,
- keep_going,
- })
-}
-
-fn print_help() {
- eprintln!(
- "render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]"
- );
- eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})");
- eprintln!(" --output-dir where current renders and diff images are written");
- eprintln!(" --demo-bin prebuilt parkan-render-demo binary path");
- eprintln!(" --keep-going continue all cases even after failures");
-}
-
-fn run(args: Args) -> Result<(), String> {
- let workspace = workspace_root()?;
- let manifest_path = resolve_path(&workspace, &args.manifest);
- let output_dir = resolve_path(&workspace, &args.output_dir);
- let demo_bin = args
- .demo_bin
- .as_ref()
- .map(|path| resolve_path(&workspace, path));
-
- let manifest_raw = fs::read_to_string(&manifest_path)
- .map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?;
- let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| {
- format!(
- "failed to parse manifest {}: {err}",
- manifest_path.display()
- )
- })?;
-
- if manifest.cases.is_empty() {
- println!(
- "render-parity: no cases in {} (nothing to validate)",
- manifest_path.display()
- );
- return Ok(());
- }
-
- fs::create_dir_all(&output_dir).map_err(|err| {
- format!(
- "failed to create output directory {}: {err}",
- output_dir.display()
- )
- })?;
-
- let manifest_dir = manifest_path
- .parent()
- .map(Path::to_path_buf)
- .unwrap_or_else(|| workspace.clone());
-
- let mut failed_cases = 0usize;
- for case in &manifest.cases {
- let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?;
- let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id)));
- let diff_file = output_dir
- .join("diff")
- .join(format!("{}.png", sanitize_case_id(&effective.id)));
-
- let run_res = run_single_case(
- &workspace, // ensure `cargo run` executes from workspace root
- demo_bin.as_deref(),
- &effective,
- &case_file,
- &diff_file,
- );
-
- match run_res {
- Ok(()) => {}
- Err(err) => {
- failed_cases = failed_cases.saturating_add(1);
- eprintln!("[FAIL] {}: {}", effective.id, err);
- if !args.keep_going {
- break;
- }
- }
- }
- }
-
- if failed_cases > 0 {
- return Err(format!(
- "render-parity failed: {} case(s) did not match reference frames",
- failed_cases
- ));
- }
-
- println!("render-parity: all cases passed");
- Ok(())
-}
-
-fn run_single_case(
- workspace: &Path,
- demo_bin: Option<&Path>,
- case: &EffectiveCase,
- case_file: &Path,
- diff_file: &Path,
-) -> Result<(), String> {
- run_render_capture(workspace, demo_bin, case, case_file)?;
-
- let reference = load_rgba(&case.reference)?;
- let actual = load_rgba(case_file)?;
- let metrics = compare_images(&reference, &actual, case.diff_threshold)?;
- let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio);
-
- if violations.is_empty() {
- println!(
- "[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})",
- case.id,
- metrics.mean_abs,
- metrics.changed_ratio * 100.0,
- metrics.max_abs,
- metrics.width,
- metrics.height
- );
- return Ok(());
- }
-
- if let Some(parent) = diff_file.parent() {
- fs::create_dir_all(parent).map_err(|err| {
- format!(
- "failed to create diff output directory {}: {err}",
- parent.display()
- )
- })?;
- }
- let diff = build_diff_image(&reference, &actual)?;
- diff.save(diff_file)
- .map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?;
-
- let mut details = String::new();
- for item in violations {
- if !details.is_empty() {
- details.push_str("; ");
- }
- details.push_str(&item);
- }
- Err(format!(
- "{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}",
- details,
- diff_file.display(),
- metrics.mean_abs,
- metrics.changed_ratio * 100.0,
- metrics.changed_pixels,
- metrics.max_abs
- ))
-}
-
-fn run_render_capture(
- workspace: &Path,
- demo_bin: Option<&Path>,
- case: &EffectiveCase,
- out_path: &Path,
-) -> Result<(), String> {
- if let Some(parent) = out_path.parent() {
- fs::create_dir_all(parent).map_err(|err| {
- format!(
- "failed to create capture directory {}: {err}",
- parent.display()
- )
- })?;
- }
-
- let mut cmd = if let Some(bin) = demo_bin {
- Command::new(bin)
- } else {
- let mut command = Command::new("cargo");
- command.args(["run", "-p", "render-demo", "--features", "demo", "--"]);
- command
- };
-
- cmd.current_dir(workspace)
- .arg("--archive")
- .arg(&case.archive)
- .arg("--lod")
- .arg(case.lod.to_string())
- .arg("--group")
- .arg(case.group.to_string())
- .arg("--width")
- .arg(case.width.to_string())
- .arg("--height")
- .arg(case.height.to_string())
- .arg("--angle")
- .arg(case.angle.to_string())
- .arg("--capture")
- .arg(out_path);
-
- if let Some(model) = case.model.as_deref() {
- cmd.arg("--model").arg(model);
- }
-
- let output = cmd.output().map_err(|err| {
- let mode = if demo_bin.is_some() {
- "parkan-render-demo"
- } else {
- "cargo run -p render-demo"
- };
- format!("failed to execute {} for case {}: {err}", mode, case.id)
- })?;
- if !output.status.success() {
- let stdout = String::from_utf8_lossy(&output.stdout);
- let stderr = String::from_utf8_lossy(&output.stderr);
- return Err(format!(
- "render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
- output.status.code(),
- stdout,
- stderr
- ));
- }
-
- Ok(())
-}
-
-fn load_rgba(path: &Path) -> Result<RgbaImage, String> {
- image::open(path)
- .map_err(|err| format!("failed to load image {}: {err}", path.display()))
- .map(|img| img.to_rgba8())
-}
-
-fn make_effective_case(
- meta: &ManifestMeta,
- case: &CaseSpec,
- manifest_dir: &Path,
-) -> Result<EffectiveCase, String> {
- let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH);
- let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT);
- if width == 0 || height == 0 {
- return Err(format!(
- "case '{}' has invalid dimensions {}x{}",
- case.id, width, height
- ));
- }
-
- let archive = resolve_path(manifest_dir, Path::new(&case.archive));
- let reference = resolve_path(manifest_dir, Path::new(&case.reference));
- if !archive.is_file() {
- return Err(format!(
- "case '{}' archive not found: {}",
- case.id,
- archive.display()
- ));
- }
- if !reference.is_file() {
- return Err(format!(
- "case '{}' reference frame not found: {}",
- case.id,
- reference.display()
- ));
- }
-
- Ok(EffectiveCase {
- id: case.id.clone(),
- archive,
- model: case.model.clone(),
- reference,
- width,
- height,
- lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD),
- group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP),
- angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE),
- diff_threshold: case
- .diff_threshold
- .or(meta.diff_threshold)
- .unwrap_or(DEFAULT_DIFF_THRESHOLD),
- max_mean_abs: case
- .max_mean_abs
- .or(meta.max_mean_abs)
- .unwrap_or(DEFAULT_MAX_MEAN_ABS),
- max_changed_ratio: case
- .max_changed_ratio
- .or(meta.max_changed_ratio)
- .unwrap_or(DEFAULT_MAX_CHANGED_RATIO),
- })
-}
-
-fn sanitize_case_id(id: &str) -> String {
- id.chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
- c
- } else {
- '_'
- }
- })
- .collect()
-}
-
-fn workspace_root() -> Result<PathBuf, String> {
- let root = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("..")
- .join("..")
- .canonicalize()
- .map_err(|err| format!("failed to resolve workspace root: {err}"))?;
- Ok(root)
-}
-
-fn resolve_path(base: &Path, path: &Path) -> PathBuf {
- if path.is_absolute() {
- path.to_path_buf()
- } else {
- base.join(path)
- }
-}