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-08-11 13:31:48 -07:00
committed by Dennis Kobert
parent d9271a66b2
commit 2badd926c4
4 changed files with 712 additions and 525 deletions

View File

@@ -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 paint::{
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};
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`)
/// 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> {
// 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]>) {
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.
@@ -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.
// 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) => {
// 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 {
for v in sub_list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
*v = current_transform * *v;
@@ -573,6 +598,45 @@ mod tests {
use super::*;
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> {
Graphic::Vector(Vector::default())
}

View File

@@ -37,20 +37,23 @@ where
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.
pub fn vector_lane_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S, index: usize) -> bool {
let Some(element) = source.element(index) else { return false };
let opacity: f64 = source.attr::<Opacity>(index);
let fill_opaque_or_absent = paint_graphics::<Fill, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()));
let stroke_invisible_or_transparent = element.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| 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
}
/// 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| {
let Some(element) = source.element(index) else { return false };
let opacity: f64 = source.attr::<Opacity>(index);
let fill_opaque_or_absent = paint_graphics::<Fill, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()));
let stroke_invisible_or_transparent = element.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| 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
})
(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

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ use core_types::registry::types::Angle;
use core_types::uuid::NodeId;
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color, Ctx, ExtractIndex, InjectIndex};
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::{ATTR_FILL, ATTR_STROKE, Vector};
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 {
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)
}