This commit is contained in:
Keavon Chambers
2026-07-15 21:54:17 -07:00
parent 751a6ca77d
commit 9537a4ae03
30 changed files with 766 additions and 39 deletions

View File

@@ -83,6 +83,10 @@ pub struct RenderConfig {
pub export_format: ExportFormat,
pub for_export: bool,
pub for_eyedropper: bool,
/// Skip rendering artboard background rectangles, used for pen plotter output.
pub hide_artboard_background: bool,
/// Cut dashed strokes into their visible dash segments so the path geometry itself carries the dash pattern, used for pen plotter output.
pub bake_stroke_dashes: bool,
}
impl RenderConfig {

View File

@@ -1,4 +1,5 @@
pub mod convert_usvg_path;
pub mod plot_statistics;
pub mod render_ext;
mod renderer;
pub mod to_peniko;

View File

@@ -0,0 +1,138 @@
use kurbo::{BezPath, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape};
/// Pen movement totals for plotting an SVG document with a pen plotter, which draws every path as its outline.
/// Distances are in SVG user units; scale by the paper fit before converting to wall-clock time.
///
/// Pen-up travel between subpaths is deliberately not measured: the print server reorders paths (and rotates the
/// start points of closed ones) to minimize travel, so document-order travel distance is meaningless. Its cost is
/// captured as part of the constant time per pen lift instead.
pub struct PlotStatistics {
/// Total distance drawn with the pen down, following every path's geometry.
pub pen_down_distance: f64,
/// Number of pen lift/reposition/lower cycles (one per subpath).
pub pen_lift_count: usize,
/// Width of the artwork's bounding box, which the print server scales to fit the paper (the document size is ignored).
pub width: f64,
/// Height of the artwork's bounding box.
pub height: f64,
}
#[derive(Default)]
struct MeasureState {
pen_down_distance: f64,
pen_lift_count: usize,
bounds: Option<Rect>,
}
/// Measures the pen plotter movement statistics of an SVG document by walking every path in document order.
/// Returns `None` if the SVG cannot be parsed or contains no path geometry.
pub fn svg_plot_statistics(svg: &str) -> Option<PlotStatistics> {
let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?;
let mut state = MeasureState::default();
accumulate_group(tree.root(), &mut state);
let bounds = state.bounds?;
Some(PlotStatistics {
pen_down_distance: state.pen_down_distance,
pen_lift_count: state.pen_lift_count,
width: bounds.width(),
height: bounds.height(),
})
}
fn accumulate_group(group: &usvg::Group, state: &mut MeasureState) {
for node in group.children() {
match node {
usvg::Node::Group(group) => accumulate_group(group, state),
usvg::Node::Path(path) => accumulate_bezpath(&usvg_path_to_bezpath(path), state),
_ => {}
}
}
}
fn accumulate_bezpath(bezpath: &BezPath, state: &mut MeasureState) {
const ARC_LENGTH_ACCURACY: f64 = 0.1;
if bezpath.elements().is_empty() {
return;
}
for segment in bezpath.segments() {
state.pen_down_distance += match segment {
PathSeg::Line(line) => line.p0.distance(line.p1),
segment => segment.arclen(ARC_LENGTH_ACCURACY),
};
}
state.pen_lift_count += bezpath.elements().iter().filter(|element| matches!(element, PathEl::MoveTo(_))).count();
let bounds = bezpath.bounding_box();
if !bounds.is_nan() {
state.bounds = Some(state.bounds.map_or(bounds, |existing| existing.union(bounds)));
}
}
/// Converts a usvg path into a kurbo path with its absolute transform applied.
fn usvg_path_to_bezpath(path: &usvg::Path) -> BezPath {
let transform = path.abs_transform();
let to_point = |point: &usvg::tiny_skia_path::Point| {
let (x, y) = (point.x as f64, point.y as f64);
Point::new(
transform.sx as f64 * x + transform.kx as f64 * y + transform.tx as f64,
transform.ky as f64 * x + transform.sy as f64 * y + transform.ty as f64,
)
};
let mut bezpath = BezPath::new();
let mut points = path.data().points().iter();
for verb in path.data().verbs() {
match verb {
usvg::tiny_skia_path::PathVerb::Move => {
let Some(point) = points.next().map(to_point) else { continue };
bezpath.move_to(point);
}
usvg::tiny_skia_path::PathVerb::Line => {
let Some(end) = points.next().map(to_point) else { continue };
bezpath.line_to(end);
}
usvg::tiny_skia_path::PathVerb::Quad => {
let Some(handle) = points.next().map(to_point) else { continue };
let Some(end) = points.next().map(to_point) else { continue };
bezpath.quad_to(handle, end);
}
usvg::tiny_skia_path::PathVerb::Cubic => {
let Some(first_handle) = points.next().map(to_point) else { continue };
let Some(second_handle) = points.next().map(to_point) else { continue };
let Some(end) = points.next().map(to_point) else { continue };
bezpath.curve_to(first_handle, second_handle, end);
}
usvg::tiny_skia_path::PathVerb::Close => bezpath.close_path(),
}
}
bezpath
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_lines_squares_and_bounds() {
// A 30x30 square (120 units drawn, 1 lift) followed by a vertical line (100 units drawn, 1 lift),
// with an artwork bounding box spanning (10,10) to (40,150)
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 200" width="100" height="200">
<path d="M10,10 L40,10 L40,40 L10,40 Z" fill="black" />
<path d="M10,50 L10,150" stroke="black" fill="none" />
</svg>"##;
let statistics = svg_plot_statistics(svg).unwrap();
assert_eq!(statistics.pen_lift_count, 2);
assert!((statistics.pen_down_distance - 220.).abs() < 1e-6, "pen down was {}", statistics.pen_down_distance);
assert!((statistics.width - 30.).abs() < 1e-6, "width was {}", statistics.width);
assert!((statistics.height - 140.).abs() < 1e-6, "height was {}", statistics.height);
}
}

View File

@@ -179,10 +179,11 @@ impl RenderExt for Stroke {
let default_weight = if self.align != StrokeAlign::Center && render_params.aligned_strokes { 1. / 2. } else { 1. };
// Set to None if the value is the SVG default
// Set to None if the value is the SVG default. When dashes are baked into the path geometry, the dash attributes must be
// omitted so the pattern isn't applied a second time to the already-cut dash segments.
let weight = (self.weight != default_weight).then_some(self.weight);
let dash_array = (!self.dash_lengths.is_empty()).then_some(self.dash_lengths());
let dash_offset = (self.dash_offset != 0.).then_some(self.dash_offset);
let dash_array = (!self.dash_lengths.is_empty() && !render_params.bake_stroke_dashes).then_some(self.dash_lengths());
let dash_offset = (self.dash_offset != 0. && !render_params.bake_stroke_dashes).then_some(self.dash_offset);
let stroke_cap = (self.cap != StrokeCap::Butt).then_some(self.cap);
let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join);
let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit);

