mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-04 02:08:12 +08:00
Fix clipping masks, click targets, and bounds when rendering Vector[] and flattened graphic wrappers (#4429)
* Honor clipping masks when rendering Vector[] lists, not just Graphic[] groups * Keep per-layer click targets when a Vector[] round-trips through a Graphic[] wrapper * Skip the rebuild and the merged-layers snapshot when flattening a lone anonymous graphic wrapper * Fix a group's bounds excluding its raster children and stretching to the origin * Fix SVG clipping masks landing in the wrong space when sibling layers differ in transform
This commit is contained in:
@@ -111,9 +111,27 @@ impl From<List<String>> for Graphic {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the list is a single leaf item carrying nothing to compose onto its contents, so flattening it
|
||||||
|
/// collapses no structure and rebuilding or snapshotting the result would be busywork.
|
||||||
|
pub fn is_lone_anonymous_leaf(content: &List<Graphic>) -> bool {
|
||||||
|
content.len() == 1
|
||||||
|
&& !matches!(content.element(0), Some(Graphic::Graphic(_)))
|
||||||
|
&& content.attribute::<DAffine2>(ATTR_TRANSFORM, 0).is_none()
|
||||||
|
&& content.attribute::<f64>(ATTR_OPACITY, 0).is_none()
|
||||||
|
&& content.attribute::<f64>(ATTR_OPACITY_FILL, 0).is_none()
|
||||||
|
&& content.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).is_none()
|
||||||
|
}
|
||||||
|
|
||||||
/// Deeply flattens a `List<Graphic>`, collecting only elements matching a specific variant (extracted by `extract_variant`)
|
/// Deeply flattens a `List<Graphic>`, collecting only elements matching a specific variant (extracted by `extract_variant`)
|
||||||
/// and discarding all other non-matching content. Recursion through `Graphic::Graphic` sub-`List`s composes transforms and opacity.
|
/// and discarding all other non-matching content. Recursion through `Graphic::Graphic` sub-`List`s composes transforms and opacity.
|
||||||
fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) -> List<T> {
|
fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) -> List<T> {
|
||||||
|
// Its list is already the flat answer, so hand it back rather than rebuilding it item by item
|
||||||
|
if is_lone_anonymous_leaf(&content) {
|
||||||
|
let Some(item) = content.into_iter().next() else { return List::new() };
|
||||||
|
|
||||||
|
return extract_variant(item.into_element()).unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
fn flatten_recursive<T>(output: &mut List<T>, current_graphic_list: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) {
|
fn flatten_recursive<T>(output: &mut List<T>, current_graphic_list: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) {
|
||||||
for current_graphic_item in current_graphic_list.into_iter() {
|
for current_graphic_item in current_graphic_list.into_iter() {
|
||||||
// Whether the parent carries each attribute: a structural fact (column presence), never a value comparison.
|
// Whether the parent carries each attribute: a structural fact (column presence), never a value comparison.
|
||||||
@@ -133,6 +151,12 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
|
|||||||
// Compose the parent's transform/opacity/fill onto each child, but only for attributes the parent carries.
|
// Compose the parent's transform/opacity/fill onto each child, but only for attributes the parent carries.
|
||||||
// A child lacking one is padded with the composition identity (`1.` for opacity/fill, identity for transform), so composing through it is a no-op.
|
// A child lacking one is padded with the composition identity (`1.` for opacity/fill, identity for transform), so composing through it is a no-op.
|
||||||
Graphic::Graphic(mut sub_list) => {
|
Graphic::Graphic(mut sub_list) => {
|
||||||
|
// A group's first child has no preceding sibling, so its clipping flag is inert until splicing
|
||||||
|
// hands it the group's own predecessor. Clear it (keeping the column) to stay clip-neutral.
|
||||||
|
if sub_list.attribute::<bool>(ATTR_CLIPPING_MASK, 0).is_some() {
|
||||||
|
sub_list.set_attribute(ATTR_CLIPPING_MASK, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
if parent_has_transform {
|
if parent_has_transform {
|
||||||
for v in sub_list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
|
for v in sub_list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
|
||||||
*v = current_transform * *v;
|
*v = current_transform * *v;
|
||||||
@@ -304,14 +328,9 @@ impl IntoGraphicList for List<Graphic> {
|
|||||||
|
|
||||||
impl IntoGraphicList for List<Vector> {
|
impl IntoGraphicList for List<Vector> {
|
||||||
fn into_graphic_list(self) -> List<Graphic> {
|
fn into_graphic_list(self) -> List<Graphic> {
|
||||||
// Propagate the `editor:layer_path` column (if present) from item 0 onto the wrapper Graphic item so a
|
// A synthetic container, not a real group layer, so it carries no `editor:layer_path` that would
|
||||||
// subsequent `flatten_graphic_list` doesn't drop the inner Vector's layer stamp
|
// overwrite the inner items' own stamps when flattened back out
|
||||||
let layer_path = self.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).cloned();
|
List::new_from_element(Graphic::Vector(self))
|
||||||
let mut graphic_list = List::new_from_element(Graphic::Vector(self));
|
|
||||||
if let Some(layer_path) = layer_path {
|
|
||||||
graphic_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
|
|
||||||
}
|
|
||||||
graphic_list
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,12 +360,7 @@ impl IntoGraphicList for List<Gradient> {
|
|||||||
|
|
||||||
impl IntoGraphicList for List<String> {
|
impl IntoGraphicList for List<String> {
|
||||||
fn into_graphic_list(self) -> List<Graphic> {
|
fn into_graphic_list(self) -> List<Graphic> {
|
||||||
let layer_path = self.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).cloned();
|
List::new_from_element(Graphic::Text(self))
|
||||||
let mut graphic_list = List::new_from_element(Graphic::Text(self));
|
|
||||||
if let Some(layer_path) = layer_path {
|
|
||||||
graphic_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
|
|
||||||
}
|
|
||||||
graphic_list
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,11 +648,52 @@ impl<T: Clone> OmitIndex for List<T> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use core_types::list::List;
|
use core_types::list::List;
|
||||||
|
use core_types::uuid::NodeId;
|
||||||
|
|
||||||
fn vector_graphic() -> Graphic {
|
fn vector_graphic() -> Graphic {
|
||||||
Graphic::Vector(List::new_from_element(Vector::default()))
|
Graphic::Vector(List::new_from_element(Vector::default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn vector_list_stamped_with_layers(layers: [u64; 2]) -> List<Vector> {
|
||||||
|
let mut list = List::new();
|
||||||
|
|
||||||
|
for layer in layers {
|
||||||
|
let mut item = Item::new_from_element(Vector::default());
|
||||||
|
item.set_attribute(ATTR_EDITOR_LAYER_PATH, NodeIdPath::from(vec![NodeId(layer)]));
|
||||||
|
list.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wrapper minted for a typed list is a container rather than a layer, so it must never claim a layer path
|
||||||
|
#[test]
|
||||||
|
fn wrapping_a_typed_list_leaves_the_wrapper_anonymous() {
|
||||||
|
let graphic_list = vector_list_stamped_with_layers([7, 9]).into_graphic_list();
|
||||||
|
|
||||||
|
assert_eq!(graphic_list.len(), 1);
|
||||||
|
assert!(!graphic_list.attribute_keys().any(|key| key == ATTR_EDITOR_LAYER_PATH));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-tripping through that wrapper must not collapse the items' distinct stamps onto item 0's
|
||||||
|
#[test]
|
||||||
|
fn round_trip_through_the_wrapper_preserves_per_item_layer_paths() {
|
||||||
|
let flattened: List<Vector> = vector_list_stamped_with_layers([7, 9]).into_flattened_list();
|
||||||
|
|
||||||
|
let layers = (0..flattened.len())
|
||||||
|
.map(|index| {
|
||||||
|
flattened
|
||||||
|
.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index)
|
||||||
|
.0
|
||||||
|
.iter_element_values()
|
||||||
|
.next_back()
|
||||||
|
.copied()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert_eq!(layers, [Some(NodeId(7)), Some(NodeId(9))]);
|
||||||
|
}
|
||||||
|
|
||||||
// Flattening must not invent attribute columns that neither the parent graphic nor the child carried
|
// Flattening must not invent attribute columns that neither the parent graphic nor the child carried
|
||||||
#[test]
|
#[test]
|
||||||
fn flatten_does_not_invent_attributes() {
|
fn flatten_does_not_invent_attributes() {
|
||||||
|
|||||||
@@ -940,12 +940,41 @@ impl Render for List<Graphic> {
|
|||||||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||||||
let element = self.element(index).unwrap();
|
let element = self.element(index).unwrap();
|
||||||
|
|
||||||
|
let matrix = format_transform_matrix(transform);
|
||||||
|
let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled();
|
||||||
|
let mut masked_by = None;
|
||||||
|
|
||||||
|
if next_clips && mask_state.is_none() {
|
||||||
|
let uuid = generate_uuid();
|
||||||
|
let mask_type = if element.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask };
|
||||||
|
|
||||||
|
let mut svg = SvgRender::new();
|
||||||
|
element.render_svg(&mut svg, &render_params.for_clipper());
|
||||||
|
|
||||||
|
// The def is resolved in this list's space, so the masker's own transform has to be baked into it
|
||||||
|
let masker = match matrix.is_empty() {
|
||||||
|
true => svg.svg.to_svg_string(),
|
||||||
|
false => format!(r##"<g transform="{matrix}">{}</g>"##, svg.svg.to_svg_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
render.svg_defs.push_str(&svg.svg_defs);
|
||||||
|
mask_type.write_to_defs(&mut render.svg_defs, uuid, masker);
|
||||||
|
|
||||||
|
mask_state = Some((uuid, mask_type));
|
||||||
|
} else if let Some((uuid, mask_type)) = mask_state {
|
||||||
|
if !next_clips {
|
||||||
|
mask_state = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let render_item = |render: &mut SvgRender| {
|
||||||
render.parent_tag(
|
render.parent_tag(
|
||||||
"g",
|
"g",
|
||||||
|attributes| {
|
|attributes| {
|
||||||
let matrix = format_transform_matrix(transform);
|
|
||||||
if !matrix.is_empty() {
|
if !matrix.is_empty() {
|
||||||
attributes.push(ATTR_TRANSFORM, matrix);
|
attributes.push(ATTR_TRANSFORM, matrix.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||||||
@@ -956,33 +985,16 @@ impl Render for List<Graphic> {
|
|||||||
if blend_mode != BlendMode::default() {
|
if blend_mode != BlendMode::default() {
|
||||||
attributes.push("style", blend_mode.render());
|
attributes.push("style", blend_mode.render());
|
||||||
}
|
}
|
||||||
|
|
||||||
let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled();
|
|
||||||
|
|
||||||
if next_clips && mask_state.is_none() {
|
|
||||||
let uuid = generate_uuid();
|
|
||||||
let mask_type = if element.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask };
|
|
||||||
mask_state = Some((uuid, mask_type));
|
|
||||||
let mut svg = SvgRender::new();
|
|
||||||
element.render_svg(&mut svg, &render_params.for_clipper());
|
|
||||||
|
|
||||||
write!(&mut attributes.0.svg_defs, r##"{}"##, svg.svg_defs).unwrap();
|
|
||||||
mask_type.write_to_defs(&mut attributes.0.svg_defs, uuid, svg.svg.to_svg_string());
|
|
||||||
} else if let Some((uuid, mask_type)) = mask_state {
|
|
||||||
if !next_clips {
|
|
||||||
mask_state = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = format!("mask-{uuid}");
|
|
||||||
let selector = format!("url(#{id})");
|
|
||||||
|
|
||||||
attributes.push(mask_type.to_attribute(), selector);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|render| {
|
|
||||||
element.render_svg(render, render_params);
|
|
||||||
},
|
},
|
||||||
|
|render| element.render_svg(render, render_params),
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The mask rides an untransformed wrapper so it resolves in this list's space rather than the item's own
|
||||||
|
match masked_by {
|
||||||
|
Some((attribute, selector)) => render.parent_tag("g", |attributes| attributes.push(attribute, selector), render_item),
|
||||||
|
None => render_item(render),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,14 +1168,12 @@ impl Render for List<Graphic> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for List<Vector> {
|
/// Emits one item of a `List<Vector>` as SVG, with no wrapping group of its own.
|
||||||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) {
|
||||||
for index in 0..self.len() {
|
let item_transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||||||
let Some(vector) = self.element(index) else { continue };
|
let blend_mode_attr: BlendMode = list.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
||||||
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
let opacity_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
||||||
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
|
let opacity_fill_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
||||||
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
|
|
||||||
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
|
|
||||||
|
|
||||||
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
|
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
|
||||||
let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
|
let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
|
||||||
@@ -1192,10 +1202,10 @@ impl Render for List<Vector> {
|
|||||||
MaskType::Mask
|
MaskType::Mask
|
||||||
};
|
};
|
||||||
|
|
||||||
let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL);
|
let fill_graphic_list = graphic_list_at(list, index, ATTR_FILL);
|
||||||
let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0));
|
let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0));
|
||||||
|
|
||||||
let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE);
|
let stroke_graphic_list = graphic_list_at(list, index, ATTR_STROKE);
|
||||||
let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0));
|
let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0));
|
||||||
|
|
||||||
let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed());
|
let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed());
|
||||||
@@ -1369,9 +1379,52 @@ impl Render for List<Vector> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Render for List<Vector> {
|
||||||
|
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||||||
|
let mut clip_mask_state: Option<(u64, MaskType)> = None;
|
||||||
|
|
||||||
|
for index in 0..self.len() {
|
||||||
|
let Some(vector) = self.element(index) else { continue };
|
||||||
|
|
||||||
|
// A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares
|
||||||
|
let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::<bool>(ATTR_CLIPPING_MASK, index + 1);
|
||||||
|
let mut masked_by = None;
|
||||||
|
|
||||||
|
if next_clips && clip_mask_state.is_none() {
|
||||||
|
let masker = Graphic::Vector(List::new_from_item(Item::from_parts(vector.clone(), self.clone_item_attributes(index))));
|
||||||
|
let mask_type = if masker.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask };
|
||||||
|
let uuid = generate_uuid();
|
||||||
|
|
||||||
|
let mut masker_svg = SvgRender::new();
|
||||||
|
masker.render_svg(&mut masker_svg, &render_params.for_clipper());
|
||||||
|
render.svg_defs.push_str(&masker_svg.svg_defs);
|
||||||
|
mask_type.write_to_defs(&mut render.svg_defs, uuid, masker_svg.svg.to_svg_string());
|
||||||
|
|
||||||
|
clip_mask_state = Some((uuid, mask_type));
|
||||||
|
} else if let Some((uuid, mask_type)) = clip_mask_state {
|
||||||
|
if !next_clips {
|
||||||
|
clip_mask_state = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item geometry is baked into the path data instead of a group transform, so mask coordinates line up
|
||||||
|
match masked_by {
|
||||||
|
Some((attribute, selector)) => render.parent_tag(
|
||||||
|
"g",
|
||||||
|
|attributes| attributes.push(attribute, selector),
|
||||||
|
|render| render_vector_item_svg(self, index, vector, render, render_params),
|
||||||
|
),
|
||||||
|
None => render_vector_item_svg(self, index, vector, render, render_params),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
|
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
|
||||||
|
let mut clip_masker: Option<List<Vector>> = None;
|
||||||
|
|
||||||
for index in 0..self.len() {
|
for index in 0..self.len() {
|
||||||
use graphic_types::vector_types::vector;
|
use graphic_types::vector_types::vector;
|
||||||
|
|
||||||
@@ -1423,23 +1476,53 @@ impl Render for List<Vector> {
|
|||||||
let can_draw_aligned_stroke =
|
let can_draw_aligned_stroke =
|
||||||
!stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed());
|
!stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed());
|
||||||
|
|
||||||
|
// A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares
|
||||||
|
let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::<bool>(ATTR_CLIPPING_MASK, index + 1);
|
||||||
|
|
||||||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||||||
if opacity < 1. || blend_mode_attr != BlendMode::default() {
|
let needs_blend_layer = opacity < 1. || blend_mode_attr != BlendMode::default();
|
||||||
layer = true;
|
|
||||||
|
// Shared by the blend and clipping layers below, so it is only worth deriving when one of them is pushed
|
||||||
|
let layer_geometry = (needs_blend_layer || clip_masker.is_some()).then(|| {
|
||||||
// `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`.
|
// `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`.
|
||||||
// Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform.
|
// Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform.
|
||||||
let (_, smallest_scale) = singular_values(applied_stroke_transform);
|
let (_, smallest_scale) = singular_values(applied_stroke_transform);
|
||||||
let stroke_inflation = stroke.map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke));
|
let stroke_inflation = stroke.map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke));
|
||||||
let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. };
|
let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. };
|
||||||
let quad = Quad::from_box(layer_bounds).inflate(inflate_amount);
|
let bounds = Quad::from_box(layer_bounds).inflate(inflate_amount).bounding_box();
|
||||||
let layer_bounds = quad.bounding_box();
|
|
||||||
|
(
|
||||||
|
kurbo::Affine::new(multiplied_transform.to_cols_array()),
|
||||||
|
kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if needs_blend_layer && let Some((layer_affine, layer_rect)) = layer_geometry {
|
||||||
|
layer = true;
|
||||||
|
scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), opacity, layer_affine, &layer_rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pushed inside the blend layer so the mask cuts this item's own paint rather than the composited result
|
||||||
|
let mut clip_layers = false;
|
||||||
|
if next_clips && clip_masker.is_none() {
|
||||||
|
clip_masker = Some(List::new_from_item(Item::from_parts(element.clone(), self.clone_item_attributes(index))));
|
||||||
|
} else if let Some(masker) = clip_masker.as_ref() {
|
||||||
|
if let Some((layer_affine, layer_rect)) = layer_geometry {
|
||||||
|
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect);
|
||||||
|
masker.render_to_vello(scene, parent_transform, context, &render_params.for_clipper());
|
||||||
scene.push_layer(
|
scene.push_layer(
|
||||||
peniko::Fill::NonZero,
|
peniko::Fill::NonZero,
|
||||||
peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver),
|
peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn),
|
||||||
opacity,
|
1.,
|
||||||
kurbo::Affine::new(multiplied_transform.to_cols_array()),
|
layer_affine,
|
||||||
&kurbo::Rect::new(layer_bounds[0].x, layer_bounds[0].y, layer_bounds[1].x, layer_bounds[1].y),
|
&layer_rect,
|
||||||
);
|
);
|
||||||
|
clip_layers = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !next_clips {
|
||||||
|
clip_masker = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let use_layer = can_draw_aligned_stroke;
|
let use_layer = can_draw_aligned_stroke;
|
||||||
@@ -1643,6 +1726,11 @@ impl Render for List<Vector> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if clip_layers {
|
||||||
|
scene.pop_layer();
|
||||||
|
scene.pop_layer();
|
||||||
|
}
|
||||||
|
|
||||||
// If we pushed a layer for opacity or a blend mode, we need to pop it
|
// If we pushed a layer for opacity or a blend mode, we need to pop it
|
||||||
if layer {
|
if layer {
|
||||||
scene.pop_layer();
|
scene.pop_layer();
|
||||||
@@ -1652,17 +1740,8 @@ impl Render for List<Vector> {
|
|||||||
|
|
||||||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
|
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
|
||||||
// Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph.
|
// Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph.
|
||||||
// Targets are baked relative to item 0's transform since `Graphic::collect_metadata` records that as `local_transforms[element_id]`.
|
// Targets are baked relative to the first item carrying each element_id, since that is the transform recorded as its `local_transforms` entry.
|
||||||
let item_zero_transform: DAffine2 = if !self.is_empty() {
|
let mut reference_transforms: HashMap<NodeId, DAffine2> = HashMap::new();
|
||||||
self.attribute_cloned_or_default(ATTR_TRANSFORM, 0)
|
|
||||||
} else {
|
|
||||||
DAffine2::IDENTITY
|
|
||||||
};
|
|
||||||
let item_zero_inverse = if transform_is_invertible(item_zero_transform) {
|
|
||||||
item_zero_transform.inverse()
|
|
||||||
} else {
|
|
||||||
DAffine2::IDENTITY
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut accumulated_click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
|
let mut accumulated_click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
|
||||||
let mut accumulated_outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
|
let mut accumulated_outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
|
||||||
@@ -1674,18 +1753,17 @@ impl Render for List<Vector> {
|
|||||||
let layer = layer_path.iter_element_values().next_back().copied();
|
let layer = layer_path.iter_element_values().next_back().copied();
|
||||||
|
|
||||||
if let Some(element_id) = caller_element_id.or(layer) {
|
if let Some(element_id) = caller_element_id.or(layer) {
|
||||||
// When recovering element_id from the item's editor:layer_path tag (because the caller
|
let reference_transform = *reference_transforms.entry(element_id).or_insert(transform);
|
||||||
// passed None), also store the transform metadata that Graphic::collect_metadata
|
let reference_inverse = if transform_is_invertible(reference_transform) {
|
||||||
// normally provides but skipped due to the None element_id.
|
reference_transform.inverse()
|
||||||
if caller_element_id.is_none() {
|
} else {
|
||||||
metadata.upstream_footprints.entry(element_id).or_insert(footprint);
|
DAffine2::IDENTITY
|
||||||
metadata.local_transforms.entry(element_id).or_insert(item_zero_transform);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes)
|
// Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes)
|
||||||
let click_target_vector = self.attribute::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source);
|
let click_target_vector = self.attribute::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source);
|
||||||
|
|
||||||
let item_relative_transform = item_zero_inverse * transform;
|
let item_relative_transform = reference_inverse * transform;
|
||||||
|
|
||||||
let mut click_targets_unwrapped = Vec::new();
|
let mut click_targets_unwrapped = Vec::new();
|
||||||
extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform);
|
extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform);
|
||||||
@@ -1735,6 +1813,15 @@ impl Render for List<Vector> {
|
|||||||
for (element_id, targets) in accumulated_outlines {
|
for (element_id, targets) in accumulated_outlines {
|
||||||
metadata.outlines.insert(element_id, targets);
|
metadata.outlines.insert(element_id, targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recovering element_id from `editor:layer_path` means `Graphic::collect_metadata` skipped this transform metadata.
|
||||||
|
// It lands after the snapshot recursion above so each element keeps the pair its targets were baked against.
|
||||||
|
if caller_element_id.is_none() {
|
||||||
|
for (element_id, reference_transform) in reference_transforms {
|
||||||
|
metadata.upstream_footprints.insert(element_id, footprint);
|
||||||
|
metadata.local_transforms.insert(element_id, reference_transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||||||
@@ -1986,10 +2073,16 @@ impl Render for List<Raster<CPU>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
for index in 0..self.len() {
|
||||||
|
// The unit square is the raster's own space, so its placement only exists in the item transform
|
||||||
|
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||||||
|
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
||||||
|
subpath.apply_transform(transform);
|
||||||
|
|
||||||
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
|
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static LAZY_ARC_VEC_ZERO_U8: LazyLock<Arc<Vec<u8>>> = LazyLock::new(|| Arc::new(Vec::new()));
|
static LAZY_ARC_VEC_ZERO_U8: LazyLock<Arc<Vec<u8>>> = LazyLock::new(|| Arc::new(Vec::new()));
|
||||||
|
|
||||||
@@ -2081,10 +2174,15 @@ impl Render for List<Raster<GPU>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
|
||||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
for index in 0..self.len() {
|
||||||
|
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||||||
|
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
||||||
|
subpath.apply_transform(transform);
|
||||||
|
|
||||||
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
|
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Since colors and gradients are technically infinitely big, we have to implement
|
// Since colors and gradients are technically infinitely big, we have to implement
|
||||||
// workarounds for rendering them correctly in a way which still allows us
|
// workarounds for rendering them correctly in a way which still allows us
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath};
|
|||||||
use core_types::registry::types::{Angle, SeedValue, SignedInteger};
|
use core_types::registry::types::{Angle, SeedValue, SignedInteger};
|
||||||
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM, AnyHash, BlendMode, CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl};
|
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM, AnyHash, BlendMode, CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl};
|
||||||
use glam::{DAffine2, DVec2};
|
use glam::{DAffine2, DVec2};
|
||||||
use graphic_types::graphic::{Graphic, IntoGraphicList};
|
use graphic_types::graphic::{Graphic, IntoGraphicList, is_lone_anonymous_leaf};
|
||||||
use graphic_types::{Artboard, Vector};
|
use graphic_types::{Artboard, Vector};
|
||||||
use rand::SeedableRng;
|
use rand::SeedableRng;
|
||||||
use rand::seq::SliceRandom;
|
use rand::seq::SliceRandom;
|
||||||
@@ -1020,7 +1020,7 @@ pub async fn flatten_vector<T: IntoGraphicList>(_: impl Ctx, #[implementations(L
|
|||||||
// TODO: The cleaner fix is to drive each layer's metadata from its own Monitor's captured `(Context, List<Graphic>)`,
|
// TODO: The cleaner fix is to drive each layer's metadata from its own Monitor's captured `(Context, List<Graphic>)`,
|
||||||
// TODO: at which point this attribute (and the equivalents in Boolean Operation, Solidify Stroke, Combine Paths,
|
// TODO: at which point this attribute (and the equivalents in Boolean Operation, Solidify Stroke, Combine Paths,
|
||||||
// TODO: Morph, Rasterize) become unnecessary.
|
// TODO: Morph, Rasterize) become unnecessary.
|
||||||
if !output.is_empty() {
|
if !output.is_empty() && !is_lone_anonymous_leaf(&graphic_list) {
|
||||||
// Item 0 carries a composed transform inherited from the flattened input, but the merged_layers
|
// Item 0 carries a composed transform inherited from the flattened input, but the merged_layers
|
||||||
// already holds the original transforms; pre-compensate by item 0's inverse so the renderer's
|
// already holds the original transforms; pre-compensate by item 0's inverse so the renderer's
|
||||||
// `upstream_footprint *= item_0_transform` recursion cancels out and leaves the originals intact.
|
// `upstream_footprint *= item_0_transform` recursion cancels out and leaves the originals intact.
|
||||||
|
|||||||
Reference in New Issue
Block a user