mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
Plotter
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod convert_usvg_path;
|
||||
pub mod plot_statistics;
|
||||
pub mod render_ext;
|
||||
mod renderer;
|
||||
pub mod to_peniko;
|
||||
|
||||
138
node-graph/libraries/rendering/src/plot_statistics.rs
Normal file
138
node-graph/libraries/rendering/src/plot_statistics.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user