View File

@@ -230,6 +230,10 @@ pub struct RenderParams {
pub artboard_background: Option<Color>,
/// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode.
pub viewport_zoom: f64,
/// Skip rendering artboard background rectangles, used for pen plotter output where a background would be traced as a giant filled contour.
pub hide_artboard_background: bool,
/// Cut dashed strokes into their visible dash segments so the path geometry itself carries the dash pattern, used for pen plotter output where `stroke-dasharray` would be ignored.
pub bake_stroke_dashes: bool,
}
impl RenderParams {
@@ -718,16 +722,18 @@ impl Render for List<Artboard> {
let height = dimensions.y.abs();
// Background
render.leaf_tag("rect", |attributes| {
attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
if background.a() < 1. {
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
}
attributes.push("x", x.to_string());
attributes.push("y", y.to_string());
attributes.push("width", width.to_string());
attributes.push("height", height.to_string());
});
if !render_params.hide_artboard_background {
render.leaf_tag("rect", |attributes| {
attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
if background.a() < 1. {
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
}
attributes.push("x", x.to_string());
attributes.push("y", y.to_string());
attributes.push("width", width.to_string());
attributes.push("height", height.to_string());
});
}
// Artwork
render.parent_tag(
@@ -775,10 +781,12 @@ impl Render for List<Artboard> {
let artboard_transform = kurbo::Affine::new(transform.to_cols_array());
let color = SRGBA8::from(background).to_peniko_color();
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
scene.pop_layer();
if !render_params.hide_artboard_background {
let color = SRGBA8::from(background).to_peniko_color();
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
scene.pop_layer();
}
if clip {
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect);
@@ -1114,7 +1122,33 @@ impl Render for List<Vector> {
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill();
if needs_separate_alignment_fill && !wants_stroke_below {
// When baking dashes, cut each stroked path into its visible dash segments so the geometry itself carries the dash
// pattern, letting consumers that ignore `stroke-dasharray` (like a pen plotter) still draw the dashes.
let baked_dash_path = (render_params.bake_stroke_dashes && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()))
.then_some(vector.stroke.as_ref())
.flatten()
.filter(|stroke| stroke.has_renderable_stroke() && !stroke.dash_lengths.is_empty())
.and_then(|stroke| {
let dash_pattern: Vec<f64> = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect();
if dash_pattern.iter().sum::<f64>() <= 0. {
return None;
}
let mut dashed_path = String::new();
for mut bezpath in vector.stroke_bezpath_iter() {
bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
let dashed_bezpath: BezPath = kurbo::dash(bezpath.iter(), stroke.dash_offset, &dash_pattern).collect();
dashed_path.push_str(dashed_bezpath.to_svg().as_str());
}
Some(dashed_path)
});
// When dashes are baked, the fill is dropped entirely rather than emitted as a separate path: the pen plotter
// draws every path as its outline, and a fill's contour is the same uncut geometry the dashes were cut from,
// so it would be drawn as a solid line right over the dashes.
let emit_separate_fill = needs_separate_alignment_fill && baked_dash_path.is_none();
if emit_separate_fill && !wants_stroke_below {
emit_svg_fill_path(
render,
path.clone(),
@@ -1142,7 +1176,8 @@ impl Render for List<Vector> {
(id, mask_type, vector_item)
});
if use_face_fill {
// Face fills are dropped when dashes are baked for the same reason as above: their boundaries retrace the dashed edges
if use_face_fill && baked_dash_path.is_none() {
for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) {
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
let face_d = face_path.to_svg();
@@ -1161,7 +1196,7 @@ impl Render for List<Vector> {
}
render.leaf_tag("path", |attributes| {
attributes.push("d", path.clone());
attributes.push("d", baked_dash_path.clone().unwrap_or_else(|| path.clone()));
let matrix = format_transform_matrix(element_transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
@@ -1229,7 +1264,7 @@ impl Render for List<Vector> {
String::new()
};
let fill_attribute = if needs_separate_alignment_fill || use_face_fill {
let fill_attribute = if needs_separate_alignment_fill || use_face_fill || baked_dash_path.is_some() {
r#" fill="none""#.to_string()
} else {
fill_graphic_list
@@ -1261,7 +1296,7 @@ impl Render for List<Vector> {
});
// When splitting passes and stroke is below, draw the fill after the stroke.
if needs_separate_alignment_fill && wants_stroke_below {
if emit_separate_fill && wants_stroke_below {
emit_svg_fill_path(
render,
path.clone(),
@@ -1473,7 +1508,16 @@ impl Render for List<Vector> {
// Render the path
match render_params.render_mode {
RenderMode::Outline => {
let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params);
let (mut outline_stroke, outline_color_peniko) = get_outline_styles(render_params);
// Show the stroke's dash pattern in the outline preview so dashed strokes read the same as they will be plotted
if let Some(stroke) = stroke.filter(|stroke| stroke.has_renderable_stroke()) {
let dash_pattern: kurbo::Dashes = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect();
if dash_pattern.iter().sum::<f64>() > 0. {
outline_stroke.dash_pattern = dash_pattern;
outline_stroke.dash_offset = stroke.dash_offset;
}
}
scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path);
}

View File

@@ -7,6 +7,7 @@ use core_types::{Ctx, ExtractFootprint};
use glam::{Affine2, UVec2, Vec2};
use graph_craft::document::value::{RenderOutput, RenderOutputType};
use graphic_types::raster_types::Texture;
use graphic_types::vector_types::vector::style::RenderMode;
use rendering::{RenderParams, SvgRender, SvgRenderOutput};
use std::fmt::Write;
use wgpu::util::DeviceExt;
@@ -33,6 +34,9 @@ async fn render_background<'a: 'n>(
let mut render_params = render_params.clone();
render_params.footprint = *footprint;
// Outline mode previews the artwork as it would be drawn on paper, so transparency shows as solid white instead of the checkerboard
let solid_white_background = render_params.render_mode == RenderMode::Outline;
let data = match foreground_data {
RenderOutputType::Texture(foreground_texture) => {
let doc_to_screen = render_params.footprint.transform.as_affine2();
@@ -43,6 +47,7 @@ async fn render_background<'a: 'n>(
backgrounds: &metadata.backgrounds,
document_to_screen: doc_to_screen,
zoom: render_params.viewport_zoom.to_f32(),
solid_white: solid_white_background,
})
.await;
@@ -58,6 +63,17 @@ async fn render_background<'a: 'n>(
if render_params.viewport_zoom > 0. {
let draw_checkerboard = |render: &mut SvgRender, rect: vello::kurbo::Rect, pattern_origin: glam::DVec2, checker_id_prefix: &str| {
if solid_white_background {
render.leaf_tag("rect", |attributes| {
attributes.push("x", rect.x0.to_string());
attributes.push("y", rect.y0.to_string());
attributes.push("width", rect.width().to_string());
attributes.push("height", rect.height().to_string());
attributes.push("fill", "#ffffff".to_string());
});
return;
}
let checker_id = format!("{checker_id_prefix}-{}", generate_uuid());
let cell_size = 8. / render_params.viewport_zoom;
let pattern_size = cell_size * 2.;
@@ -148,6 +164,8 @@ pub struct CompositeBackgroundArgs<'a> {
backgrounds: &'a [rendering::Background],
document_to_screen: Affine2,
zoom: f32,
/// Draw solid white instead of the transparency checkerboard, used in Outline mode.
solid_white: bool,
}
impl AsyncWgpuPipeline for CompositeBackground {
@@ -339,6 +357,7 @@ impl AsyncWgpuPipeline for CompositeBackground {
backgrounds,
document_to_screen,
zoom,
solid_white,
} = args;
let foreground_size = foreground.size();
@@ -362,7 +381,7 @@ impl AsyncWgpuPipeline for CompositeBackground {
let checker_draws = if backgrounds.is_empty() {
vec![(
3,
self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc)),
self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc, solid_white)),
)]
} else {
backgrounds
@@ -378,7 +397,7 @@ impl AsyncWgpuPipeline for CompositeBackground {
return None;
}
let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc);
let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc, solid_white);
Some((6, self.create_checker_bind_group(device, uniforms)))
})
.collect()
@@ -474,19 +493,19 @@ struct CompositeUniforms {
viewport_size: [f32; 2],
pattern_origin: [f32; 2],
checker_size: f32,
_pad: f32,
solid_white: f32,
}
impl CompositeUniforms {
fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32) -> Self {
Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc)
fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32, solid_white: bool) -> Self {
Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc, solid_white)
}
fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32) -> Self {
Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc)
fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32, solid_white: bool) -> Self {
Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc, solid_white)
}
fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32) -> Self {
fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32, solid_white: bool) -> Self {
Self {
transform_x: transform.matrix2.x_axis.to_array(),
transform_y: transform.matrix2.y_axis.to_array(),
@@ -496,7 +515,7 @@ impl CompositeUniforms {
viewport_size: viewport_size.to_array(),
pattern_origin: pattern_origin.to_array(),
checker_size,
_pad: 0.,
solid_white: if solid_white { 1. } else { 0. },
}
}
}

View File

@@ -7,7 +7,7 @@ struct CompositeUniforms {
viewport_size: vec2<f32>,
pattern_origin: vec2<f32>,
checker_size: f32,
_pad: f32,
solid_white: f32,
};
@group(0) @binding(0)
@@ -44,7 +44,10 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size);
let parity = i32(tile.x + tile.y) & 1;
let luminance = select(1.0, 0.8, parity == 1);
var luminance = select(1.0, 0.8, parity == 1);
if uniforms.solid_white != 0.0 {
luminance = 1.0;
}
let fw = fwidthFine(in.document_position);
let coverage_max = 1.0 - smoothstep(uniforms.rect_max - fw, uniforms.rect_max, in.document_position);

View File

@@ -7,7 +7,7 @@ struct CompositeUniforms {
viewport_size: vec2<f32>,
pattern_origin: vec2<f32>,
checker_size: f32,
_pad: f32,
solid_white: f32,
};
@group(0) @binding(0)
@@ -40,6 +40,9 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size);
let parity = i32(tile.x + tile.y) & 1;
let luminance = vec3<f32>(select(1.0, 0.8, parity == 1));
var luminance = vec3<f32>(select(1.0, 0.8, parity == 1));
if uniforms.solid_white != 0.0 {
luminance = vec3<f32>(1.0);
}
return vec4<f32>(luminance, 1.0);
}

View File

@@ -174,6 +174,8 @@ async fn create_context<'a: 'n>(
render_output_type,
scale: render_config.scale,
viewport_zoom: logical_viewport.scale_magnitudes().x,
hide_artboard_background: render_config.hide_artboard_background,
bake_stroke_dashes: render_config.bake_stroke_dashes,
..Default::default()
};