From b5c4aa45985f03352547336918519915a5d74321 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Fri, 21 Aug 2026 19:34:03 +0000 Subject: [PATCH] Add the group variant to Graphic with typed run walks --- .../data_panel/data_panel_message_handler.rs | 2 + .../libraries/graphic-types/src/graphic.rs | 180 +++++++++++++++++- .../libraries/rendering/src/render_ext.rs | 2 +- .../libraries/rendering/src/renderer.rs | 12 +- node-graph/nodes/path-bool/src/lib.rs | 1 + 5 files changed, 193 insertions(+), 4 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index a210e52805..e20e639205 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -336,6 +336,7 @@ impl TableItemLayout for Graphic { Self::Color(list) => list.identifier(), Self::Gradient(list) => list.identifier(), Self::Text(list) => list.identifier(), + Self::Group(_) => "Group".to_string(), } } // Don't put a breadcrumb for Graphic @@ -351,6 +352,7 @@ impl TableItemLayout for Graphic { Self::Color(list) => list.layout_with_breadcrumb(data), Self::Gradient(list) => list.layout_with_breadcrumb(data), Self::Text(list) => list.layout_with_breadcrumb(data), + Self::Group(_) => Vec::new(), } } } diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index b4c51561bb..6868575dc4 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -22,6 +22,7 @@ pub enum Graphic { Color(List), Gradient(List), Text(List), + Group(core_types::record::Group), } impl Default for Graphic { @@ -238,7 +239,7 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA Graphic::RasterGPU(list) => bake_list_transform(list, transform), Graphic::Gradient(list) => bake_list_transform(list, transform), Graphic::Text(list) => bake_list_transform(list, transform), - Graphic::Color(_) => {} + Graphic::Color(_) | Graphic::Group(_) => {} } } } @@ -430,6 +431,7 @@ impl Graphic { Graphic::Color(list) => all_clipped(list), Graphic::Gradient(list) => all_clipped(list), Graphic::Text(list) => all_clipped(list), + Graphic::Group(group) => group_all_clipped(group), } } @@ -469,6 +471,7 @@ impl Graphic { Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())), Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + Graphic::Group(group) => group_is_opaque(group), } } @@ -492,6 +495,7 @@ impl Graphic { Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.), Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, + Graphic::Group(group) => group_is_fully_transparent(group), } } @@ -511,10 +515,181 @@ impl Graphic { Graphic::RasterCPU(list) => list.is_empty(), Graphic::RasterGPU(list) => list.is_empty(), Graphic::Text(list) => list.is_empty(), + Graphic::Group(group) => group_is_empty(group), } } } +/// One run's attribute offsets, resolved once so the lane loops read raw. +struct RunAttrs { + transform: Option, + opacity: Option, + opacity_fill: Option, + clipping_mask: Option, +} + +impl RunAttrs { + fn of(item: &core_types::record::GroupItem) -> Self { + let layout = item.layout(); + Self { + transform: layout.offset_of(ATTR_TRANSFORM, 0), + opacity: layout.offset_of(ATTR_OPACITY, 0), + opacity_fill: layout.offset_of(ATTR_OPACITY_FILL, 0), + clipping_mask: layout.offset_of(ATTR_CLIPPING_MASK, 0), + } + } + + fn read_or(item: &core_types::record::GroupItem, offset: Option, lane: usize, default: T) -> T { + match offset { + // SAFETY: the offset comes from the item's own layout. + Some(offset) => unsafe { item.lanes().get(lane).rec().read(offset) }, + None => default, + } + } +} + +fn group_row_transform(group: &core_types::record::Group) -> DAffine2 { + match &group.row { + Some(row) if !row.is_empty() => RunAttrs::read_or(row, RunAttrs::of(row).transform, 0, DAffine2::IDENTITY), + _ => DAffine2::IDENTITY, + } +} + +pub fn group_is_empty(group: &core_types::record::Group) -> bool { + match &group.content { + core_types::record::GroupContent::Run(item) => item.is_empty(), + core_types::record::GroupContent::Stack(children) => children.iter().all(group_is_empty), + } +} + +fn group_all_clipped(group: &core_types::record::Group) -> bool { + match &group.content { + core_types::record::GroupContent::Run(item) => { + let attrs = RunAttrs::of(item); + (0..item.len()).all(|lane| RunAttrs::read_or(item, attrs.clipping_mask, lane, false)) + } + core_types::record::GroupContent::Stack(children) => children.iter().all(group_all_clipped), + } +} + +fn group_is_opaque(group: &core_types::record::Group) -> bool { + match &group.content { + core_types::record::GroupContent::Run(item) => { + let attrs = RunAttrs::of(item); + let lanes = item.typed_lanes::(); + !item.is_empty() + && (0..item.len()).all(|lane| { + RunAttrs::read_or(item, attrs.opacity, lane, 1.) >= 1. + && RunAttrs::read_or(item, attrs.opacity_fill, lane, 1.) >= 1. + && lanes.as_ref().is_some_and(|lanes| lanes.element_ref(lane).is_opaque()) + }) + } + core_types::record::GroupContent::Stack(children) => !children.is_empty() && children.iter().all(group_is_opaque), + } +} + +fn group_is_fully_transparent(group: &core_types::record::Group) -> bool { + match &group.content { + core_types::record::GroupContent::Run(item) => { + let attrs = RunAttrs::of(item); + let lanes = item.typed_lanes::(); + (0..item.len()).all(|lane| { + RunAttrs::read_or(item, attrs.opacity, lane, 1.) <= 0. || lanes.as_ref().is_some_and(|lanes| lanes.element_ref(lane).is_fully_transparent()) + }) + } + core_types::record::GroupContent::Stack(children) => children.iter().all(group_is_fully_transparent), + } +} + +fn group_bounding_box(group: &core_types::record::Group, transform: DAffine2, include_stroke: bool, thumbnail: bool) -> RenderBoundingBox { + fn combine(combined: &mut Option<[DVec2; 2]>, any_infinite: &mut bool, bounds: RenderBoundingBox, thumbnail: bool) -> Option { + match bounds { + RenderBoundingBox::None => None, + RenderBoundingBox::Infinite if thumbnail => { + *any_infinite = true; + None + } + RenderBoundingBox::Infinite => Some(RenderBoundingBox::Infinite), + RenderBoundingBox::Rectangle(bounds) => { + *combined = Some(match *combined { + Some(existing) => core_types::math::quad::Quad::combine_bounds(existing, bounds), + None => bounds, + }); + None + } + } + } + fn typed_run(item: &core_types::record::GroupItem, transform: DAffine2, include_stroke: bool, thumbnail: bool) -> Option { + let lanes = item.typed_lanes::()?; + let transform_offset = RunAttrs::of(item).transform; + let mut combined = None; + let mut any_infinite = false; + for lane in 0..lanes.len() { + let lane_transform = transform * RunAttrs::read_or(item, transform_offset, lane, DAffine2::IDENTITY); + let element = lanes.element_ref(lane); + let bounds = match thumbnail { + true => element.thumbnail_bounding_box(lane_transform, include_stroke), + false => element.bounding_box(lane_transform, include_stroke), + }; + if let Some(short_circuit) = combine(&mut combined, &mut any_infinite, bounds, thumbnail) { + return Some(short_circuit); + } + } + Some(match (combined, any_infinite) { + (Some(bounds), _) => RenderBoundingBox::Rectangle(bounds), + (None, true) => RenderBoundingBox::Infinite, + (None, false) => RenderBoundingBox::None, + }) + } + fn run_bounding_box(item: &core_types::record::GroupItem, transform: DAffine2, include_stroke: bool, thumbnail: bool) -> RenderBoundingBox { + None.or_else(|| typed_run::(item, transform, include_stroke, thumbnail)) + .or_else(|| typed_run::(item, transform, include_stroke, thumbnail)) + .or_else(|| typed_run::>(item, transform, include_stroke, thumbnail)) + .or_else(|| typed_run::>(item, transform, include_stroke, thumbnail)) + .or_else(|| typed_run::(item, transform, include_stroke, thumbnail)) + .or_else(|| typed_run::(item, transform, include_stroke, thumbnail)) + .or_else(|| typed_run::(item, transform, include_stroke, thumbnail)) + .unwrap_or(RenderBoundingBox::Infinite) + } + match &group.content { + core_types::record::GroupContent::Run(item) => run_bounding_box(item, transform, include_stroke, thumbnail), + core_types::record::GroupContent::Stack(children) => { + let mut combined = None; + let mut any_infinite = false; + for child in children { + let bounds = group_bounding_box(child, transform * group_row_transform(child), include_stroke, thumbnail); + if let Some(short_circuit) = combine(&mut combined, &mut any_infinite, bounds, thumbnail) { + return short_circuit; + } + } + match (combined, any_infinite) { + (Some(bounds), _) => RenderBoundingBox::Rectangle(bounds), + (None, true) => RenderBoundingBox::Infinite, + (None, false) => RenderBoundingBox::None, + } + } + } +} + +fn group_render_complexity(group: &core_types::record::Group) -> usize { + fn typed_run(item: &core_types::record::GroupItem) -> Option { + let lanes = item.typed_lanes::()?; + Some((0..lanes.len()).map(|lane| lanes.element_ref(lane).render_complexity()).sum()) + } + match &group.content { + core_types::record::GroupContent::Run(item) => None + .or_else(|| typed_run::(item)) + .or_else(|| typed_run::(item)) + .or_else(|| typed_run::>(item)) + .or_else(|| typed_run::>(item)) + .or_else(|| typed_run::(item)) + .or_else(|| typed_run::(item)) + .or_else(|| typed_run::(item)) + .unwrap_or(item.len()), + core_types::record::GroupContent::Stack(children) => children.iter().map(group_render_complexity).sum(), + } +} + impl BoundingBox for Graphic { fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { match self { @@ -525,6 +700,7 @@ impl BoundingBox for Graphic { Graphic::Color(list) => list.bounding_box(transform, include_stroke), Graphic::Gradient(list) => list.bounding_box(transform, include_stroke), Graphic::Text(list) => list.bounding_box(transform, include_stroke), + Graphic::Group(group) => group_bounding_box(group, transform, include_stroke, false), } } @@ -537,6 +713,7 @@ impl BoundingBox for Graphic { Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke), Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), Graphic::Text(list) => list.thumbnail_bounding_box(transform, include_stroke), + Graphic::Group(group) => group_bounding_box(group, transform, include_stroke, true), } } } @@ -567,6 +744,7 @@ impl RenderComplexity for Graphic { Self::Color(list) => list.render_complexity(), Self::Gradient(list) => list.render_complexity(), Self::Text(list) => list.render_complexity(), + Self::Group(group) => group_render_complexity(group), } } } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index f25bb069ff..31182b0754 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -241,7 +241,7 @@ impl RenderExt for List { let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target); format!(r##" {paint_attr}="url(#{gradient_id})""##) } - Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) => { + Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) | Some(Graphic::Group(_)) => { let bounds = if target == PaintTarget::Stroke { // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. let inverse = |len: f64| if len > 0. { 1. / len } else { 0. }; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 6b23eb940a..cb1f6030ad 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -553,6 +553,7 @@ impl Render for Graphic { Graphic::Color(list) => list.render_svg(render, render_params), Graphic::Gradient(list) => list.render_svg(render, render_params), Graphic::Text(list) => list.render_svg(render, render_params), + Graphic::Group(_) => (), } } @@ -565,12 +566,14 @@ impl Render for Graphic { Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params), + Graphic::Group(_) => (), } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { if let Some(element_id) = element_id { match self { + Graphic::Group(_) => {} Graphic::Graphic(_) => { metadata.upstream_footprints.insert(element_id, footprint); } @@ -637,6 +640,7 @@ impl Render for Graphic { Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id), + Graphic::Group(_) => (), } } @@ -649,6 +653,7 @@ impl Render for Graphic { Graphic::Color(list) => list.add_upstream_click_targets(click_targets), Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets), Graphic::Text(list) => list.add_upstream_click_targets(click_targets), + Graphic::Group(_) => (), } } @@ -661,6 +666,7 @@ impl Render for Graphic { Graphic::Color(list) => list.add_upstream_outline_targets(outlines), Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines), Graphic::Text(list) => list.add_upstream_outline_targets(outlines), + Graphic::Group(_) => (), } } @@ -673,6 +679,7 @@ impl Render for Graphic { Graphic::Color(list) => list.contains_artboard(), Graphic::Gradient(list) => list.contains_artboard(), Graphic::Text(list) => list.contains_artboard(), + Graphic::Group(_) => false, } } @@ -685,6 +692,7 @@ impl Render for Graphic { Graphic::Color(_) => (), Graphic::Gradient(_) => (), Graphic::Text(_) => (), + Graphic::Group(_) => (), } } } @@ -1367,7 +1375,7 @@ impl Render for List { let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Group(_) => { scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); paint.render_to_vello(scene, multiplied_transform, context, render_params); scene.pop_layer(); @@ -1449,7 +1457,7 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) | Graphic::Group(_) => { let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index cc4dc9fbd1..ce84b7741e 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -183,6 +183,7 @@ fn flatten_vector(graphic_list: &List) -> List { .flat_map(|index| { let graphic = graphic_list.element(index).unwrap(); match graphic.clone() { + Graphic::Group(_) => Vec::new(), Graphic::Vector(vector) => { // Apply the parent graphic's transform to each element of the `List` let parent_transform: DAffine2 = graphic_list.attribute_cloned_or_default(ATTR_TRANSFORM, index);