From 511b4da9f54d83eb9d90663a5bde5095a1393614 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 23 Aug 2026 16:52:43 +0000 Subject: [PATCH] Convert the raster std and adjustments families to element kernels and keep the unit primary's slot --- .../messages/portfolio/document_migration.rs | 6 + .../libraries/no-std-types/src/context.rs | 9 + node-graph/node-macro/src/codegen.rs | 6 +- node-graph/nodes/gcore/src/record.rs | 2 +- node-graph/nodes/raster/src/adjust.rs | 11 +- node-graph/nodes/raster/src/adjustments.rs | 130 +++--- node-graph/nodes/raster/src/gradient_map.rs | 20 +- node-graph/nodes/raster/src/std_nodes.rs | 398 ++++++++++-------- 8 files changed, 326 insertions(+), 256 deletions(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 596a3596ba..aed1c3c1dd 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1402,6 +1402,12 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node(*node_id, index), input, network_path); } } + + // The leveled-records flip gave Mandelbrot a unit primary input. + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::mandelbrot::IDENTIFIER) { + let mut node_template = node_definition.default_node_template(); + document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + } } // Rebuild stale Merge/Artboard subgraphs that still use the removed LegacyLayerExtendNode internally diff --git a/node-graph/libraries/no-std-types/src/context.rs b/node-graph/libraries/no-std-types/src/context.rs index 11ed839a31..453adc963c 100644 --- a/node-graph/libraries/no-std-types/src/context.rs +++ b/node-graph/libraries/no-std-types/src/context.rs @@ -7,3 +7,12 @@ impl Ctx for () {} pub trait ArcCtx: Send + Sync {} #[cfg(feature = "std")] impl Ctx for std::sync::Arc {} + +// The cache-hash bound record kernels place on their element generics; the +// shader build compiles the same signatures without the hashing machinery. +#[cfg(feature = "std")] +pub use graphene_hash::CacheHash; +#[cfg(not(feature = "std"))] +pub trait CacheHash {} +#[cfg(not(feature = "std"))] +impl CacheHash for T {} diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index f166a446f8..f66ee6f0a7 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -74,10 +74,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn (Some(crate::codegen::ir::NodeKind::Routing), crate::codegen::ir::Element::Generic(ident)) => Some(ident.clone()), _ => None, }; - // A `_: ()` primary stays visible in the metadata but claims no struct field. - let record_unit_carrier = record_io && crate::codegen::classify::record_shape(parsed).is_some_and(|shape| shape.skips_carrier()); + // A `_: ()` primary keeps its slot: dropping it would shift every + // per-index classification against the IR and the document's arity. let record_skips_carrier = record_io && !carrier_present; - let struct_regular_fields: Vec<_> = regular_fields.iter().skip(record_unit_carrier as usize).copied().collect(); + let struct_regular_fields: Vec<_> = regular_fields.to_vec(); let struct_regular_field_names: Vec<_> = struct_regular_fields.iter().map(|f| &f.pat_ident.ident).collect(); // Extract function generics used by data fields diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index 589555d6d0..20a94e0171 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -1938,7 +1938,7 @@ mod tests { let layout = source_opacity_layout(); reserve_for(&[&layout]); - let node = install(SourceOpacityNode::new(ValueNode(3.), ValueNode(0.25)), source_opacity_layout_meta(), &[]); + let node = install(SourceOpacityNode::new(ValueNode(()), ValueNode(3.), ValueNode(0.25)), source_opacity_layout_meta(), &[]); assert_eq!(Node::::layout(&node), &layout); let GPoll::Final(value) = node.eval(&ctx) else { panic!("expected a final record"); diff --git a/node-graph/nodes/raster/src/adjust.rs b/node-graph/nodes/raster/src/adjust.rs index a2ca7ce783..41eefad66e 100644 --- a/node-graph/nodes/raster/src/adjust.rs +++ b/node-graph/nodes/raster/src/adjust.rs @@ -19,9 +19,14 @@ mod adjust_std { impl Adjust for List> { fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) { for element in self.iter_element_values_mut() { - for color in element.data_mut().data.iter_mut() { - *color = map_fn(color); - } + element.adjust(&map_fn); + } + } + } + impl Adjust for Raster { + fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) { + for color in self.data_mut().data.iter_mut() { + *color = map_fn(color); } } } diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index ca76e82b65..4afdbcc2c2 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -3,8 +3,6 @@ use crate::adjust::Adjust; use crate::cubic_spline::CubicSplines; use core::fmt::Debug; -#[cfg(feature = "std")] -use core_types::list::List; use glam::Vec3; use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear}; use no_std_types::context::Ctx; @@ -50,12 +48,12 @@ pub enum LuminanceCalculation { } #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] -fn luminance>( +fn luminance + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -75,12 +73,12 @@ fn luminance>( } #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] -fn gamma_correction>( +fn gamma_correction + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -97,12 +95,12 @@ fn gamma_correction>( } #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] -fn extract_channel>( +fn extract_channel + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -121,12 +119,12 @@ fn extract_channel>( } #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] -fn make_opaque>( +fn make_opaque + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -143,12 +141,12 @@ fn make_opaque>( // TODO: Remove this once GPU shader nodes are able to support the non-classic algorithm // TODO: Maybe re-add the "Raster: Adjustment" category to make this user-facing if we care to make this not just for testing #[node_macro::node(name("Brightness/Contrast Classic"), category(""), properties("brightness_contrast_properties"), shader_node(PerPixelAdjust))] -fn brightness_contrast_classic>( +fn brightness_contrast_classic + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -174,12 +172,12 @@ fn brightness_contrast_classic>( // Some further analysis available at: // https://geraldbakker.nl/psnumbers/brightness-contrast.html #[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), properties("brightness_contrast_properties"), cfg(feature = "std"))] -fn brightness_contrast>( +fn brightness_contrast + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _ctx: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -255,12 +253,12 @@ fn brightness_contrast>( // Some further analysis available at: // https://geraldbakker.nl/psnumbers/levels.html #[node_macro::node(category("Raster: Adjustment"), properties("levels_properties"), shader_node(PerPixelAdjust))] -fn levels>( +fn levels + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut image: T, @@ -334,12 +332,12 @@ fn levels>( // Works the same for gamma and linear color // TODO: Currently the un-List-wrapped `tint` Color is causing a type error. Put this back in the "Raster: Adjustment" category once that's fixed. #[node_macro::node(name("Black & White"), category(""), properties("black_and_white_properties"), shader_node(PerPixelAdjust))] -fn black_and_white>( +fn black_and_white + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut image: T, @@ -417,12 +415,12 @@ fn black_and_white>( // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0 // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings #[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))] -fn hue_saturation>( +fn hue_saturation + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -449,12 +447,12 @@ fn hue_saturation>( // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27%20%3D%20Color%20Lookup-,%27nvrt%27%20%3D%20Invert,-%27post%27%20%3D%20Posterize #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] -fn invert>( +fn invert + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -470,12 +468,12 @@ fn invert>( // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=post%27%20%3D%20Posterize-,%27thrs%27%20%3D%20Threshold,-%27grdm%27%20%3D%20Gradient #[node_macro::node(category("Raster: Adjustment"), properties("threshold_properties"), shader_node(PerPixelAdjust))] -fn threshold>( +fn threshold + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut image: T, @@ -516,12 +514,12 @@ fn threshold>( // When both parameters are set, it is equivalent to running this adjustment twice, with only vibrance set and then only saturation set. // (Except for some noise probably due to rounding error.) #[node_macro::node(category("Raster: Adjustment"), properties("vibrance_properties"), shader_node(PerPixelAdjust))] -fn vibrance>( +fn vibrance + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut image: T, @@ -718,12 +716,12 @@ pub enum DomainWarpType { // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27mixr%27%20%3D%20Channel%20Mixer // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Lab%20color%20only-,Channel%20Mixer,-Key%20is%20%27mixr #[node_macro::node(category("Raster: Adjustment"), properties("channel_mixer_properties"), shader_node(PerPixelAdjust))] -fn channel_mixer>( +fn channel_mixer + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut image: T, @@ -850,12 +848,12 @@ pub enum SelectiveColorChoice { // Algorithm based on: // https://blog.pkh.me/p/22-understanding-selective-coloring-in-adobe-photoshop.html #[node_macro::node(category("Raster: Adjustment"), properties("selective_color_properties"), shader_node(PerPixelAdjust))] -fn selective_color>( +fn selective_color + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut image: T, @@ -994,12 +992,12 @@ fn selective_color>( // https://www.axiomx.com/posterize.htm // This algorithm produces fully accurate output in relation to the industry standard. #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] -fn posterize>( +fn posterize + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, @@ -1023,12 +1021,12 @@ fn posterize>( // Algorithm based on: // https://geraldbakker.nl/psnumbers/exposure.html #[node_macro::node(category("Raster: Adjustment"), properties("exposure_properties"), shader_node(PerPixelAdjust))] -fn exposure>( +fn exposure + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>( _: impl Ctx, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] #[gpu_image] mut input: T, diff --git a/node-graph/nodes/raster/src/gradient_map.rs b/node-graph/nodes/raster/src/gradient_map.rs index c314332a8c..1f9c660e69 100644 --- a/node-graph/nodes/raster/src/gradient_map.rs +++ b/node-graph/nodes/raster/src/gradient_map.rs @@ -1,8 +1,7 @@ //! Not immediately shader compatible due to needing [`GradientStops`] as a param, which needs [`Vec`] use crate::adjust::Adjust; -use core_types::list::List; -use core_types::{Color, Ctx}; +use core_types::{Color, Ctx, ExtractIndex, InjectIndex}; use raster_types::{CPU, Raster}; use vector_types::GradientStops; @@ -10,18 +9,21 @@ use vector_types::GradientStops; // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Gradient%20settings%20(Photoshop%206.0) #[node_macro::node(category("Raster: Adjustment"))] -fn gradient_map>( - _: impl Ctx, +fn gradient_map + Clone + Send + Sync + core_types::CacheHash + 'static>( + _: impl Ctx + ExtractIndex + InjectIndex + Copy, #[implementations( - List>, - List, - List, + Raster, + Color, + GradientStops, )] mut image: T, - gradient: List, + gradient: IList, reverse: bool, ) -> T { - let Some(gradient) = gradient.element(0) else { return image }; + if gradient.is_empty() { + return image; + } + let gradient = gradient.element_ref(0); image.adjust(|color| { let intensity = color.luminance_rec_709(); diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 5e1fe31b9c..6dca4d7f86 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -1,10 +1,10 @@ use crate::adjustments::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, NoiseType}; -use core_types::ATTR_TRANSFORM; -use core_types::attribute::{Attr, Transform as TransformAttr}; +use core_types::attribute::{Attr, Attribute, BlendMode as BlendModeAttr, ClippingMask, EditorLayerPath, Opacity, OpacityFill, Transform as TransformAttr}; use core_types::color::Color; use core_types::color::{Alpha, AlphaMut, Channel, LinearChannel, Luminance, RGBMut}; use core_types::context::{Ctx, ExtractFootprint, ExtractIndex, InjectIndex}; -use core_types::list::{Item, List}; +use core_types::extent::{LevelIn, ListIn, ValueIn}; +use core_types::gpoll::{Extent, GPoll, GraphError, Interrupt}; use core_types::math::bbox::Bbox; use core_types::transform::Transform; use dyn_any::DynAny; @@ -31,199 +31,249 @@ impl From for Error { } #[node_macro::node(category("Debug"))] -pub fn sample_image(ctx: impl ExtractFootprint + Clone + Send, image_frame: List>) -> List> { - image_frame - .into_iter() - .filter_map(|row| { - let image_frame_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let (image, mut attributes) = row.into_parts(); +pub fn sample_image(ctx: impl Ctx + ExtractFootprint, (image, lane_transform): (Raster, Attr)) -> (Raster, Attr) { + let image_frame_transform: DAffine2 = *lane_transform; - // Resize the image using the image crate - let data = bytemuck::cast_vec(image.data.clone()); + // Resize the image using the image crate + let data = bytemuck::cast_vec(image.data.clone()); - let footprint = ctx.footprint(); - let viewport_bounds = footprint.viewport_bounds_in_local_space(); - let image_bounds = Bbox::from_transform(image_frame_transform).to_axis_aligned_bbox(); - let intersection = viewport_bounds.intersect(&image_bounds); - let image_size = DAffine2::from_scale(DVec2::new(image.width as f64, image.height as f64)); - let size = intersection.size(); - let size_px = image_size.transform_vector2(size).as_uvec2(); + let footprint = ctx.footprint(); + let viewport_bounds = footprint.viewport_bounds_in_local_space(); + let image_bounds = Bbox::from_transform(image_frame_transform).to_axis_aligned_bbox(); + let intersection = viewport_bounds.intersect(&image_bounds); + let image_size = DAffine2::from_scale(DVec2::new(image.width as f64, image.height as f64)); + let size = intersection.size(); + let size_px = image_size.transform_vector2(size).as_uvec2(); - // If the image would not be visible, add nothing. - if size.x <= 0. || size.y <= 0. { - return None; - } + // A culled lane serves a zero-size raster, which renders as nothing. + if size.x <= 0. || size.y <= 0. { + return (Raster::new_cpu(Image::default()), Attr(image_frame_transform)); + } - let image_buffer = ::image::Rgba32FImage::from_raw(image.width, image.height, data).expect("Failed to convert internal image format into image-rs data type."); + let image_buffer = ::image::Rgba32FImage::from_raw(image.width, image.height, data).expect("Failed to convert internal image format into image-rs data type."); - let dynamic_image: ::image::DynamicImage = image_buffer.into(); - let offset = (intersection.start - image_bounds.start).max(DVec2::ZERO); - let offset_px = image_size.transform_vector2(offset).as_uvec2(); - let cropped = dynamic_image.crop_imm(offset_px.x, offset_px.y, size_px.x, size_px.y); + let dynamic_image: ::image::DynamicImage = image_buffer.into(); + let offset = (intersection.start - image_bounds.start).max(DVec2::ZERO); + let offset_px = image_size.transform_vector2(offset).as_uvec2(); + let cropped = dynamic_image.crop_imm(offset_px.x, offset_px.y, size_px.x, size_px.y); - let viewport_resolution_x = footprint.transform.transform_vector2(DVec2::X * size.x).length(); - let viewport_resolution_y = footprint.transform.transform_vector2(DVec2::Y * size.y).length(); - let mut new_width = size_px.x; - let mut new_height = size_px.y; + let viewport_resolution_x = footprint.transform.transform_vector2(DVec2::X * size.x).length(); + let viewport_resolution_y = footprint.transform.transform_vector2(DVec2::Y * size.y).length(); + let mut new_width = size_px.x; + let mut new_height = size_px.y; - // Only downscale the image for now - let resized = if new_width < image.width || new_height < image.height { - new_width = viewport_resolution_x as u32; - new_height = viewport_resolution_y as u32; - // TODO: choose filter based on quality requirements - cropped.resize_exact(new_width, new_height, ::image::imageops::Triangle) - } else { - cropped - }; - let buffer = resized.to_rgba32f(); - let buffer = buffer.into_raw(); - let vec = bytemuck::cast_vec(buffer); - let image = Image { - width: new_width, - height: new_height, - data: vec, - base64_string: None, - }; - // we need to adjust the offset if we truncate the offset calculation + // Only downscale the image for now + let resized = if new_width < image.width || new_height < image.height { + new_width = viewport_resolution_x as u32; + new_height = viewport_resolution_y as u32; + // TODO: choose filter based on quality requirements + cropped.resize_exact(new_width, new_height, ::image::imageops::Triangle) + } else { + cropped + }; + let buffer = resized.to_rgba32f(); + let buffer = buffer.into_raw(); + let vec = bytemuck::cast_vec(buffer); + let image = Image { + width: new_width, + height: new_height, + data: vec, + base64_string: None, + }; + // we need to adjust the offset if we truncate the offset calculation - let new_transform = image_frame_transform * DAffine2::from_translation(offset) * DAffine2::from_scale(size); - attributes.insert(ATTR_TRANSFORM, new_transform); + let new_transform = image_frame_transform * DAffine2::from_translation(offset) * DAffine2::from_scale(size); - Some(Item::from_parts(Raster::new_cpu(image), attributes)) - }) - .collect() + (Raster::new_cpu(image), Attr(new_transform)) } -#[node_macro::node(category("Raster: Channels"))] -pub fn combine_channels( - _: impl Ctx, +#[node_macro::node(category("Raster: Channels"), extent(combine_channels_extent))] +pub fn combine_channels<'e>( + ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy, _primary: (), - #[expose] red: List>, - #[expose] green: List>, - #[expose] blue: List>, - #[expose] alpha: List>, -) -> List> { + #[expose] red: IList>, + #[expose] green: IList>, + #[expose] blue: IList>, + #[expose] alpha: IList>, +) -> Result< + IList<( + Raster, + Attr<'e, TransformAttr>, + Attr<'e, BlendModeAttr>, + Attr<'e, Opacity>, + Attr<'e, OpacityFill>, + Attr<'e, ClippingMask>, + Attr<'e, EditorLayerPath>, + )>, + Interrupt, +> { + let lane = ctx.innermost_index() as usize; let max_len = red.len().max(green.len()).max(blue.len()).max(alpha.len()); - let red = red.into_iter().map(Some).chain(std::iter::repeat(None)).take(max_len); - let green = green.into_iter().map(Some).chain(std::iter::repeat(None)).take(max_len); - let blue = blue.into_iter().map(Some).chain(std::iter::repeat(None)).take(max_len); - let alpha = alpha.into_iter().map(Some).chain(std::iter::repeat(None)).take(max_len); + if lane >= max_len { + return Err(GraphError::past_end().into()); + } - red.zip(green) - .zip(blue) - .zip(alpha) - .filter_map(|(((red, green), blue), alpha)| { - // Turn any default zero-sized image items into None - let red = red.filter(|i| i.element().width > 0 && i.element().height > 0); - let green = green.filter(|i| i.element().width > 0 && i.element().height > 0); - let blue = blue.filter(|i| i.element().width > 0 && i.element().height > 0); - let alpha = alpha.filter(|i| i.element().width > 0 && i.element().height > 0); + // Zero-size lanes and lanes past a shorter channel's end contribute nothing + fn pick<'l>(list: &'l core_types::node::List<'_, Raster>, lane: usize) -> Option<&'l Raster> { + (lane < list.len()).then(|| list.element_ref(lane)).filter(|i| i.width > 0 && i.height > 0) + } + let (red_el, green_el, blue_el, alpha_el) = (pick(&red, lane), pick(&green, lane), pick(&blue, lane), pick(&alpha, lane)); - // Get this item's transform and alpha blending mode from the first non-empty channel - let attributes = [&red, &green, &blue, &alpha].iter().find_map(|i| i.as_ref()).map(|i| i.attributes().clone())?; + // This lane's transform and blending come from the first non-empty channel + let attr_source = [(red_el.is_some(), &red), (green_el.is_some(), &green), (blue_el.is_some(), &blue), (alpha_el.is_some(), &alpha)] + .into_iter() + .find_map(|(present, list)| present.then_some(list.lane(lane))); - // Get the common width and height of the channels, which must have equal dimensions - let channel_dimensions = [ - red.as_ref().map(|r| (r.element().width, r.element().height)), - green.as_ref().map(|g| (g.element().width, g.element().height)), - blue.as_ref().map(|b| (b.element().width, b.element().height)), - alpha.as_ref().map(|a| (a.element().width, a.element().height)), - ]; - if channel_dimensions.iter().all(Option::is_none) - || channel_dimensions - .iter() - .flatten() - .any(|&(x, y)| channel_dimensions.iter().flatten().any(|&(other_x, other_y)| x != other_x || y != other_y)) - { - return None; + // The channels must have equal dimensions; an unusable lane serves a + // zero-size raster, which renders as nothing (the legacy form dropped it) + let channel_dimensions = [ + red_el.map(|r| (r.width, r.height)), + green_el.map(|g| (g.width, g.height)), + blue_el.map(|b| (b.width, b.height)), + alpha_el.map(|a| (a.width, a.height)), + ]; + let mismatched = channel_dimensions + .iter() + .flatten() + .any(|&(x, y)| channel_dimensions.iter().flatten().any(|&(other_x, other_y)| x != other_x || y != other_y)); + let (Some(source), Some(&(width, height)), false) = (attr_source, channel_dimensions.iter().flatten().next(), mismatched) else { + return Ok(( + Raster::new_cpu(Image::default()), + Attr(DAffine2::IDENTITY), + Attr(::default()), + Attr(1.), + Attr(1.), + Attr(false), + Attr(::default()), + )); + }; + + // Create a new image for the output element + let mut image = Image::new(width, height, Color::TRANSPARENT); + + // Iterate over all pixels in the image and set the color channels + for y in 0..image.height() { + for x in 0..image.width() { + let image_pixel = image.get_pixel_mut(x, y).unwrap(); + + if let Some(r) = red_el.and_then(|r| r.get_pixel(x, y)) { + image_pixel.set_red(r.l().cast_linear_channel()); + } else { + image_pixel.set_red(Channel::from_linear(0.)); } - let &(width, height) = channel_dimensions.iter().flatten().next()?; - - // Create a new image for the output element - let mut image = Image::new(width, height, Color::TRANSPARENT); - - // Iterate over all pixels in the image and set the color channels - for y in 0..image.height() { - for x in 0..image.width() { - let image_pixel = image.get_pixel_mut(x, y).unwrap(); - - if let Some(r) = red.as_ref().and_then(|r| r.element().get_pixel(x, y)) { - image_pixel.set_red(r.l().cast_linear_channel()); - } else { - image_pixel.set_red(Channel::from_linear(0.)); - } - if let Some(g) = green.as_ref().and_then(|g| g.element().get_pixel(x, y)) { - image_pixel.set_green(g.l().cast_linear_channel()); - } else { - image_pixel.set_green(Channel::from_linear(0.)); - } - if let Some(b) = blue.as_ref().and_then(|b| b.element().get_pixel(x, y)) { - image_pixel.set_blue(b.l().cast_linear_channel()); - } else { - image_pixel.set_blue(Channel::from_linear(0.)); - } - if let Some(a) = alpha.as_ref().and_then(|a| a.element().get_pixel(x, y)) { - image_pixel.set_alpha(a.l().cast_linear_channel()); - } else { - image_pixel.set_alpha(Channel::from_linear(1.)); - } - } + if let Some(g) = green_el.and_then(|g| g.get_pixel(x, y)) { + image_pixel.set_green(g.l().cast_linear_channel()); + } else { + image_pixel.set_green(Channel::from_linear(0.)); } + if let Some(b) = blue_el.and_then(|b| b.get_pixel(x, y)) { + image_pixel.set_blue(b.l().cast_linear_channel()); + } else { + image_pixel.set_blue(Channel::from_linear(0.)); + } + if let Some(a) = alpha_el.and_then(|a| a.get_pixel(x, y)) { + image_pixel.set_alpha(a.l().cast_linear_channel()); + } else { + image_pixel.set_alpha(Channel::from_linear(1.)); + } + } + } - Some(Item::from_parts(Raster::new_cpu(image), attributes)) - }) - .collect() + // The layer path re-parks into the arena so the borrow outlives the batch + let layer_path: Vec = source.attr::().to_vec(); + let (layer_path, _) = ctx.arena().alloc(layer_path).ok_or(GraphError { + kind: core_types::gpoll::ErrorKind::ArenaExhausted, + trace: Vec::new(), + })?; + + Ok(( + Raster::new_cpu(image), + Attr(source.attr::()), + Attr(source.attr::()), + Attr(source.attr::()), + Attr(source.attr::()), + Attr(source.attr::()), + Attr(layer_path.as_slice()), + )) +} + +/// The combined level's count is the longest channel's; a lower-bound channel +/// keeps the result a lower bound too, and consumers drain to past-end. +fn combine_channels_extent( + _primary: ValueIn<'_, ()>, + red: ListIn<'_, Raster>, + green: ListIn<'_, Raster>, + blue: ListIn<'_, Raster>, + alpha: ListIn<'_, Raster>, + level: LevelIn, +) -> GPoll { + match level.top() { + true => red.total().zip(green.total()).zip(blue.total()).zip(alpha.total()).map(|(((red, green), blue), alpha)| { + let totals = [red, green, blue, alpha]; + let bound = totals + .iter() + .map(|extent| match extent { + Extent::Exactly(count) | Extent::AtLeast(count) => *count, + Extent::Free => 0, + }) + .max() + .unwrap_or(0); + match totals.iter().all(|extent| matches!(extent, Extent::Exactly(_))) { + true => Extent::Exactly(bound), + false => Extent::AtLeast(bound), + } + }), + false => GPoll::Final(Extent::Exactly(1)), + } } #[node_macro::node(category("Raster"))] pub fn mask( - _: impl Ctx, + _: impl Ctx + ExtractIndex + InjectIndex + Copy, /// The image to be masked. - image: List>, + (mut image, lane_transform): (Raster, Attr), /// The stencil to be used for masking. #[expose] - stencil: List>, -) -> List> { + stencil: IList>, +) -> (Raster, Attr) { // TODO: Figure out what it means to support multiple stencil items? - let Some(stencil) = stencil.into_iter().next() else { + if stencil.is_empty() { // No stencil provided so we return the original image - return image; - }; - let stencil_size = DVec2::new(stencil.element().width as f64, stencil.element().height as f64); + return (image, Attr(*lane_transform)); + } + let stencil_element = stencil.element_ref(0); + let stencil_transform: DAffine2 = stencil.lane(0).attr::(); + let stencil_size = DVec2::new(stencil_element.width as f64, stencil_element.height as f64); - image - .into_iter() - .filter_map(|mut row| { - let image_size = DVec2::new(row.element().width as f64, row.element().height as f64); - let stencil_transform: DAffine2 = stencil.attribute_cloned_or_default(ATTR_TRANSFORM); - let mask_size = stencil_transform.scale_magnitudes(); + let image_size = DVec2::new(image.width as f64, image.height as f64); + let mask_size = stencil_transform.scale_magnitudes(); - if mask_size == DVec2::ZERO { - return None; - } + // A degenerate stencil serves a zero-size raster, which renders as + // nothing (the legacy form dropped the lane) + if mask_size == DVec2::ZERO { + return (Raster::new_cpu(Image::default()), Attr(*lane_transform)); + } - // Transforms a point from the background image to the foreground image - let transform_attribute: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - let bg_to_fg = transform_attribute * DAffine2::from_scale(1. / image_size); - let stencil_transform_inverse = stencil_transform.inverse(); + // Transforms a point from the background image to the foreground image + let transform_attribute: DAffine2 = *lane_transform; + let bg_to_fg = transform_attribute * DAffine2::from_scale(1. / image_size); + let stencil_transform_inverse = stencil_transform.inverse(); - for y in 0..row.element().height { - for x in 0..row.element().width { - let image_point = DVec2::new(x as f64, y as f64); - let mask_point = bg_to_fg.transform_point2(image_point); - let local_mask_point = stencil_transform_inverse.transform_point2(mask_point); - let mask_point = stencil_transform.transform_point2(local_mask_point.clamp(DVec2::ZERO, DVec2::ONE)); - let mask_point = (DAffine2::from_scale(stencil_size) * stencil_transform.inverse()).transform_point2(mask_point); + for y in 0..image.height { + for x in 0..image.width { + let image_point = DVec2::new(x as f64, y as f64); + let mask_point = bg_to_fg.transform_point2(image_point); + let local_mask_point = stencil_transform_inverse.transform_point2(mask_point); + let mask_point = stencil_transform.transform_point2(local_mask_point.clamp(DVec2::ZERO, DVec2::ONE)); + let mask_point = (DAffine2::from_scale(stencil_size) * stencil_transform.inverse()).transform_point2(mask_point); - let image_pixel = row.element_mut().data_mut().get_pixel_mut(x, y).unwrap(); - let mask_pixel = stencil.element().sample(mask_point); - *image_pixel = image_pixel.multiplied_alpha(mask_pixel.l().cast_linear_channel()); - } - } + let image_pixel = image.data_mut().get_pixel_mut(x, y).unwrap(); + let mask_pixel = stencil_element.sample(mask_point); + *image_pixel = image_pixel.multiplied_alpha(mask_pixel.l().cast_linear_channel()); + } + } - Some(row) - }) - .collect() + (image, Attr(transform_attribute)) } /// The per-lane extend, shared with the brush's plain callers. @@ -348,7 +398,7 @@ pub fn noise_pattern( #[widget(ParsedWidgetOverride::Custom = "noise_properties_cellular_jitter")] #[default(1.)] cellular_jitter: f64, -) -> List> { +) -> (Raster, Attr) { let footprint = ctx.footprint(); let viewport_bounds = footprint.viewport_bounds_in_local_space(); @@ -364,9 +414,9 @@ pub fn noise_pattern( size = intersection.size(); } - // If the image would not be visible, return an empty image + // A culled pattern serves a zero-size raster, which renders as nothing if size.x <= 0. || size.y <= 0. { - return List::new(); + return (Raster::new_cpu(Image::default()), Attr(DAffine2::IDENTITY)); } let transform = DAffine2::from_translation(offset) * DAffine2::from_scale(size); @@ -412,7 +462,7 @@ pub fn noise_pattern( } } - return List::new_from_item(Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform)); + return (Raster::new_cpu(image), Attr(transform)); } }; noise.set_noise_type(Some(noise_type)); @@ -470,11 +520,11 @@ pub fn noise_pattern( } } - List::new_from_item(Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform)) + (Raster::new_cpu(image), Attr(transform)) } #[node_macro::node(category("Raster: Pattern"))] -pub fn mandelbrot(ctx: impl ExtractFootprint + Send) -> List> { +pub fn mandelbrot(ctx: impl Ctx + ExtractFootprint, _primary: ()) -> (Raster, Attr) { let footprint = ctx.footprint(); let viewport_bounds = footprint.viewport_bounds_in_local_space(); @@ -484,9 +534,9 @@ pub fn mandelbrot(ctx: impl ExtractFootprint + Send) -> List> { let offset = (intersection.start - image_bounds.start).max(DVec2::ZERO); - // If the image would not be visible, return an empty image + // A culled pattern serves a zero-size raster, which renders as nothing if size.x <= 0. || size.y <= 0. { - return List::new(); + return (Raster::new_cpu(Image::default()), Attr(DAffine2::IDENTITY)); } let scale = footprint.scale(); @@ -508,14 +558,14 @@ pub fn mandelbrot(ctx: impl ExtractFootprint + Send) -> List> { } } - List::new_from_item( - Item::new_from_element(Raster::new_cpu(Image { + ( + Raster::new_cpu(Image { width, height, data, ..Default::default() - })) - .with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(offset) * DAffine2::from_scale(size)), + }), + Attr(DAffine2::from_translation(offset) * DAffine2::from_scale(size)), ) }