From 9a129ecf5c4652d468f03c02ba20543a5c0e3ec0 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 11 Aug 2026 13:31:48 -0700 Subject: [PATCH] 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 --- .../libraries/graphic-types/src/graphic.rs | 83 ++- .../libraries/rendering/src/renderer.rs | 628 ++++++++++-------- node-graph/nodes/graphic/src/graphic.rs | 4 +- 3 files changed, 434 insertions(+), 281 deletions(-) diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 13f1fe7740..30a06ff794 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -111,9 +111,27 @@ impl From> 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) -> bool { + content.len() == 1 + && !matches!(content.element(0), Some(Graphic::Graphic(_))) + && content.attribute::(ATTR_TRANSFORM, 0).is_none() + && content.attribute::(ATTR_OPACITY, 0).is_none() + && content.attribute::(ATTR_OPACITY_FILL, 0).is_none() + && content.attribute::(ATTR_EDITOR_LAYER_PATH, 0).is_none() +} + /// Deeply flattens a `List`, 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(content: List, extract_variant: fn(Graphic) -> Option>) -> List { + // 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(output: &mut List, current_graphic_list: List, extract_variant: fn(Graphic) -> Option>) { 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. @@ -133,6 +151,12 @@ fn flatten_graphic_list(content: List, 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::(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::(ATTR_TRANSFORM) { *v = current_transform * *v; @@ -304,14 +328,9 @@ impl IntoGraphicList for List { impl IntoGraphicList for List { fn into_graphic_list(self) -> List { - // Propagate the `editor:layer_path` column (if present) from item 0 onto the wrapper Graphic item so a - // subsequent `flatten_graphic_list` doesn't drop the inner Vector's layer stamp - let layer_path = self.attribute::(ATTR_EDITOR_LAYER_PATH, 0).cloned(); - 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 + // A synthetic container, not a real group layer, so it carries no `editor:layer_path` that would + // overwrite the inner items' own stamps when flattened back out + List::new_from_element(Graphic::Vector(self)) } } @@ -341,12 +360,7 @@ impl IntoGraphicList for List { impl IntoGraphicList for List { fn into_graphic_list(self) -> List { - let layer_path = self.attribute::(ATTR_EDITOR_LAYER_PATH, 0).cloned(); - 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 + List::new_from_element(Graphic::Text(self)) } } @@ -634,11 +648,52 @@ impl OmitIndex for List { mod tests { use super::*; use core_types::list::List; + use core_types::uuid::NodeId; fn vector_graphic() -> Graphic { Graphic::Vector(List::new_from_element(Vector::default())) } + fn vector_list_stamped_with_layers(layers: [u64; 2]) -> List { + 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_list_stamped_with_layers([7, 9]).into_flattened_list(); + + let layers = (0..flattened.len()) + .map(|index| { + flattened + .attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, index) + .0 + .iter_element_values() + .next_back() + .copied() + }) + .collect::>(); + + assert_eq!(layers, [Some(NodeId(7)), Some(NodeId(9))]); + } + // Flattening must not invent attribute columns that neither the parent graphic nor the child carried #[test] fn flatten_does_not_invent_attributes() { diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a5aade97ac..274b8cb648 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -940,49 +940,61 @@ impl Render for List { let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let element = self.element(index).unwrap(); - render.parent_tag( - "g", - |attributes| { - let matrix = format_transform_matrix(transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } + 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; - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } + 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 }; - if blend_mode != BlendMode::default() { - attributes.push("style", blend_mode.render()); - } + let mut svg = SvgRender::new(); + element.render_svg(&mut svg, &render_params.for_clipper()); - let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled(); + // 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##"{}"##, svg.svg.to_svg_string()), + }; - 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()); + render.svg_defs.push_str(&svg.svg_defs); + mask_type.write_to_defs(&mut render.svg_defs, uuid, masker); - 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; + 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( + "g", + |attributes| { + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix.clone()); } - let id = format!("mask-{uuid}"); - let selector = format!("url(#{id})"); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } - attributes.push(mask_type.to_attribute(), selector); - } - }, - |render| { - element.render_svg(render, render_params); - }, - ); + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + }, + |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,222 +1168,263 @@ impl Render for List { } } -impl Render for List { - fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - for index in 0..self.len() { - let Some(vector) = self.element(index) else { continue }; - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); - 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.); +/// Emits one item of a `List` as SVG, with no wrapping group of its own. +fn render_vector_item_svg(list: &List, index: usize, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) { + let item_transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let blend_mode_attr: BlendMode = list.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let opacity_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY, index, 1.); + let opacity_fill_attr: f64 = list.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 - let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.); - let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform)); - let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform); - let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); - let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse()); - let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); - let layer_bounds = vector.bounding_box().unwrap_or_default(); - let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default(); - let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds); + // 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 set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform)); + let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform); + let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); + let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse()); + let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); + let layer_bounds = vector.bounding_box().unwrap_or_default(); + let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default(); + let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds); - let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]); - let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]); + let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]); + let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]); - let mut path = String::new(); + let mut path = String::new(); - for mut bezpath in vector.stroke_bezpath_iter() { - bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - path.push_str(bezpath.to_svg().as_str()); + for mut bezpath in vector.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + path.push_str(bezpath.to_svg().as_str()); + } + + let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) { + MaskType::Clip + } else { + MaskType::Mask + }; + + 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 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 path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); + let can_draw_aligned_stroke = path_is_closed + && vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) + && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()); + let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip); + + let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; + let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); + let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; + let use_face_fill = vector.use_face_fill(); + + if needs_separate_alignment_fill && !wants_stroke_below { + emit_svg_fill_path( + render, + path.clone(), + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + render_params, + ); + } + + let push_id = needs_separate_alignment_fill.then_some({ + let id = format!("alignment-{}", generate_uuid()); + + let mut cloned_vector = vector.clone(); + cloned_vector.stroke = None; + + // The mask must draw at full alpha so the SVG ``/`` fully zeroes the path interior. + // The wrapping SVG group (above) handles the user-set opacity. + let mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform); + set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK)); + let vector_item = List::new_from_item(mask_item); + + (id, mask_type, vector_item) + }); + + if use_face_fill { + for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) { + face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + let face_d = face_path.to_svg(); + + emit_svg_fill_path( + render, + face_d, + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + render_params, + ); + } + } + + render.leaf_tag("path", |attributes| { + attributes.push("d", path.clone()); + let matrix = format_transform_matrix(element_transform); + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix); + } + + let defs = &mut attributes.0.svg_defs; + if let Some((ref id, mask_type, ref vector_item)) = push_id { + let mut svg = SvgRender::new(); + vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); + let stroke = vector.stroke.as_ref().unwrap(); + // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed` + let (largest_scale, _) = singular_values(applied_stroke_transform); + let inflation = stroke.max_aabb_inflation(true) * largest_scale; + let quad = Quad::from_box(transformed_bounds).inflate(inflation); + let (x, y) = quad.top_left().into(); + let (width, height) = (quad.bottom_right() - quad.top_left()).into(); + + write!(defs, r##"{}"##, svg.svg_defs).unwrap(); + let rect = format!(r##""##); + + match mask_type { + MaskType::Clip => write!(defs, r##"{}"##, svg.svg.to_svg_string()).unwrap(), + MaskType::Mask => write!( + defs, + r##"{}{}"##, + rect, + svg.svg.to_svg_string() + ) + .unwrap(), } + } - let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) { - MaskType::Clip - } else { - MaskType::Mask - }; + let mut render_params = render_params.clone(); + render_params.aligned_strokes = can_draw_aligned_stroke; + render_params.override_paint_order = override_paint_order; - let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL); - 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 = stroke_graphic_list.as_ref().and_then(|l| l.element(0)); - - let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); - let can_draw_aligned_stroke = path_is_closed - && vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) - && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()); - let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip); - - let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; - let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); - let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; - let use_face_fill = vector.use_face_fill(); - - if needs_separate_alignment_fill && !wants_stroke_below { - emit_svg_fill_path( - render, - path.clone(), - fill_graphic_list.as_deref(), - item_transform, - element_transform, - applied_stroke_transform, - bounds_matrix, - render_params, - ); - } - - let push_id = needs_separate_alignment_fill.then_some({ - let id = format!("alignment-{}", generate_uuid()); - - let mut cloned_vector = vector.clone(); - cloned_vector.stroke = None; - - // The mask must draw at full alpha so the SVG ``/`` fully zeroes the path interior. - // The wrapping SVG group (above) handles the user-set opacity. - let mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform); - set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK)); - let vector_item = List::new_from_item(mask_item); - - (id, mask_type, vector_item) - }); - - if use_face_fill { - for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) { - face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - let face_d = face_path.to_svg(); - - emit_svg_fill_path( - render, - face_d, - fill_graphic_list.as_deref(), - item_transform, - element_transform, - applied_stroke_transform, - bounds_matrix, - render_params, - ); - } - } - - render.leaf_tag("path", |attributes| { - attributes.push("d", path.clone()); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - - let defs = &mut attributes.0.svg_defs; - if let Some((ref id, mask_type, ref vector_item)) = push_id { - let mut svg = SvgRender::new(); - vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); - let stroke = vector.stroke.as_ref().unwrap(); - // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed` - let (largest_scale, _) = singular_values(applied_stroke_transform); - let inflation = stroke.max_aabb_inflation(true) * largest_scale; - let quad = Quad::from_box(transformed_bounds).inflate(inflation); - let (x, y) = quad.top_left().into(); - let (width, height) = (quad.bottom_right() - quad.top_left()).into(); - - write!(defs, r##"{}"##, svg.svg_defs).unwrap(); - let rect = format!(r##""##); - - match mask_type { - MaskType::Clip => write!(defs, r##"{}"##, svg.svg.to_svg_string()).unwrap(), - MaskType::Mask => write!( - defs, - r##"{}{}"##, - rect, - svg.svg.to_svg_string() - ) - .unwrap(), - } - } - - let mut render_params = render_params.clone(); - render_params.aligned_strokes = can_draw_aligned_stroke; - render_params.override_paint_order = override_paint_order; - - let stroke_shape_attribute = vector - .stroke - .as_ref() - .map(|stroke| { - if stroke_graphic_list.as_deref().is_some_and(is_paint_present) { - stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke) - } else { - String::new() - } - }) - .unwrap_or_default(); - - // Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback - let stroke_visible = vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent()); - let stroke_attribute = if stroke_visible { - stroke_graphic_list - .as_deref() - .map(|list| { - // Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke. - // Only clipping-based paints need the stroke-inclusive bbox. - let paint_bounds = match list.element(0) { - Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) => bounds_matrix, - _ => stroke_bounds_matrix, - }; - list.render(defs, item_transform, element_transform, applied_stroke_transform, paint_bounds, &render_params, PaintTarget::Stroke) - }) - .unwrap_or_else(|| r#" stroke="none""#.to_string()) + let stroke_shape_attribute = vector + .stroke + .as_ref() + .map(|stroke| { + if stroke_graphic_list.as_deref().is_some_and(is_paint_present) { + stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke) } else { String::new() - }; - - let fill_attribute = if needs_separate_alignment_fill || use_face_fill { - r#" fill="none""#.to_string() - } else { - fill_graphic_list - .as_deref() - .map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill)) - .unwrap_or_else(|| r#" fill="none""#.to_string()) - }; - - if let Some((id, mask_type, _)) = push_id { - let selector = format!("url(#{id})"); - attributes.push(mask_type.to_attribute(), selector); } - attributes.push_val(fill_attribute); - attributes.push_val(stroke_shape_attribute); - attributes.push_val(stroke_attribute); + }) + .unwrap_or_default(); - if vector.is_branching() && !use_face_fill { - attributes.push("fill-rule", "evenodd"); + // Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback + let stroke_visible = vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent()); + let stroke_attribute = if stroke_visible { + stroke_graphic_list + .as_deref() + .map(|list| { + // Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke. + // Only clipping-based paints need the stroke-inclusive bbox. + let paint_bounds = match list.element(0) { + Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) => bounds_matrix, + _ => stroke_bounds_matrix, + }; + list.render(defs, item_transform, element_transform, applied_stroke_transform, paint_bounds, &render_params, PaintTarget::Stroke) + }) + .unwrap_or_else(|| r#" stroke="none""#.to_string()) + } else { + String::new() + }; + + let fill_attribute = if needs_separate_alignment_fill || use_face_fill { + r#" fill="none""#.to_string() + } else { + fill_graphic_list + .as_deref() + .map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill)) + .unwrap_or_else(|| r#" fill="none""#.to_string()) + }; + + if let Some((id, mask_type, _)) = push_id { + let selector = format!("url(#{id})"); + attributes.push(mask_type.to_attribute(), selector); + } + attributes.push_val(fill_attribute); + attributes.push_val(stroke_shape_attribute); + attributes.push_val(stroke_attribute); + + if vector.is_branching() && !use_face_fill { + attributes.push("fill-rule", "evenodd"); + } + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if blend_mode_attr != BlendMode::default() { + attributes.push("style", blend_mode_attr.render()); + } + }); + + // When splitting passes and stroke is below, draw the fill after the stroke. + if needs_separate_alignment_fill && wants_stroke_below { + emit_svg_fill_path( + render, + path.clone(), + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + render_params, + ); + } +} + +impl Render for List { + 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::(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; } - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } + masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})"))); + } - if blend_mode_attr != BlendMode::default() { - attributes.push("style", blend_mode_attr.render()); - } - }); - - // When splitting passes and stroke is below, draw the fill after the stroke. - if needs_separate_alignment_fill && wants_stroke_below { - emit_svg_fill_path( - render, - path.clone(), - fill_graphic_list.as_deref(), - item_transform, - element_transform, - applied_stroke_transform, - bounds_matrix, - render_params, - ); + // 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) { + let mut clip_masker: Option> = None; + for index in 0..self.len() { use graphic_types::vector_types::vector; @@ -1423,23 +1476,53 @@ impl Render for List { 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()); + // 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::(ATTR_CLIPPING_MASK, index + 1); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. || blend_mode_attr != BlendMode::default() { - layer = true; + let needs_blend_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); + + // 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`. // 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 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 quad = Quad::from_box(layer_bounds).inflate(inflate_amount); - let layer_bounds = quad.bounding_box(); - scene.push_layer( - peniko::Fill::NonZero, - peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), - opacity, + let bounds = Quad::from_box(layer_bounds).inflate(inflate_amount).bounding_box(); + + ( kurbo::Affine::new(multiplied_transform.to_cols_array()), - &kurbo::Rect::new(layer_bounds[0].x, layer_bounds[0].y, layer_bounds[1].x, layer_bounds[1].y), - ); + 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( + peniko::Fill::NonZero, + peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), + 1., + layer_affine, + &layer_rect, + ); + clip_layers = true; + } + + if !next_clips { + clip_masker = None; + } } let use_layer = can_draw_aligned_stroke; @@ -1643,6 +1726,11 @@ impl Render for List { } } + 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 layer { scene.pop_layer(); @@ -1652,17 +1740,8 @@ impl Render for List { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { // 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]`. - let item_zero_transform: DAffine2 = if !self.is_empty() { - 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 - }; + // Targets are baked relative to the first item carrying each element_id, since that is the transform recorded as its `local_transforms` entry. + let mut reference_transforms: HashMap = HashMap::new(); let mut accumulated_click_targets: HashMap>> = HashMap::new(); let mut accumulated_outlines: HashMap>> = HashMap::new(); @@ -1674,18 +1753,17 @@ impl Render for List { let layer = layer_path.iter_element_values().next_back().copied(); 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 - // passed None), also store the transform metadata that Graphic::collect_metadata - // normally provides but skipped due to the None element_id. - if caller_element_id.is_none() { - metadata.upstream_footprints.entry(element_id).or_insert(footprint); - metadata.local_transforms.entry(element_id).or_insert(item_zero_transform); - } + let reference_transform = *reference_transforms.entry(element_id).or_insert(transform); + let reference_inverse = if transform_is_invertible(reference_transform) { + reference_transform.inverse() + } else { + DAffine2::IDENTITY + }; // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes) let click_target_vector = self.attribute::(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(); extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform); @@ -1735,6 +1813,15 @@ impl Render for List { for (element_id, targets) in accumulated_outlines { 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) { @@ -1986,8 +2073,14 @@ impl Render for List> { } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); + 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.)); + } } } @@ -2081,8 +2174,13 @@ impl Render for List> { } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); + 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.)); + } } } diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 8e2eeca2dc..80ef1c65e2 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -3,7 +3,7 @@ use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath}; 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 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 rand::SeedableRng; use rand::seq::SliceRandom; @@ -1020,7 +1020,7 @@ pub async fn flatten_vector(_: impl Ctx, #[implementations(L // TODO: The cleaner fix is to drive each layer's metadata from its own Monitor's captured `(Context, List)`, // TODO: at which point this attribute (and the equivalents in Boolean Operation, Solidify Stroke, Combine Paths, // 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 // 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.