Implement clipping masks, stroke align, and stroke paint order (#2644)

* refactor: opacity + blend_mode -> blend_style

* Add code for clipping

* Add alt-click masking

* Clip to all colors. Fill option

* Fix undo not working. Fix strokes not being white

* Allow clipped to be grouped or raster

* Switch to alpha mode in mask-type

* add plumbing to know if clipped in frontend and add fill slider

* Attempt at document upgrade code

* Fix fill slider

* Add clipped styling and Alt-click layer border

* Use mask attr judiciously by using clip when possible

* Fix breaking documents and upgrade code

* Fix fixes

* No-op toggle if last child of parent and don't show clip UI if last element

* Fix mouse styles by plumbing clippable to frontend

* Fix Clip detection by disallowed groups as clipPath according to SVG spec doesn't allow <g>

* Add opacity to clippers can_use_clip check

* Fix issue with clipping not working nicely with strokes by using masks

* Add vello code

* cleanup

* Add stroke alignment hacks to SVG renderer

* svg: Fix mask bounds in vector data

* vello: Implement mask hacks to support stroke alignment

* Move around alignment and doc upgrade code

* rename Line X -> X

* An attempt at fixing names not updating

* svg: add stroke order with svg

* vello: add stroke order with by calling one before the other explicitly

* fix merge

* fix svg renderer messing up transform det

* Code review; reorder and rename parameters (TODO: fix tools)

* Fixes to previous

* Formatting

* fix bug 3

* some moving around (not fixed)

* fix issue 1

* fix vello

* Final code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
mTvare
2025-06-18 11:06:37 +05:30
committed by Keavon Chambers
parent 8a3f133140
commit e238753a35
28 changed files with 1025 additions and 311 deletions

View File

@@ -2,7 +2,7 @@
use crate::Color;
use crate::consts::{LAYER_OUTLINE_STROKE_COLOR, LAYER_OUTLINE_STROKE_WEIGHT};
use crate::renderer::format_transform_matrix;
use crate::renderer::{RenderParams, format_transform_matrix};
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use std::fmt::Write;
@@ -214,7 +214,7 @@ impl Gradient {
}
/// Adds the gradient def through mutating the first argument, returning the gradient ID.
fn render_defs(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> u64 {
fn render_defs(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2], _render_params: &RenderParams) -> u64 {
// TODO: Figure out how to use `self.transform` as part of the gradient transform, since that field (`Gradient::transform`) is currently never read from, it's only written to.
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
@@ -381,7 +381,7 @@ impl Fill {
}
/// Renders the fill, adding necessary defs through mutating the first argument.
pub fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String {
pub fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2], render_params: &RenderParams) -> String {
match self {
Self::None => r#" fill="none""#.to_string(),
Self::Solid(color) => {
@@ -392,7 +392,7 @@ impl Fill {
result
}
Self::Gradient(gradient) => {
let gradient_id = gradient.render_defs(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds);
let gradient_id = gradient.render_defs(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params);
format!(r##" fill="url('#{gradient_id}')""##)
}
}
@@ -413,6 +413,20 @@ impl Fill {
_ => None,
}
}
/// Find if fill can be represented with only opaque colors
pub fn is_opaque(&self) -> bool {
match self {
Fill::Solid(color) => color.is_opaque(),
Fill::Gradient(gradient) => gradient.stops.iter().all(|(_, color)| color.is_opaque()),
Fill::None => true,
}
}
/// Returns if fill is none
pub fn is_none(&self) -> bool {
*self == Self::None
}
}
impl From<Color> for Fill {
@@ -499,19 +513,19 @@ pub enum FillType {
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Radio)]
pub enum LineCap {
pub enum StrokeCap {
#[default]
Butt,
Round,
Square,
}
impl LineCap {
impl StrokeCap {
fn svg_name(&self) -> &'static str {
match self {
LineCap::Butt => "butt",
LineCap::Round => "round",
LineCap::Square => "square",
StrokeCap::Butt => "butt",
StrokeCap::Round => "round",
StrokeCap::Square => "square",
}
}
}
@@ -519,29 +533,61 @@ impl LineCap {
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Radio)]
pub enum LineJoin {
pub enum StrokeJoin {
#[default]
Miter,
Bevel,
Round,
}
impl LineJoin {
impl StrokeJoin {
fn svg_name(&self) -> &'static str {
match self {
LineJoin::Bevel => "bevel",
LineJoin::Miter => "miter",
LineJoin::Round => "round",
StrokeJoin::Bevel => "bevel",
StrokeJoin::Miter => "miter",
StrokeJoin::Round => "round",
}
}
}
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Radio)]
pub enum StrokeAlign {
#[default]
Center,
Inside,
Outside,
}
impl StrokeAlign {
pub fn is_not_centered(self) -> bool {
self != Self::Center
}
}
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Radio)]
pub enum PaintOrder {
#[default]
StrokeAbove,
StrokeBelow,
}
impl PaintOrder {
pub fn is_default(self) -> bool {
self == Self::default()
}
}
fn daffine2_identity() -> DAffine2 {
DAffine2::IDENTITY
}
#[repr(C)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, DynAny, specta::Type)]
#[serde(default)]
pub struct Stroke {
/// Stroke color
pub color: Option<Color>,
@@ -549,26 +595,38 @@ pub struct Stroke {
pub weight: f64,
pub dash_lengths: Vec<f64>,
pub dash_offset: f64,
pub line_cap: LineCap,
pub line_join: LineJoin,
pub line_join_miter_limit: f64,
#[serde(alias = "line_cap")]
pub cap: StrokeCap,
#[serde(alias = "line_join")]
pub join: StrokeJoin,
#[serde(alias = "line_join_miter_limit")]
pub join_miter_limit: f64,
#[serde(default)]
pub align: StrokeAlign,
#[serde(default = "daffine2_identity")]
pub transform: DAffine2,
#[serde(default)]
pub non_scaling: bool,
#[serde(default)]
pub paint_order: PaintOrder,
}
impl core::hash::Hash for Stroke {
fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.color.hash(state);
self.weight.to_bits().hash(state);
self.dash_lengths.len().hash(state);
self.dash_lengths.iter().for_each(|length| length.to_bits().hash(state));
{
self.dash_lengths.len().hash(state);
self.dash_lengths.iter().for_each(|length| length.to_bits().hash(state));
}
self.dash_offset.to_bits().hash(state);
self.line_cap.hash(state);
self.line_join.hash(state);
self.line_join_miter_limit.to_bits().hash(state);
self.cap.hash(state);
self.join.hash(state);
self.join_miter_limit.to_bits().hash(state);
self.align.hash(state);
self.transform.to_cols_array().iter().for_each(|x| x.to_bits().hash(state));
self.non_scaling.hash(state);
self.paint_order.hash(state);
}
}
@@ -590,11 +648,13 @@ impl Stroke {
weight,
dash_lengths: Vec::new(),
dash_offset: 0.,
line_cap: LineCap::Butt,
line_join: LineJoin::Miter,
line_join_miter_limit: 4.,
cap: StrokeCap::Butt,
join: StrokeJoin::Miter,
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
non_scaling: false,
paint_order: PaintOrder::StrokeAbove,
}
}
@@ -604,14 +664,16 @@ impl Stroke {
weight: self.weight + (other.weight - self.weight) * time,
dash_lengths: self.dash_lengths.iter().zip(other.dash_lengths.iter()).map(|(a, b)| a + (b - a) * time).collect(),
dash_offset: self.dash_offset + (other.dash_offset - self.dash_offset) * time,
line_cap: if time < 0.5 { self.line_cap } else { other.line_cap },
line_join: if time < 0.5 { self.line_join } else { other.line_join },
line_join_miter_limit: self.line_join_miter_limit + (other.line_join_miter_limit - self.line_join_miter_limit) * time,
cap: if time < 0.5 { self.cap } else { other.cap },
join: if time < 0.5 { self.join } else { other.join },
join_miter_limit: self.join_miter_limit + (other.join_miter_limit - self.join_miter_limit) * time,
align: if time < 0.5 { self.align } else { other.align },
transform: DAffine2::from_mat2_translation(
time * self.transform.matrix2 + (1. - time) * other.transform.matrix2,
self.transform.translation * time + other.transform.translation * (1. - time),
),
non_scaling: if time < 0.5 { self.non_scaling } else { other.non_scaling },
paint_order: if time < 0.5 { self.paint_order } else { other.paint_order },
}
}
@@ -637,23 +699,23 @@ impl Stroke {
self.dash_offset
}
pub fn line_cap_index(&self) -> u32 {
self.line_cap as u32
pub fn cap_index(&self) -> u32 {
self.cap as u32
}
pub fn line_join_index(&self) -> u32 {
self.line_join as u32
pub fn join_index(&self) -> u32 {
self.join as u32
}
pub fn line_join_miter_limit(&self) -> f32 {
self.line_join_miter_limit as f32
pub fn join_miter_limit(&self) -> f32 {
self.join_miter_limit as f32
}
/// Provide the SVG attributes for the stroke.
pub fn render(&self) -> String {
pub fn render(&self, aligned_strokes: bool, override_paint_order: bool, _render_params: &RenderParams) -> String {
// Don't render a stroke at all if it would be invisible
let Some(color) = self.color else { return String::new() };
if self.weight <= 0. || color.a() == 0. {
if !self.has_renderable_stroke() {
return String::new();
}
@@ -661,16 +723,21 @@ impl Stroke {
let weight = (self.weight != 1.).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 line_cap = (self.line_cap != LineCap::Butt).then_some(self.line_cap);
let line_join = (self.line_join != LineJoin::Miter).then_some(self.line_join);
let line_join_miter_limit = (self.line_join_miter_limit != 4.).then_some(self.line_join_miter_limit);
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);
let stroke_align = (self.align != StrokeAlign::Center).then_some(self.align);
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || override_paint_order).then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes
let mut attributes = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma());
if color.a() < 1. {
let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
}
if let Some(weight) = weight {
if let Some(mut weight) = weight {
if stroke_align.is_some() && aligned_strokes {
weight *= 2.;
}
let _ = write!(&mut attributes, r#" stroke-width="{}""#, weight);
}
if let Some(dash_array) = dash_array {
@@ -679,19 +746,22 @@ impl Stroke {
if let Some(dash_offset) = dash_offset {
let _ = write!(&mut attributes, r#" stroke-dashoffset="{}""#, dash_offset);
}
if let Some(line_cap) = line_cap {
let _ = write!(&mut attributes, r#" stroke-linecap="{}""#, line_cap.svg_name());
if let Some(stroke_cap) = stroke_cap {
let _ = write!(&mut attributes, r#" stroke-linecap="{}""#, stroke_cap.svg_name());
}
if let Some(line_join) = line_join {
let _ = write!(&mut attributes, r#" stroke-linejoin="{}""#, line_join.svg_name());
if let Some(stroke_join) = stroke_join {
let _ = write!(&mut attributes, r#" stroke-linejoin="{}""#, stroke_join.svg_name());
}
if let Some(line_join_miter_limit) = line_join_miter_limit {
let _ = write!(&mut attributes, r#" stroke-miterlimit="{}""#, line_join_miter_limit);
if let Some(stroke_join_miter_limit) = stroke_join_miter_limit {
let _ = write!(&mut attributes, r#" stroke-miterlimit="{}""#, stroke_join_miter_limit);
}
// Add vector-effect attribute to make strokes non-scaling
if self.non_scaling {
let _ = write!(&mut attributes, r#" vector-effect="non-scaling-stroke""#);
}
if paint_order.is_some() {
let _ = write!(&mut attributes, r#" style="paint-order: stroke;" "#);
}
attributes
}
@@ -724,18 +794,23 @@ impl Stroke {
self
}
pub fn with_line_cap(mut self, line_cap: LineCap) -> Self {
self.line_cap = line_cap;
pub fn with_stroke_cap(mut self, stroke_cap: StrokeCap) -> Self {
self.cap = stroke_cap;
self
}
pub fn with_line_join(mut self, line_join: LineJoin) -> Self {
self.line_join = line_join;
pub fn with_stroke_join(mut self, stroke_join: StrokeJoin) -> Self {
self.join = stroke_join;
self
}
pub fn with_line_join_miter_limit(mut self, limit: f64) -> Self {
self.line_join_miter_limit = limit;
pub fn with_stroke_join_miter_limit(mut self, limit: f64) -> Self {
self.join_miter_limit = limit;
self
}
pub fn with_stroke_align(mut self, stroke_align: StrokeAlign) -> Self {
self.align = stroke_align;
self
}
@@ -743,6 +818,10 @@ impl Stroke {
self.non_scaling = non_scaling;
self
}
pub fn has_renderable_stroke(&self) -> bool {
self.weight > 0. && self.color.is_some_and(|color| color.a() != 0.)
}
}
// Having an alpha of 1 to start with leads to a better experience with the properties panel
@@ -753,11 +832,13 @@ impl Default for Stroke {
color: Some(Color::from_rgba8_srgb(0, 0, 0, 255)),
dash_lengths: Vec::new(),
dash_offset: 0.,
line_cap: LineCap::Butt,
line_join: LineJoin::Miter,
line_join_miter_limit: 4.,
cap: StrokeCap::Butt,
join: StrokeJoin::Miter,
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
non_scaling: false,
paint_order: PaintOrder::default(),
}
}
}
@@ -929,19 +1010,35 @@ impl PathStyle {
}
/// Renders the shape's fill and stroke attributes as a string with them concatenated together.
pub fn render(&self, view_mode: ViewMode, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String {
#[allow(clippy::too_many_arguments)]
pub fn render(
&self,
svg_defs: &mut String,
element_transform: DAffine2,
stroke_transform: DAffine2,
bounds: [DVec2; 2],
transformed_bounds: [DVec2; 2],
aligned_strokes: bool,
override_paint_order: bool,
render_params: &RenderParams,
) -> String {
let view_mode = render_params.view_mode;
match view_mode {
ViewMode::Outline => {
let fill_attribute = Fill::None.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds);
let fill_attribute = Fill::None.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params);
let mut outline_stroke = Stroke::new(Some(LAYER_OUTLINE_STROKE_COLOR), LAYER_OUTLINE_STROKE_WEIGHT);
// Outline strokes should be non-scaling by default
outline_stroke.non_scaling = true;
let stroke_attribute = outline_stroke.render();
let stroke_attribute = outline_stroke.render(aligned_strokes, override_paint_order, render_params);
format!("{fill_attribute}{stroke_attribute}")
}
_ => {
let fill_attribute = self.fill.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds);
let stroke_attribute = self.stroke.as_ref().map(|stroke| stroke.render()).unwrap_or_default();
let fill_attribute = self.fill.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params);
let stroke_attribute = self
.stroke
.as_ref()
.map(|stroke| stroke.render(aligned_strokes, override_paint_order, render_params))
.unwrap_or_default();
format!("{fill_attribute}{stroke_attribute}")
}
}

View File

@@ -10,7 +10,7 @@ use crate::registry::types::{Angle, Fraction, IntegerCount, Length, Multiplier,
use crate::renderer::GraphicElementRendered;
use crate::transform::{Footprint, ReferencePoint, Transform};
use crate::vector::misc::dvec2_to_point;
use crate::vector::style::{LineCap, LineJoin};
use crate::vector::style::{PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
use crate::vector::{FillId, PointDomain, RegionId};
use crate::{CloneVarArgs, Color, Context, Ctx, ExtractAll, GraphicElement, GraphicGroupTable, OwnedContextImpl};
use bezier_rs::{Join, ManipulatorGroup, Subpath};
@@ -167,17 +167,22 @@ async fn stroke<C: Into<Option<Color>> + 'n + Send, V>(
#[default(2.)]
/// The stroke weight.
weight: f64,
/// The stroke dash lengths. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed.
dash_lengths: Vec<f64>,
/// The offset distance from the starting point of the dash pattern.
dash_offset: f64,
/// The alignment of stroke to the path's centerline or (for closed shapes) the inside or outside of the shape.
align: StrokeAlign,
/// The shape of the stroke at open endpoints.
line_cap: crate::vector::style::LineCap,
cap: StrokeCap,
/// The curvature of the bent stroke at sharp corners.
line_join: LineJoin,
join: StrokeJoin,
#[default(4.)]
/// The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio.
miter_limit: f64,
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
/// <https://svgwg.org/svg2-draft/painting.html#PaintOrderProperty>
paint_order: PaintOrder,
/// The stroke dash lengths. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed.
dash_lengths: Vec<f64>,
/// The phase offset distance from the starting point of the dash pattern.
dash_offset: f64,
) -> Instances<V>
where
Instances<V>: VectorDataTableIterMut + 'n + Send,
@@ -187,12 +192,15 @@ where
weight,
dash_lengths,
dash_offset,
line_cap,
line_join,
line_join_miter_limit: miter_limit,
cap,
join,
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
non_scaling: false,
paint_order,
};
for vector in vector_data.vector_iter_mut() {
let mut stroke = stroke.clone();
stroke.transform *= *vector.transform;
@@ -1084,7 +1092,7 @@ async fn points_to_polyline(_: impl Ctx, mut points: VectorDataTable, #[default(
}
#[node_macro::node(category("Vector"), path(graphene_core::vector), properties("offset_path_properties"))]
async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, line_join: LineJoin, #[default(4.)] miter_limit: f64) -> VectorDataTable {
async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, join: StrokeJoin, #[default(4.)] miter_limit: f64) -> VectorDataTable {
let mut result_table = VectorDataTable::default();
for mut vector_data_instance in vector_data.instance_iter() {
@@ -1106,10 +1114,10 @@ async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, l
let mut subpath_out = offset_subpath(
&subpath,
-distance,
match line_join {
LineJoin::Miter => Join::Miter(Some(miter_limit)),
LineJoin::Bevel => Join::Bevel,
LineJoin::Round => Join::Round,
match join {
StrokeJoin::Miter => Join::Miter(Some(miter_limit)),
StrokeJoin::Bevel => Join::Bevel,
StrokeJoin::Round => Join::Round,
},
);
@@ -1139,19 +1147,19 @@ async fn solidify_stroke(_: impl Ctx, vector_data: VectorDataTable) -> VectorDat
let mut result = VectorData::default();
// Taking the existing stroke data and passing it to kurbo::stroke to generate new fill paths.
let join = match stroke.line_join {
LineJoin::Miter => kurbo::Join::Miter,
LineJoin::Bevel => kurbo::Join::Bevel,
LineJoin::Round => kurbo::Join::Round,
let join = match stroke.join {
StrokeJoin::Miter => kurbo::Join::Miter,
StrokeJoin::Bevel => kurbo::Join::Bevel,
StrokeJoin::Round => kurbo::Join::Round,
};
let cap = match stroke.line_cap {
LineCap::Butt => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square,
let cap = match stroke.cap {
StrokeCap::Butt => kurbo::Cap::Butt,
StrokeCap::Round => kurbo::Cap::Round,
StrokeCap::Square => kurbo::Cap::Square,
};
let dash_offset = stroke.dash_offset;
let dash_pattern = stroke.dash_lengths;
let miter_limit = stroke.line_join_miter_limit;
let miter_limit = stroke.join_miter_limit;
let stroke_style = kurbo::Stroke::new(stroke.weight)
.with_caps(cap)