mirror of
https://github.com/italicsjenga/vello.git
synced 2025-01-09 20:31:29 +11:00
example: SVG viewer based on usvg
(#260)
This commit is contained in:
parent
b83642bf0c
commit
9ba85075d4
|
@ -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"
|
||||
|
|
22
examples/usvg_viewer/Cargo.toml
Normal file
22
examples/usvg_viewer/Cargo.toml
Normal file
|
@ -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"
|
41
examples/usvg_viewer/README.md
Normal file
41
examples/usvg_viewer/README.md
Normal file
|
@ -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.
|
104
examples/usvg_viewer/src/asset.rs
Normal file
104
examples/usvg_viewer/src/asset.rs
Normal file
|
@ -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<u8> = 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,
|
||||
// },
|
||||
];
|
215
examples/usvg_viewer/src/main.rs
Normal file
215
examples/usvg_viewer/src/main.rs
Normal file
|
@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
// 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::<Vec<_>>();
|
||||
|
||||
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<PathBuf>) {
|
||||
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(())
|
||||
}
|
108
examples/usvg_viewer/src/render.rs
Normal file
108
examples/usvg_viewer/src/render.rs
Normal file
|
@ -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<SceneFragment>,
|
||||
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<Brush> {
|
||||
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,
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue