mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-31 10:17:21 +00:00
Add path-bool library (#1952)
* Add path-bool library * Cleanup code * Cargo format * Integrate boolean ops into graphite * Add test for editor crash * Fix edge sort floating point instability * Add unit test for red-dress failure * Backport tests and aux functions * Use curvature based sorting * Convert linear cubic splines to line segments * Deduplicate reversed path segments * Fix epsilon for empty segments * Remove parameter based intersection pruning * Add support for reversed paths * Add benchmark infrastructure * Add intersection benchmark * Add recursion bound * Implement support for overlapping path segments * Remove rouge prinln * Fix sorting for bezier segments with one control point at the start of the segment * Cleanup log statements * Directly translate graphite paths to Path segments * Round data before passing it to path_bool * Fix flag_faces traversal order * Add test for white dots in bottom right of painted dreams * Make rounding configurable * Update demo artwork to remove manual path modifications * Convert from path segments to manipulator groups directly * Remove dead code * Fix clippy lints * Replace functions in path segment with methods and add documentation * Add more documentation * Close subpaths * Reorganize files and add README.md * Add license information * Code review * Fix license info * Adopt new node macro and fix demo artwork * Close subpaths with Z --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
parent
2febbfd698
commit
3eb98c6d6d
165 changed files with 5990 additions and 78 deletions
183
libraries/path-bool/src/visual_tests.rs
Normal file
183
libraries/path-bool/src/visual_tests.rs
Normal file
|
@ -0,0 +1,183 @@
|
|||
use core::panic;
|
||||
use glob::glob;
|
||||
use image::{DynamicImage, GenericImageView, RgbaImage};
|
||||
use resvg::render;
|
||||
use resvg::tiny_skia::Transform;
|
||||
use resvg::usvg::{Options, Tree};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use svg::parser::Event;
|
||||
|
||||
use crate::path_boolean::{self, FillRule, PathBooleanOperation};
|
||||
use crate::path_data::{path_from_path_data, path_to_path_data};
|
||||
|
||||
const TOLERANCE: u8 = 84;
|
||||
|
||||
fn get_fill_rule(fill_rule: &str) -> FillRule {
|
||||
match fill_rule {
|
||||
"evenodd" => FillRule::EvenOdd,
|
||||
_ => FillRule::NonZero,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_tests() {
|
||||
let ops = [
|
||||
("union", PathBooleanOperation::Union),
|
||||
("difference", PathBooleanOperation::Difference),
|
||||
("intersection", PathBooleanOperation::Intersection),
|
||||
("exclusion", PathBooleanOperation::Exclusion),
|
||||
("division", PathBooleanOperation::Division),
|
||||
("fracture", PathBooleanOperation::Fracture),
|
||||
];
|
||||
|
||||
let folders: Vec<(String, PathBuf, &str, PathBooleanOperation)> = glob("__fixtures__/visual-tests/*/")
|
||||
.expect("Failed to read glob pattern")
|
||||
.flat_map(|entry| {
|
||||
let dir = entry.expect("Failed to get directory entry");
|
||||
ops.iter()
|
||||
.map(move |(op_name, op)| (dir.file_name().unwrap().to_string_lossy().into_owned(), dir.clone(), *op_name, *op))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut failure = false;
|
||||
|
||||
for (name, dir, op_name, op) in folders {
|
||||
let test_name = format!("{} {}", name, op_name);
|
||||
println!("Running test: {}", test_name);
|
||||
|
||||
fs::create_dir_all(dir.join("test-results")).expect("Failed to create test-results directory");
|
||||
|
||||
let original_path = dir.join("original.svg");
|
||||
|
||||
let mut content = String::new();
|
||||
let svg_tree = svg::open(&original_path, &mut content).expect("Failed to parse SVG");
|
||||
|
||||
let mut paths = Vec::new();
|
||||
let mut first_path_attributes = String::new();
|
||||
let mut width = String::new();
|
||||
let mut height = String::new();
|
||||
let mut view_box = String::new();
|
||||
for event in svg_tree {
|
||||
match event {
|
||||
Event::Tag("svg", svg::node::element::tag::Type::Start, attributes) => {
|
||||
width = attributes.get("width").map(|s| s.to_string()).unwrap_or_default();
|
||||
height = attributes.get("height").map(|s| s.to_string()).unwrap_or_default();
|
||||
view_box = attributes.get("viewBox").map(|s| s.to_string()).unwrap_or_default();
|
||||
}
|
||||
Event::Tag("path", svg::node::element::tag::Type::Empty, attributes) => {
|
||||
let data = attributes.get("d").map(|s| s.to_string()).expect("Path data not found");
|
||||
let fill_rule = attributes.get("fill-rule").map(|v| v.to_string()).unwrap_or_else(|| "nonzero".to_string());
|
||||
paths.push((data, fill_rule));
|
||||
|
||||
// Store attributes of the first path
|
||||
if first_path_attributes.is_empty() {
|
||||
for (key, value) in attributes.iter() {
|
||||
if key != "d" && key != "id" {
|
||||
first_path_attributes.push_str(&format!("{}=\"{}\" ", key, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if (width.is_empty() || height.is_empty()) && !view_box.is_empty() {
|
||||
let vb: Vec<&str> = view_box.split_whitespace().collect();
|
||||
if vb.len() == 4 {
|
||||
width = vb[2].to_string();
|
||||
height = vb[3].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if width.is_empty() || height.is_empty() {
|
||||
panic!("Failed to extract width and height from SVG");
|
||||
}
|
||||
|
||||
let a_node = paths[0].clone();
|
||||
let b_node = paths[1].clone();
|
||||
|
||||
let a = path_from_path_data(&a_node.0);
|
||||
let b = path_from_path_data(&b_node.0);
|
||||
|
||||
let a_fill_rule = get_fill_rule(&a_node.1);
|
||||
let b_fill_rule = get_fill_rule(&b_node.1);
|
||||
|
||||
let result = path_boolean::path_boolean(&a, a_fill_rule, &b, b_fill_rule, op).unwrap();
|
||||
|
||||
// Create the result SVG with correct dimensions
|
||||
let mut result_svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"{}\">", width, height, view_box);
|
||||
for path in &result {
|
||||
result_svg.push_str(&format!("<path d=\"{}\" {}/>", path_to_path_data(path, 1e-4), first_path_attributes));
|
||||
}
|
||||
result_svg.push_str("</svg>");
|
||||
|
||||
// Save the result SVG
|
||||
let destination_path = dir.join("test-results").join(format!("{}-ours.svg", op_name));
|
||||
fs::write(&destination_path, &result_svg).expect("Failed to write result SVG");
|
||||
|
||||
// Render and compare images
|
||||
let ground_truth_path = dir.join(format!("{}.svg", op_name));
|
||||
let ground_truth_svg = fs::read_to_string(&ground_truth_path).expect("Failed to read ground truth SVG");
|
||||
|
||||
let ours_image = render_svg(&result_svg);
|
||||
let ground_truth_image = render_svg(&ground_truth_svg);
|
||||
|
||||
let ours_png_path = dir.join("test-results").join(format!("{}-ours.png", op_name));
|
||||
ours_image.save(&ours_png_path).expect("Failed to save our PNG");
|
||||
|
||||
let ground_truth_png_path = dir.join("test-results").join(format!("{}.png", op_name));
|
||||
ground_truth_image.save(&ground_truth_png_path).expect("Failed to save ground truth PNG");
|
||||
|
||||
failure |= compare_images(&ours_image, &ground_truth_image, TOLERANCE);
|
||||
|
||||
// Check the number of paths
|
||||
let result_path_count = result.len();
|
||||
let ground_truth_path_count = ground_truth_svg.matches("<path").count();
|
||||
if result_path_count != ground_truth_path_count {
|
||||
failure = true;
|
||||
eprintln!("Number of paths doesn't match for test: {}", test_name);
|
||||
}
|
||||
}
|
||||
if failure {
|
||||
panic!("Some tests have failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn render_svg(svg_code: &str) -> DynamicImage {
|
||||
let opts = Options::default();
|
||||
let tree = Tree::from_str(svg_code, &opts).unwrap();
|
||||
let pixmap_size = tree.size();
|
||||
let (width, height) = (pixmap_size.width() as u32, pixmap_size.height() as u32);
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height).unwrap();
|
||||
let mut pixmap_mut = pixmap.as_mut();
|
||||
render(&tree, Transform::default(), &mut pixmap_mut);
|
||||
DynamicImage::ImageRgba8(RgbaImage::from_raw(width, height, pixmap.data().to_vec()).unwrap())
|
||||
}
|
||||
|
||||
fn compare_images(img1: &DynamicImage, img2: &DynamicImage, tolerance: u8) -> bool {
|
||||
assert_eq!(img1.dimensions(), img2.dimensions(), "Image dimensions do not match");
|
||||
|
||||
for (x, y, pixel1) in img1.pixels() {
|
||||
let pixel2 = img2.get_pixel(x, y);
|
||||
for i in 0..4 {
|
||||
let difference = (pixel1[i] as i32 - pixel2[i] as i32).unsigned_abs() as u8;
|
||||
if difference > tolerance {
|
||||
println!("Difference {} larger than tolerance {} at [{}, {}], channel {}.", difference, tolerance, x, y, i);
|
||||
return true;
|
||||
}
|
||||
|
||||
assert!(
|
||||
difference <= tolerance,
|
||||
"Difference {} larger than tolerance {} at [{}, {}], channel {}.",
|
||||
difference,
|
||||
tolerance,
|
||||
x,
|
||||
y,
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue