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:
Keavon Chambers
2026-09-15 14:40:35 +02:00
committed by Dennis Kobert
parent ae1ff71f45
commit 1c88592fa4
4 changed files with 712 additions and 525 deletions
@@ -9,7 +9,7 @@ pub(crate) use legacy::run_to_legacy_list;
pub use legacy::{group_to_legacy_graphic, group_to_legacy_list, map_groups_to_legacy, map_paint_attrs_to_legacy, run_to_list}; pub use legacy::{group_to_legacy_graphic, group_to_legacy_list, map_groups_to_legacy, map_paint_attrs_to_legacy, run_to_list};
pub use paint::{ pub use paint::{
LanePaint, PaintColumns, PaintOverlay, PaintOverlayColumn, PaintReach, bake_paint_transforms, has_paint, is_paint_present, paint_graphics, set_paint_attribute, set_paint_attribute_at, LanePaint, PaintColumns, PaintOverlay, PaintOverlayColumn, PaintReach, bake_paint_transforms, has_paint, is_paint_present, paint_graphics, set_paint_attribute, set_paint_attribute_at,
vector_can_reduce_to_clip_path, vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path,
}; };
pub use walk::{GraphicLevel, GraphicLevelColumn, RowStep, VectorRow, direct_vector_len, flatten_vector_rows, group_is_empty, lane_attributes, run_lane_attributes, walk_vector_rows}; pub use walk::{GraphicLevel, GraphicLevelColumn, RowStep, VectorRow, direct_vector_len, flatten_vector_rows, group_is_empty, lane_attributes, run_lane_attributes, walk_vector_rows};
use walk::{group_all_clipped, group_bounding_box, group_is_fully_transparent, group_is_opaque, group_render_complexity}; use walk::{group_all_clipped, group_bounding_box, group_is_fully_transparent, group_is_opaque, group_render_complexity};
@@ -157,9 +157,28 @@ impl From<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.
/// Both group forms are excluded: a lone native `Group` still has interior structure to flatten.
pub fn is_lone_anonymous_leaf(content: &List<Graphic>) -> bool {
content.len() == 1
&& !matches!(content.element(0), Some(Graphic::Graphic(_)) | Some(Graphic::Group(_)))
&& 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::<Vec<NodeId>>(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>>, parent_layer_path: Option<&[NodeId]>) { fn flatten_recursive<T>(output: &mut List<T>, current_graphic_list: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>, parent_layer_path: Option<&[NodeId]>) {
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.
@@ -179,6 +198,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;
@@ -573,6 +598,45 @@ mod tests {
use super::*; use super::*;
use core_types::list::List; use core_types::list::List;
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, vec![NodeId(layer)]);
list.push(item);
}
list
}
fn last_layer_stamps(list: &List<Vector>) -> Vec<Option<NodeId>> {
(0..list.len())
.map(|index| list.attribute_cloned_or_default::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, index).last().copied())
.collect()
}
// Our coercion de-tables: each item becomes its own graphic lane keeping its own stamp, rather than master's
// single wrapper item that had to be kept anonymous so it could not overwrite the inner items' stamps
#[test]
fn wrapping_a_typed_list_keeps_one_lane_per_item() {
let graphic_list = vector_list_stamped_with_layers([7, 9]).into_graphic_list();
assert_eq!(graphic_list.len(), 2, "the coercion must not collapse the items into one wrapper lane");
let layers = (0..graphic_list.len())
.map(|index| graphic_list.attribute_cloned_or_default::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, index).last().copied())
.collect::<Vec<_>>();
assert_eq!(layers, [Some(NodeId(7)), Some(NodeId(9))]);
}
// Round-tripping through the graphic form 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();
assert_eq!(last_layer_stamps(&flattened), [Some(NodeId(7)), Some(NodeId(9))]);
}
fn vector_graphic() -> Graphic<'static> { fn vector_graphic() -> Graphic<'static> {
Graphic::Vector(Vector::default()) Graphic::Vector(Vector::default())
} }
@@ -37,10 +37,9 @@ where
paint_graphics::<A, S>(source, index).is_some() paint_graphics::<A, S>(source, index).is_some()
} }
/// Whether every lane of a vector source draws as a plain clip path: fully /// Whether one lane of a vector source draws as a plain clip path: fully
/// opaque, fill absent or opaque, stroke invisible or fully transparent. /// opaque, fill absent or opaque, stroke invisible or fully transparent.
pub fn vector_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S) -> bool { pub fn vector_lane_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S, index: usize) -> bool {
(0..source.lane_count()).all(|index| {
let Some(element) = source.element(index) else { return false }; let Some(element) = source.element(index) else { return false };
let opacity: f64 = source.attr::<Opacity>(index); let opacity: f64 = source.attr::<Opacity>(index);
@@ -50,7 +49,11 @@ pub fn vector_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &
|| paint_graphics::<Stroke, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent())); || paint_graphics::<Stroke, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent
}) }
/// Whether every lane of a vector source draws as a plain clip path.
pub fn vector_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S) -> bool {
(0..source.lane_count()).all(|index| vector_lane_can_reduce_to_clip_path(source, index))
} }
/// The paint a lane carries for its interiors, in the reference form /// The paint a lane carries for its interiors, in the reference form
+179 -60
View File
@@ -23,7 +23,9 @@ use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2}; use glam::{DAffine2, DMat2, DVec2};
use graphene_hash::CacheHashWrapper; use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource; use graphene_resource::Resource;
use graphic_types::graphic::{PaintColumns, PaintOverlay, PaintReach, has_paint, is_paint_present, paint_graphics, set_paint_attribute, vector_can_reduce_to_clip_path}; use graphic_types::graphic::{
PaintColumns, PaintOverlay, PaintReach, has_paint, is_paint_present, paint_graphics, set_paint_attribute, vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path,
};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke}; use graphic_types::markers::{EditorMergedLayers, Fill, Stroke};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
use graphic_types::vector_types::gradient::{Gradient, GradientForm, GradientSettings}; use graphic_types::vector_types::gradient::{Gradient, GradientForm, GradientSettings};
@@ -1103,12 +1105,41 @@ fn render_graphic_svg_with<'a, 'e, S: LaneSource<Element = Graphic<'e>>>(source:
let element = source.element(index).unwrap(); let element = source.element(index).unwrap();
let reach = inherited.for_lane(&paint_columns, index); let reach = inherited.for_lane(&paint_columns, index);
let matrix = format_transform_matrix(transform);
let next_clips = index + 1 < source.lane_count() && source.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(element, reach) { MaskType::Clip } else { MaskType::Mask };
let mut svg = SvgRender::new();
render_element_svg(element, reach, &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;
@@ -1119,33 +1150,16 @@ fn render_graphic_svg_with<'a, 'e, S: LaneSource<Element = Graphic<'e>>>(source:
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 < source.lane_count() && source.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(element, reach) { MaskType::Clip } else { MaskType::Mask };
mask_state = Some((uuid, mask_type));
let mut svg = SvgRender::new();
render_element_svg(element, reach, &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| {
render_element_svg(element, reach, render, render_params);
}, },
|render| render_element_svg(element, reach, 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),
}
} }
} }
@@ -1382,9 +1396,8 @@ impl Render for List<Graphic<'_>> {
} }
} }
fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut SvgRender, render_params: &RenderParams) { /// Emits one lane of a vector source as SVG, with no wrapping group of its own.
for index in 0..source.lane_count() { fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: usize, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) {
let Some(vector) = source.element(index) else { continue };
let item_transform: DAffine2 = source.attr::<Transform>(index); let item_transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index); let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
let opacity_attr: f64 = source.attr::<Opacity>(index); let opacity_attr: f64 = source.attr::<Opacity>(index);
@@ -1592,13 +1605,62 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut S
); );
} }
} }
fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
let mut clip_mask_state: Option<(u64, MaskType)> = None;
for index in 0..source.lane_count() {
let Some(vector) = source.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 < source.lane_count() && source.attr::<ClippingMask>(index + 1);
let mut masked_by = None;
if next_clips && clip_mask_state.is_none() {
let mask_type = if vector_lane_can_reduce_to_clip_path(source, index) { MaskType::Clip } else { MaskType::Mask };
let uuid = generate_uuid();
let mut masker_svg = SvgRender::new();
render_vector_item_svg(source, index, vector, &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;
} }
fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})")));
for index in 0..source.lane_count() { }
// 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(source, index, vector, render, render_params),
),
None => render_vector_item_svg(source, index, vector, render, render_params),
}
}
}
/// Emits one lane of a vector source to Vello. `clip_masker` names the lane whose
/// paint masks this one; its layers are pushed inside this item's blend layer so the
/// mask cuts this item's own paint rather than the composited result.
fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
source: &S,
index: usize,
scene: &mut Scene,
parent_transform: DAffine2,
context: &mut RenderContext,
render_params: &RenderParams,
clip_masker: Option<usize>,
) {
use graphic_types::vector_types::vector; use graphic_types::vector_types::vector;
let Some(element) = source.element(index) else { continue }; let Some(element) = source.element(index) else { return };
let item_transform: DAffine2 = source.attr::<Transform>(index); let item_transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index); let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
let opacity_attr: f64 = source.attr::<Opacity>(index); let opacity_attr: f64 = source.attr::<Opacity>(index);
@@ -1646,22 +1708,43 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
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()); 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());
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 let Some(masker_index) = clip_masker
&& let Some((layer_affine, layer_rect)) = layer_geometry
{
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect);
render_vector_item_vello(source, masker_index, scene, parent_transform, context, &render_params.for_clipper(), None);
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;
} }
let use_layer = can_draw_aligned_stroke; let use_layer = can_draw_aligned_stroke;
@@ -1862,22 +1945,39 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
} }
} }
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();
} }
} }
fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
let mut clip_masker: Option<usize> = None;
for index in 0..source.lane_count() {
// A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares
let next_clips = index + 1 < source.lane_count() && source.attr::<ClippingMask>(index + 1);
let becomes_masker = next_clips && clip_masker.is_none();
render_vector_item_vello(source, index, scene, parent_transform, context, render_params, if becomes_masker { None } else { clip_masker });
if becomes_masker {
clip_masker = Some(index);
} else if !next_clips {
clip_masker = None;
}
}
} }
fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) { fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, 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 source.lane_count() > 0 { source.attr::<Transform>(0) } else { DAffine2::IDENTITY }; let mut reference_transforms: HashMap<NodeId, DAffine2> = HashMap::new();
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();
@@ -1889,18 +1989,17 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
let layer = layer_path.last().copied(); let layer = layer_path.last().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 = source.attr::<EditorClickTarget>(index).unwrap_or(element); let click_target_vector = source.attr::<EditorClickTarget>(index).unwrap_or(element);
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, source, index, click_target_vector, item_relative_transform); extend_targets_from_vector(&mut click_targets_unwrapped, source, index, click_target_vector, item_relative_transform);
@@ -1949,6 +2048,15 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
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_vector_upstream_click_targets<S: LaneSource<Element = Vector>>(source: &S, click_targets: &mut Vec<ClickTarget>) { fn add_vector_upstream_click_targets<S: LaneSource<Element = Vector>>(source: &S, click_targets: &mut Vec<ClickTarget>) {
@@ -2218,6 +2326,17 @@ fn add_raster_upstream_click_targets(click_targets: &mut Vec<ClickTarget>) {
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
} }
fn add_raster_lane_upstream_click_targets<S: LaneSource>(source: &S, click_targets: &mut Vec<ClickTarget>) {
for index in 0..source.lane_count() {
// The unit square is the raster's own space, so its placement only exists in the item transform
let transform: DAffine2 = source.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.));
}
}
impl Render for List<Raster<CPU>> { impl Render for List<Raster<CPU>> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_raster_cpu_svg(self, render, render_params) render_raster_cpu_svg(self, render, render_params)
@@ -2232,7 +2351,7 @@ 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>) {
add_raster_upstream_click_targets(click_targets) add_raster_lane_upstream_click_targets(self, click_targets)
} }
} }
@@ -2310,7 +2429,7 @@ 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>) {
add_raster_upstream_click_targets(click_targets) add_raster_lane_upstream_click_targets(self, click_targets)
} }
} }
@@ -3084,7 +3203,7 @@ impl Render for RunView<'_, Raster<CPU>> {
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
add_raster_upstream_click_targets(click_targets) add_raster_lane_upstream_click_targets(self, click_targets)
} }
} }
+3 -2
View File
@@ -11,7 +11,7 @@ use core_types::registry::types::Angle;
use core_types::uuid::NodeId; use core_types::uuid::NodeId;
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color, Ctx, ExtractIndex, InjectIndex}; use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color, Ctx, ExtractIndex, InjectIndex};
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use graphic_types::graphic::{Graphic, GraphicLevel, RowStep, TryFromGraphic, walk_vector_rows}; use graphic_types::graphic::{Graphic, GraphicLevel, RowStep, TryFromGraphic, is_lone_anonymous_leaf, walk_vector_rows};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr}; use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector}; use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector};
use raster_types::{CPU, GPU, Raster}; use raster_types::{CPU, GPU, Raster};
@@ -488,7 +488,8 @@ pub fn flatten_vector<'e>(
let Some((row, top)) = locate_vector_row(GraphicLevel::Run(&item), lane) else { let Some((row, top)) = locate_vector_row(GraphicLevel::Run(&item), lane) else {
return Err(GraphError::past_end().into()); return Err(GraphError::past_end().into());
}; };
let snapshot = (lane == 0).then(|| legacy_render_list_of(content)); // A lone anonymous leaf flattens to itself, so there is no erased structure to snapshot
let snapshot = (lane == 0).then(|| legacy_render_list_of(content)).filter(|list| !is_lone_anonymous_leaf(list));
emit_vector_row(ctx.arena(), content.lane(top), row, snapshot) emit_vector_row(ctx.arena(), content.lane(top), row, snapshot)
} }