From 9ba85075d429ff793060920ecb8ce9bb632903e6 Mon Sep 17 00:00:00 2001 From: David Himmelstrup Date: Sat, 21 Jan 2023 19:20:56 +0100 Subject: [PATCH] example: SVG viewer based on `usvg` (#260) --- Cargo.toml | 2 +- examples/usvg_viewer/Cargo.toml | 22 +++ examples/usvg_viewer/README.md | 41 ++++++ examples/usvg_viewer/src/asset.rs | 104 ++++++++++++++ examples/usvg_viewer/src/main.rs | 215 +++++++++++++++++++++++++++++ examples/usvg_viewer/src/render.rs | 108 +++++++++++++++ 6 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 examples/usvg_viewer/Cargo.toml create mode 100644 examples/usvg_viewer/README.md create mode 100644 examples/usvg_viewer/src/asset.rs create mode 100644 examples/usvg_viewer/src/main.rs create mode 100644 examples/usvg_viewer/src/render.rs diff --git a/Cargo.toml b/Cargo.toml index b96032c..b8ba15b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["examples/with_winit", "examples/with_bevy", "examples/run_wasm"] +members = ["examples/with_winit", "examples/with_bevy", "examples/run_wasm", "examples/usvg_viewer"] [workspace.package] edition = "2021" diff --git a/examples/usvg_viewer/Cargo.toml b/examples/usvg_viewer/Cargo.toml new file mode 100644 index 0000000..768857c --- /dev/null +++ b/examples/usvg_viewer/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "usvg_viewer" +version = "0.1.0" +license = "MIT/Apache-2.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +byte-unit = "4.0" +clap = { version = "4.1.0", features = ["derive"] } +dialoguer = "0.10" +generic-array = "0.14" +hex-literal = "0.3" +pollster = "0.2.5" +sha2 = "0.10" +ureq = "2.6" +usvg = "0.28" +vello = { path = "../../" } +wgpu = "0.14" +winit = "0.27.5" diff --git a/examples/usvg_viewer/README.md b/examples/usvg_viewer/README.md new file mode 100644 index 0000000..60a8643 --- /dev/null +++ b/examples/usvg_viewer/README.md @@ -0,0 +1,41 @@ +# Vello SVG viewer + +This example program parses SVG files with [usvg](https://crates.io/crates/usvg) and renders them with Vello. + +The rendering is extremely simplistic and does not yet support: + +- group opacity +- mix-blend-modes +- clipping +- masking +- filter effects +- group background +- path visibility +- path paint order +- path shape-rendering +- embedded images +- text +- gradients +- patterns + +## Usage + +Running the viewer without any arguments will render a built-in set of public-domain SVG images: + +```bash +$ cargo run -p usvg_viewer --release +``` + +Optionally, you can pass in paths to SVG files that you want to render: + +```bash +$ cargo run -p usvg_viewer --release -- [SVG FILES] +``` + +## Controls + +- Mouse drag-and-drop will translate the image. +- Mouse scroll wheel will zoom. +- Arrow keys switch between SVG images in the current set. +- Space resets the position and zoom of the image. +- Escape exits the program. diff --git a/examples/usvg_viewer/src/asset.rs b/examples/usvg_viewer/src/asset.rs new file mode 100644 index 0000000..f6d68e0 --- /dev/null +++ b/examples/usvg_viewer/src/asset.rs @@ -0,0 +1,104 @@ +use anyhow::{bail, Result}; +use generic_array::GenericArray; +use hex_literal::hex; +use sha2::{Digest, Sha256}; +use std::env::temp_dir; +use std::io::Read; +use std::path::PathBuf; + +pub struct SvgAsset { + _source: &'static str, + url: &'static str, + sha256sum: [u8; 32], + pub license: &'static str, + pub size: u128, +} + +impl SvgAsset { + pub fn local_path(&self) -> PathBuf { + let arr = GenericArray::from(self.sha256sum); + temp_dir() + .join(format!("vello-asset-{:x}", arr)) + .with_extension("svg") + } + + pub fn fetched(&self) -> bool { + let resource_local_path = self.local_path(); + if let Ok(contents) = std::fs::read_to_string(&resource_local_path) { + if Sha256::digest(contents)[..] == self.sha256sum { + return true; + } + } + false + } + + pub fn fetch(&self) -> Result<()> { + // ureq::into_string() has a limit of 10MiB so let's use the reader directly: + let mut buf: Vec = Vec::with_capacity(self.size as usize); + ureq::get(self.url) + .call()? + .into_reader() + .take(self.size as u64) + .read_to_end(&mut buf)?; + let body: String = String::from_utf8_lossy(&buf).to_string(); + + if Sha256::digest(&body)[..] != self.sha256sum { + bail!(format!("Invalid sha256 hash for resource: {}", self.url)) + } + + std::fs::write(self.local_path(), &body)?; + Ok(()) + } +} + +pub const ASSETS: &[SvgAsset] = &[ + // DANGER: Zooming in on this image crashes my computer. @lemmih 2023-01-20. + // SvgAsset { + // _source: "https://commons.wikimedia.org/wiki/File:American_Legion_Seal_SVG.svg", + // url: "https://upload.wikimedia.org/wikipedia/commons/c/cf/American_Legion_Seal_SVG.svg", + // sha256sum: hex!("b990f047a274b463a75433ddb9c917e90067615bba5ad8373a3f77753c6bb5e1"), + // license: "Public Domain", + // size: 10849279, + // }, + + SvgAsset { + _source: "https://commons.wikimedia.org/wiki/File:CIA_WorldFactBook-Political_world.svg", + url: "https://upload.wikimedia.org/wikipedia/commons/7/72/Political_Map_of_the_World_%28august_2013%29.svg", + sha256sum: hex!("57956f10ed0ad3b1bea1e6c74cc7b386e42c99d87720a87c323d07f18c15d349"), + license: "Public Domain", + size: 12771150, + }, + + SvgAsset { + _source: "https://commons.wikimedia.org/wiki/File:World_-_time_zones_map_(2014).svg", + url: "https://upload.wikimedia.org/wikipedia/commons/c/c6/World_-_time_zones_map_%282014%29.svg", + sha256sum: hex!("0cfecd5cdeadc51eb06f60c75207a769feb5b63abe20e4cd6c0d9fea30e07563"), + license: "Public Domain", + size: 5235172, + }, + + SvgAsset { + _source: "https://commons.wikimedia.org/wiki/File:Coat_of_arms_of_Poland-official.svg", + url: "https://upload.wikimedia.org/wikipedia/commons/3/3e/Coat_of_arms_of_Poland-official.svg", + sha256sum: hex!("59b4d0e29adcd7ec6a7ab50af5796f1d13afc0334a6d4bd4d4099a345b0e3066"), + license: "Public Domain", + size: 10747708, + }, + + SvgAsset { + _source: "https://commons.wikimedia.org/wiki/File:Coat_of_arms_of_the_Kingdom_of_Yugoslavia.svg", + url: "https://upload.wikimedia.org/wikipedia/commons/5/58/Coat_of_arms_of_the_Kingdom_of_Yugoslavia.svg", + sha256sum: hex!("2b1084dee535985eb241b14c9a5260129efe4c415c66dafa548b81117842d3e3"), + license: "Public Domain", + size: 12795806, + }, + + // This SVG renders poorly + // SvgAsset { + // _source: "https://commons.wikimedia.org/wiki/File:Map_of_the_World_Oceans_-_January_2015.svg", + // url: "https://upload.wikimedia.org/wikipedia/commons/d/db/Map_of_the_World_Oceans_-_January_2015.svg", + // sha256sum: hex!("c8b0b13a577092bafa38b48b2fed28a1a26a91d237f4808444fa4bfee423c330"), + // license: "Public Domain", + // size: 10804504, + // }, +]; diff --git a/examples/usvg_viewer/src/main.rs b/examples/usvg_viewer/src/main.rs new file mode 100644 index 0000000..73a48f3 --- /dev/null +++ b/examples/usvg_viewer/src/main.rs @@ -0,0 +1,215 @@ +mod asset; +mod render; + +use anyhow::Result; +use asset::ASSETS; +use byte_unit::Byte; +use clap::Parser; +use dialoguer::Confirm; +use render::render_svg; +use std::path::PathBuf; +use std::time::Instant; +use vello::{ + kurbo::{Affine, Vec2}, + util::RenderContext, + Renderer, Scene, SceneBuilder, +}; +use winit::{ + dpi::LogicalSize, + event_loop::EventLoop, + window::{Window, WindowBuilder}, +}; + +#[derive(Parser, Debug)] +#[command(about, long_about = None)] +struct Args { + /// Input files for rendering. Will use builtin SVGs if empty. + files: Vec, +} + +// Check if all the known assets have been downloaded. +// If some haven't been downloaded (or if the checksums don't match), find +// their combined size and licenses. Ask the user if they want to download +// the SVG files. +// If yes, download the files and return normally. +// If no, exit with status code -1 +fn fetch_missing_assets() -> Result<()> { + let missing_assets = ASSETS + .iter() + .filter(|asset| !asset.fetched()) + .collect::>(); + + if !missing_assets.is_empty() { + let total_size = Byte::from_bytes(missing_assets.iter().map(|asset| asset.size).sum()) + .get_appropriate_unit(true); + let mut licenses: Vec<_> = missing_assets.iter().map(|asset| asset.license).collect(); + licenses.sort(); + licenses.dedup(); + + println!("Some SVG assets are missing. Let me download them for you."); + println!( + "They'll take up {total_size} and are available under these licenses: {}", + licenses.join(", ") + ); + + if Confirm::new() + .with_prompt("Do you want to continue?") + .interact()? + { + println!("Looks like you want to continue."); + for missing in missing_assets { + missing.fetch()? + } + } else { + println!("Nevermind then."); + std::process::exit(1) + } + } + Ok(()) +} + +async fn run(event_loop: EventLoop<()>, window: Window, svg_files: Vec) { + use winit::{event::*, event_loop::ControlFlow}; + let mut render_cx = RenderContext::new().unwrap(); + let size = window.inner_size(); + let mut surface = render_cx + .create_surface(&window, size.width, size.height) + .await; + let device_handle = &render_cx.devices[surface.dev_id]; + let mut renderer = Renderer::new(&device_handle.device).unwrap(); + let mut current_frame = 0usize; + let mut scene = Scene::new(); + let mut cached_svg_scene = vec![]; + cached_svg_scene.resize_with(svg_files.len(), || None); + let mut transform = Affine::IDENTITY; + let mut mouse_down = false; + let mut prior_position = Vec2::default(); + let mut last_title_update = Instant::now(); + // We allow looping left and right through the svgs, so use a signed index + let mut svg_ix: i32 = 0; + // These are set after choosing the svg, as they overwrite the defaults specified there + event_loop.run(move |event, _, control_flow| match event { + Event::WindowEvent { + ref event, + window_id, + } if window_id == window.id() => match event { + WindowEvent::CloseRequested => *control_flow = ControlFlow::Exit, + WindowEvent::KeyboardInput { input, .. } => { + if input.state == ElementState::Pressed { + match input.virtual_keycode { + Some(VirtualKeyCode::Left) => { + svg_ix = svg_ix.saturating_sub(1); + transform = Affine::IDENTITY + } + Some(VirtualKeyCode::Right) => { + svg_ix = svg_ix.saturating_add(1); + transform = Affine::IDENTITY + } + Some(VirtualKeyCode::Space) => transform = Affine::IDENTITY, + Some(VirtualKeyCode::Escape) => { + *control_flow = ControlFlow::Exit; + } + _ => {} + } + } + } + WindowEvent::Resized(size) => { + render_cx.resize_surface(&mut surface, size.width, size.height); + window.request_redraw(); + } + WindowEvent::MouseInput { state, button, .. } => { + if button == &MouseButton::Left { + mouse_down = state == &ElementState::Pressed; + } + } + WindowEvent::MouseWheel { delta, .. } => { + const BASE: f64 = 1.05; + const PIXELS_PER_LINE: f64 = 20.0; + let exponent = if let MouseScrollDelta::PixelDelta(delta) = delta { + delta.y / PIXELS_PER_LINE + } else if let MouseScrollDelta::LineDelta(_, y) = delta { + *y as f64 + } else { + 0.0 + }; + transform = Affine::translate(prior_position) + * Affine::scale(BASE.powf(exponent)) + * Affine::translate(-prior_position) + * transform; + } + WindowEvent::CursorMoved { position, .. } => { + let position = Vec2::new(position.x, position.y); + if mouse_down { + transform = Affine::translate(position - prior_position) * transform; + } + prior_position = position; + } + _ => {} + }, + Event::MainEventsCleared => { + window.request_redraw(); + } + Event::RedrawRequested(_) => { + current_frame += 1; + let width = surface.config.width; + let height = surface.config.height; + let device_handle = &render_cx.devices[surface.dev_id]; + let mut builder = SceneBuilder::for_scene(&mut scene); + + // Allow looping forever + let svg_ix = svg_ix.rem_euclid(svg_files.len() as i32) as usize; + + render_svg( + &mut builder, + &mut cached_svg_scene[svg_ix], + transform, + &svg_files[svg_ix], + ); + + builder.finish(); + let surface_texture = surface + .surface + .get_current_texture() + .expect("failed to get surface texture"); + renderer + .render_to_surface( + &device_handle.device, + &device_handle.queue, + &scene, + &surface_texture, + width, + height, + ) + .expect("failed to render to surface"); + surface_texture.present(); + + if current_frame % 60 == 0 { + let now = Instant::now(); + let duration = now.duration_since(last_title_update); + let fps = 60.0 / duration.as_secs_f64(); + window.set_title(&format!("usvg viewer - fps: {:.1}", fps)); + last_title_update = now; + } + device_handle.device.poll(wgpu::Maintain::Wait); + } + _ => {} + }) +} + +fn main() -> Result<()> { + let args = Args::parse(); + let paths = if args.files.is_empty() { + fetch_missing_assets()?; + ASSETS.iter().map(|asset| asset.local_path()).collect() + } else { + args.files + }; + let event_loop = EventLoop::new(); + let window = WindowBuilder::new() + .with_inner_size(LogicalSize::new(1044, 800)) + .with_resizable(true) + .with_title("Vello usvg viewer") + .build(&event_loop)?; + pollster::block_on(run(event_loop, window, paths)); + Ok(()) +} diff --git a/examples/usvg_viewer/src/render.rs b/examples/usvg_viewer/src/render.rs new file mode 100644 index 0000000..ff0503b --- /dev/null +++ b/examples/usvg_viewer/src/render.rs @@ -0,0 +1,108 @@ +use std::path::PathBuf; +use usvg::NodeExt; +use vello::kurbo::{Affine, BezPath, Rect}; +use vello::peniko::{Brush, Color, Fill, Stroke}; +use vello::{SceneBuilder, SceneFragment}; + +pub fn render_svg( + sb: &mut SceneBuilder, + scene: &mut Option, + xform: Affine, + path: &PathBuf, +) { + let scene_frag = scene.get_or_insert_with(|| { + let contents = std::fs::read_to_string(path).expect("failed to read svg file"); + let svg = usvg::Tree::from_str(&contents, &usvg::Options::default()) + .expect("failed to parse svg file"); + let mut new_scene = SceneFragment::new(); + let mut builder = SceneBuilder::for_fragment(&mut new_scene); + render_tree(&mut builder, svg); + new_scene + }); + sb.append(scene_frag, Some(xform)); +} + +fn render_tree(sb: &mut SceneBuilder, svg: usvg::Tree) { + for elt in svg.root.descendants() { + let transform = elt.abs_transform(); + match &*elt.borrow() { + usvg::NodeKind::Group(_) => {} + usvg::NodeKind::Path(path) => { + let mut local_path = BezPath::new(); + for elt in usvg::TransformedPath::new(&path.data, transform) { + match elt { + usvg::PathSegment::MoveTo { x, y } => local_path.move_to((x, y)), + usvg::PathSegment::LineTo { x, y } => local_path.line_to((x, y)), + usvg::PathSegment::CurveTo { + x1, + y1, + x2, + y2, + x, + y, + } => local_path.curve_to((x1, y1), (x2, y2), (x, y)), + usvg::PathSegment::ClosePath => local_path.close_path(), + } + } + + // FIXME: let path.paint_order determine the fill/stroke order. + + if let Some(fill) = &path.fill { + if let Some(brush) = paint_to_brush(&fill.paint, fill.opacity) { + // FIXME: Set the fill rule + sb.fill(Fill::NonZero, Affine::IDENTITY, &brush, None, &local_path); + } else { + unimplemented(sb, &elt); + } + } + if let Some(stroke) = &path.stroke { + if let Some(brush) = paint_to_brush(&stroke.paint, stroke.opacity) { + // FIXME: handle stroke options such as linecap, linejoin, etc. + sb.stroke( + &Stroke::new(stroke.width.get() as f32), + Affine::IDENTITY, + &brush, + None, + &local_path, + ); + } else { + unimplemented(sb, &elt); + } + } + } + usvg::NodeKind::Image(_) => { + unimplemented(sb, &elt); + } + usvg::NodeKind::Text(_) => { + unimplemented(sb, &elt); + } + } + } +} + +// Draw a red box over unsupported SVG features. +fn unimplemented(sb: &mut SceneBuilder, node: &usvg::Node) { + if let Some(bb) = node.calculate_bbox() { + let rect = Rect { + x0: bb.left(), + y0: bb.top(), + x1: bb.right(), + y1: bb.bottom(), + }; + sb.fill(Fill::NonZero, Affine::IDENTITY, Color::RED, None, &rect); + } +} + +fn paint_to_brush(paint: &usvg::Paint, opacity: usvg::Opacity) -> Option { + match paint { + usvg::Paint::Color(color) => Some(Brush::Solid(Color::rgba8( + color.red, + color.green, + color.blue, + opacity.to_u8(), + ))), + usvg::Paint::LinearGradient(_) => None, + usvg::Paint::RadialGradient(_) => None, + usvg::Paint::Pattern(_) => None, + } +}