diff --git a/node-graph/libraries/core-types/src/node.rs b/node-graph/libraries/core-types/src/node.rs index 98dce654d5..6d18aa7554 100644 --- a/node-graph/libraries/core-types/src/node.rs +++ b/node-graph/libraries/core-types/src/node.rs @@ -3,7 +3,7 @@ use crate::gpoll::{Extent, Finality, GPoll, GraphError, Interrupt, Level}; use std::cell::Cell; use std::marker::PhantomData; use std::mem::MaybeUninit; -use std::ops::Range; +use std::ops::{Deref, Range}; #[derive(Debug)] pub enum BatchStatus<'a> { @@ -185,6 +185,31 @@ impl<'a> RecordLane<'a> { } } +/// One lane of a materialized level, element-typed. In a kernel's element +/// position the output frame is copied from this lane. +#[derive(Debug)] +pub struct Lane<'a, T> { + lane: RecordLane<'a>, + _element: PhantomData, +} + +// A view regardless of `T`: copying a lane copies no record. +impl Clone for Lane<'_, T> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Lane<'_, T> {} + +impl<'a, T> Deref for Lane<'a, T> { + type Target = RecordLane<'a>; + + fn deref(&self) -> &RecordLane<'a> { + &self.lane + } +} + /// A materialized nesting level handed to a folding kernel: a thin element-typed /// view over the [`RecordBatch`] the level was collected into. The eventual /// `List` once `IList` is renamed. @@ -238,8 +263,11 @@ impl<'a, T> List<'a, T> { } /// Lane `index`'s record, for attribute reads beside the element. - pub fn lane(&self, index: usize) -> RecordLane<'a> { - self.batch.get(index) + pub fn lane(&self, index: usize) -> Lane<'a, T> { + Lane { + lane: self.batch.get(index), + _element: PhantomData, + } } pub fn iter(&self) -> impl Iterator + '_ diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 426c7c1e53..21497de0ce 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -77,6 +77,9 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // 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; + // A gather carrier copies the returned lane's frame, so it needs the plan + // without a carrier layout of its own. + let gather_carrier = record_io && node.output.gathers; 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(); @@ -193,6 +196,8 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let mut state = vec![quote!(pub(super) __layout: gcore::record::Layout)]; if !record_skips_carrier { state.push(quote!(pub(super) __carrier: gcore::record::Layout)); + } + if !record_skips_carrier || gather_carrier { state.push(quote!(pub(super) __plan: ::std::vec::Vec<(usize, usize, usize)>)); } state.push(quote!(pub(super) __frame_bytes: usize)); @@ -752,16 +757,22 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn (crate::codegen::ir::NodeKind::Routing, crate::codegen::ir::Element::Generic(ident)) => Some(ident.clone()), _ => None, }; + let skips_carrier = record_io && !carrier_present; + // A gather carrier copies the returned lane's frame, so it needs the plan + // without a carrier layout of its own. + let gather_carrier = record_io && node.output.gathers; + // A gathered element is carried by the copy plan, not as a lazy token, so + // its generic stays a struct parameter. let record_token = match (kind, &node.output.shape.element) { - (crate::codegen::ir::NodeKind::RecordIo, crate::codegen::ir::Element::Generic(ident)) => Some(ident.clone()), + (crate::codegen::ir::NodeKind::RecordIo, crate::codegen::ir::Element::Generic(ident)) if !gather_carrier => Some(ident.clone()), _ => None, }; - let skips_carrier = record_io && !carrier_present; // The record-io write set, resolved from the output item and carrier input. let write_markers: Vec<&Type> = node.output.shape.attrs.iter().map(|attr| &attr.marker).collect(); let removes: Vec<&Type> = node.output.removes.iter().map(|attr| &attr.marker).collect(); + // A gathered element rides the copy plan, never a write. let element_write: Option<&Type> = match &node.output.shape.element { - crate::codegen::ir::Element::Concrete(ty) => Some(ty), + crate::codegen::ir::Element::Concrete(ty) if !gather_carrier => Some(ty), _ => None, }; let carrier_read_ty: Option<&Type> = node.inputs.first().filter(|input| input.subject).and_then(|input| match &input.shape.element { @@ -998,7 +1009,12 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let pat = &field.pat_ident; match &field.ty { ParsedFieldType::Regular(RegularParsedField { ty, .. }) if ir::materialized_levels(&node, index) > 0 => { - quote!(#pat: #core_types::node::List<'_, #ty>) + // The gathered lane borrows this list, so both take the kernel's + // own subject lifetime. + match ir::gathered_subject(&node) == Some(index) { + true => quote!(#pat: #core_types::node::List<'__lane, #ty>), + false => quote!(#pat: #core_types::node::List<'_, #ty>), + } } ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => quote!(#pat: &#ty), ParsedFieldType::Regular(RegularParsedField { ty, .. }) if !field.attribute_reads.is_empty() => read_tuple_param(field, quote!(#pat), quote!(#ty)), @@ -1578,8 +1594,13 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // A bare `Attr` in the return type cannot elide its lifetime, so the // kernel gets a fresh one; reference-valued writes name their real // lifetime explicitly and pass through untouched. - let kernel_output = record_io.then(|| inject_attr_lifetimes(&parsed.output_type)).flatten(); - let attr_lifetime = kernel_output.is_some().then(|| quote!('__attr,)); + let attr_injected = record_io.then(|| inject_attr_lifetimes(&parsed.output_type)).flatten(); + let attr_lifetime = attr_injected.is_some().then(|| quote!('__attr,)); + let lane_injected = gather_carrier + .then(|| crate::codegen::classify::inject_lane_lifetime(attr_injected.as_ref().unwrap_or(&parsed.output_type))) + .flatten(); + let lane_lifetime = lane_injected.is_some().then(|| quote!('__lane,)); + let kernel_output = lane_injected.or(attr_injected); let kernel_output = match derive_routing { true => { let generic = routing_generic.as_ref().expect("derive routing implies routing"); @@ -1591,7 +1612,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let kernel = match async_fn { false => quote! { #[allow(clippy::too_many_arguments)] - #vis fn #fn_name<#attr_lifetime #(#generics,)*>(#ctx_pat: &#ctx_ident #(, #data_params)* #(, #kernel_params)*) -> #kernel_output #fn_where #body + #vis fn #fn_name<#attr_lifetime #lane_lifetime #(#generics,)*>(#ctx_pat: &#ctx_ident #(, #data_params)* #(, #kernel_params)*) -> #kernel_output #fn_where #body }, true => { let kernel_generics = parsed.fn_generics.iter().filter(|param| match param { @@ -1784,6 +1805,15 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } }) .unwrap_or_default(); + // A gathered lane owns its record, so the plan reads straight off it. + let gather_carry = gather_carrier + .then(|| { + quote! { + let __src_rec = __element.rec(); + unsafe { #core_types::record::apply_plan(__src_rec, __dst, &self.__plan) }; + } + }) + .unwrap_or_default(); let carrier_read_bindings: Vec = match skips_carrier || lazy_carrier { true => Vec::new(), false => reads_of(0).into_iter().map(|(slot, read)| read_binding(slot, read, quote!(__src_rec))).collect(), @@ -1798,9 +1828,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn _ => quote!(#record_kernel_call), }; let attr_binders: Vec = (0..write_markers.len()).map(|index| format_ident!("__attr_{index}")).collect(); - let element_binder = match (element_write, lazy_carrier) { - (Some(_), _) | (None, true) => quote!(__element), - (None, false) => quote!(_), + let element_binder = match (element_write.is_some(), lazy_carrier || gather_carrier) { + (true, _) | (_, true) => quote!(__element), + (false, false) => quote!(_), }; // Slot binders in the return tuple's own order: an `Attr` binds the // next write binder, a `RemoveAttr` binds nothing. @@ -1854,6 +1884,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let __kernel_value = #kernel_value; #destructure #lazy_carry + #gather_carry #element_store #(#attr_stores)* if self.__frame_bytes != 0 { @@ -2226,7 +2257,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn self.#slot = __resolved.layout.offset_of(<#marker as #core_types::attribute::Attribute>::NAME, 0).expect("a written attribute is always part of the wired layout"); } }); - let plan = (!skips_carrier).then(|| quote!(self.__plan = __resolved.plan;)); + let plan = (!skips_carrier || gather_carrier).then(|| quote!(self.__plan = __resolved.plan;)); Some(quote! { #(#write_installs)* self.__frame_bytes = __resolved.frame_bytes; @@ -2292,7 +2323,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn Some(ty) => quote!({ use #core_types::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&#core_types::record::ElementWritePick::<#ty>(::core::marker::PhantomData)).element_write() }), None => quote!(__carrier.element), }; - let layout_def = match skips_carrier { + // A gather carrier's base is the gathered subject's layout, so its free + // layout fn takes that layout even though the subject materializes. + let layout_def = match skips_carrier && !gather_carrier { true => quote! { #vis fn #layout_fn() -> #core_types::record::Layout { #core_types::record::Layout::default().with_writes(0, #element, &[#(#write_descs),*]) @@ -2352,19 +2385,39 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let slot = format_ident!("__in_{index}"); quote!(#slot: #slot.clone(),) }); - let plan_default = (!skips_carrier).then(|| quote!(__plan: ::std::vec::Vec::new(),)).into_iter(); + let plan_default = (!skips_carrier || gather_carrier).then(|| quote!(__plan: ::std::vec::Vec::new(),)).into_iter(); let read_names = (0..flat_reads.len()).map(|index| format_ident!("__read_{index}")).map(|slot| quote!(#slot,)); let write_defaults = (0..write_markers.len()).map(|index| format_ident!("__write_{index}")).map(|slot| quote!(#slot: 0,)); let mat_cache_defaults = materialized_indices(®ular_fields, &node).into_iter().map(|index| { let slot = format_ident!("__mat_cache_{index}"); quote!(#slot: ::core::default::Default::default(),) }); + // A ranked input's element generic rides the struct as a phantom + // parameter, so the constructor declares and initializes it too. + let carried_type_params: Vec<&Ident> = struct_type_params + .iter() + .filter(|ident| !data_field_generic_idents.contains(ident) && !node_generics.contains(ident)) + .collect(); + let carried_generic_params: Vec = carried_type_params + .iter() + .map(|ident| { + parsed + .fn_generics + .iter() + .find_map(|param| match param { + GenericParam::Type(type_param) if &&type_param.ident == ident => Some(quote!(#type_param)), + _ => None, + }) + .unwrap_or_else(|| quote!(#ident)) + }) + .collect(); + let marker_init = (!carried_type_params.is_empty()).then(|| quote!(__marker: ::core::marker::PhantomData,)).into_iter(); quote! { #layout_def #layout_meta_def #[automatically_derived] - impl<#(#data_field_generic_idents,)* #(#node_generics,)*> #mod_name::#struct_name<#(#struct_type_params,)*> { + impl<#(#data_field_generic_idents,)* #(#node_generics,)* #(#carried_generic_params,)*> #mod_name::#struct_name<#(#struct_type_params,)*> { #[allow(clippy::too_many_arguments)] #vis fn new(#(#edge_args,)* #(#carrier_layout_param)* #(#input_layout_params)*) -> Self { #(#read_inits)* @@ -2375,6 +2428,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn #(#input_layout_inits)* __layout: ::core::default::Default::default(), #(#plan_default)* + #(#marker_init)* __frame_bytes: 0, #(#read_names)* #(#write_defaults)* diff --git a/node-graph/node-macro/src/codegen/classify.rs b/node-graph/node-macro/src/codegen/classify.rs index e8bea27abb..e231a02a44 100644 --- a/node-graph/node-macro/src/codegen/classify.rs +++ b/node-graph/node-macro/src/codegen/classify.rs @@ -230,6 +230,33 @@ pub(crate) fn inject_attr_lifetimes(output: &Type) -> Option { injector.changed.then_some(ty) } +/// Binds a `Lane` output to the kernel's subject lifetime, replacing whatever +/// the author spelled: the lane borrows the materialized subject, not the arena. +pub(crate) fn inject_lane_lifetime(output: &Type) -> Option { + struct Injector { + changed: bool, + } + + impl VisitMut for Injector { + fn visit_path_segment_mut(&mut self, segment: &mut syn::PathSegment) { + if segment.ident == "Lane" + && let PathArguments::AngleBracketed(args) = &mut segment.arguments + { + let kept: Vec = args.args.iter().filter(|arg| !matches!(arg, GenericArgument::Lifetime(_))).cloned().collect(); + args.args = kept.into_iter().collect(); + args.args.insert(0, GenericArgument::Lifetime(Lifetime::new("'__lane", proc_macro2::Span::call_site()))); + self.changed = true; + } + syn::visit_mut::visit_path_segment_mut(self, segment); + } + } + + let mut ty = output.clone(); + let mut injector = Injector { changed: false }; + injector.visit_type_mut(&mut ty); + injector.changed.then_some(ty) +} + pub(crate) fn contains_open_generic(parsed: &ParsedNodeFn, ty: &Type) -> bool { let ctx_ident = context_param(parsed).map(|ctx| ctx.ident.clone()); parsed @@ -306,12 +333,14 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option { let ParsedFieldType::Regular(RegularParsedField { ty, lend: None, implementations, .. }) = &carrier_field.ty else { return None; }; + // A gathered subject is never read as an element, so its generic stays open. + let gathers = crate::codegen::ir::gathers_lane(parsed); let token = match ty { Type::Tuple(tuple) if tuple.elems.is_empty() => None, ty => match implementations.is_empty().then(|| unbounded_generic(parsed, ty)).flatten() { Some(token) => Some(token), None => { - if contains_open_generic(parsed, ty) { + if !gathers && contains_open_generic(parsed, ty) { return None; } None @@ -334,7 +363,7 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option { } } None => { - if contains_open_generic(parsed, &element) { + if !gathers && contains_open_generic(parsed, &element) { return None; } } diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index b014f51b77..34ff62b6db 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -90,6 +90,10 @@ fn output(parsed: &ParsedNodeFn, generics: &[Ident]) -> Output { Some(RecordWrites { element, markers, removes }) => (element, markers, removes), None => (row, Vec::new(), Vec::new()), }; + let (element, gathers) = match lane_inner(&element) { + Some(inner) => (inner, true), + None => (element, false), + }; Output { shape: ItemShape { element: element_of(&element, generics), @@ -97,6 +101,7 @@ fn output(parsed: &ParsedNodeFn, generics: &[Ident]) -> Output { attrs: writes.into_iter().map(|marker| LevelAttr { marker, level: 0 }).collect(), }, removes: removes.into_iter().map(|marker| LevelAttr { marker, level: 0 }).collect(), + gathers, } } @@ -210,6 +215,31 @@ fn replace_first_type_arg(ty: &Type, replacement: Type) -> Type { ty } +/// Whether the output's element position spells `Lane`. +pub(crate) fn gathers_lane(parsed: &ParsedNodeFn) -> bool { + gathered_element(parsed).is_some() +} + +fn gathered_element(parsed: &ParsedNodeFn) -> Option { + let row = slot_value_type(&parsed.output_type); + let element = record_writes(&row).map_or(row, |writes| writes.element); + lane_inner(&element).is_some().then_some(element) +} + +/// The element type inside a `Lane` position, lifetime argument skipped. +fn lane_inner(ty: &Type) -> Option { + let Type::Path(path) = ty else { return None }; + let segment = path.path.segments.last()?; + if segment.ident != "Lane" { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { return None }; + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(inner) => Some(inner.clone()), + _ => None, + }) +} + fn ilist_inner(ty: &Type) -> Option { let Type::Path(path) = ty else { return None }; let segment = path.path.segments.last()?; @@ -226,13 +256,7 @@ fn ilist_inner(ty: &Type) -> Option { /// Emits the `LayoutMeta` literal from the IR. `element_spec` is supplied by the /// caller since it is the one row-dependent facet; the rest folds from the node. pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_types: &TokenStream2) -> TokenStream2 { - // A materialized subject is folded, not carried: it contributes no layout. - let sources = node - .inputs - .iter() - .enumerate() - .filter(|(index, input)| input.subject && materialized_levels(node, *index) == 0) - .map(|(index, _)| index as u8); + let sources = layout_sources(node).into_iter().map(|index| index as u8); let reads = node.inputs.iter().enumerate().filter_map(|(index, input)| { (matches!(input.evaluation, Evaluation::Eager) && !input.shape.attrs.is_empty()).then(|| { let descs = field_writes(&input.shape.attrs, core_types); @@ -264,6 +288,29 @@ pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_t } } +/// The subjects whose layouts union into the output's base: un-materialized +/// ones, plus a gathered subject. +pub(crate) fn layout_sources(node: &Node) -> Vec { + node.inputs + .iter() + .enumerate() + .filter(|(index, input)| input.subject && (materialized_levels(node, *index) == 0 || gathered_subject(node) == Some(*index))) + .map(|(index, _)| index) + .collect() +} + +/// The materialized subject a gather-carrier copies its output frames from. +pub(crate) fn gathered_subject(node: &Node) -> Option { + if !node.output.gathers { + return None; + } + node.inputs + .iter() + .enumerate() + .find(|(index, input)| input.subject && materialized_levels(node, *index) > 0) + .map(|(index, _)| index) +} + /// The single carried subject a level-preserving node forwards its extents /// to: exactly one un-materialized subject, no level shift, and no fold. pub(crate) fn forwarded_subject(node: &Node) -> Option { @@ -282,8 +329,12 @@ pub(crate) fn forwarded_subject(node: &Node) -> Option { } } -/// The materialized subject a node folds, as `(input, levels)`. +/// The materialized subject a node folds, as `(input, levels)`. A gathered +/// subject is count-preserving, not folded. pub(crate) fn folded_subject(node: &Node) -> Option<(u8, u8)> { + if node.output.gathers { + return None; + } node.inputs .iter() .enumerate() @@ -303,14 +354,9 @@ fn field_writes(attrs: &[LevelAttr], core_types: &TokenStream2) -> Vec i8 { - // A materialized subject contributes no base layout, so the delta is - // relative to the fresh (empty) base. - let base_depth = node - .inputs - .iter() - .enumerate() - .find(|(index, input)| input.subject && materialized_levels(node, *index) == 0) - .map_or(0, |(_, input)| input.shape.depth as i8); + // A folded subject contributes no base layout, so the delta is relative to + // the fresh (empty) base. + let base_depth = layout_sources(node).first().map_or(0, |&index| node.inputs[index].shape.depth as i8); node.output.shape.depth as i8 - base_depth } @@ -389,7 +435,11 @@ fn is_routing(node: &Node) -> bool { fn has_attr_io(node: &Node) -> bool { // Reads on lazy inputs ride the flip; only eager reads make a record-io node. - node.inputs.iter().any(|input| matches!(input.evaluation, Evaluation::Eager) && !input.shape.attrs.is_empty()) || !node.output.shape.attrs.is_empty() || !node.output.removes.is_empty() + // A gathered output takes the record tail regardless of its write set. + node.output.gathers + || node.inputs.iter().any(|input| matches!(input.evaluation, Evaluation::Eager) && !input.shape.attrs.is_empty()) + || !node.output.shape.attrs.is_empty() + || !node.output.removes.is_empty() } /// Levels of `input[index]` the output does not carry; `> 0` folds the input @@ -484,6 +534,9 @@ pub(crate) enum Evaluation { pub(crate) struct Output { pub(crate) shape: ItemShape, pub(crate) removes: Vec, + /// The element position spells `Lane`, so the output frame is a copy of a + /// chosen subject lane. + pub(crate) gathers: bool, } /// An item's ranked layout; `attrs` are reads on an input, writes on the output. @@ -545,13 +598,7 @@ mod tests { }; let subject_depth = node.inputs.iter().find(|input| input.subject).map_or(0, |input| input.shape.depth as i8); Facts { - sources: node - .inputs - .iter() - .enumerate() - .filter(|(index, input)| input.subject && materialized_levels(node, *index) == 0) - .map(|(index, _)| index) - .collect(), + sources: layout_sources(node), carried, writes: markers(node.output.shape.attrs.iter().map(|attr| &attr.marker)), removes: markers(node.output.removes.iter().map(|attr| &attr.marker)), diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index 961a689fb4..d2ff26de87 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -110,10 +110,15 @@ fn validate_record_io(parsed: &ParsedNodeFn) { ); return; }; - if lazy_carrier && !crate::codegen::ir::build(parsed).derives { + let node = crate::codegen::ir::build(parsed); + if lazy_carrier && !node.derives { emit_error!(parsed.input.pat_ident.span(), "a lazy record carrier evaluates at derived contexts; spell `impl Ctx + DeriveCtx`"); return; } + if node.output.gathers && crate::codegen::ir::gathered_subject(&node).is_none() { + emit_error!(parsed.output_type.span(), "a `Lane` output gathers a materialized subject; give the primary input an `IList` type"); + return; + } let no_carrier = matches!(carrier_ty, Type::Tuple(tuple) if tuple.elems.is_empty()); let token = match (no_carrier, &carrier.ty) { @@ -131,12 +136,12 @@ fn validate_record_io(parsed: &ParsedNodeFn) { None => { if let Some(ident) = crate::codegen::unbounded_generic(parsed, element) { emit_error!(parsed.output_type.span(), "the returned generic element `{}` has no matching input", ident); - } else if !no_carrier && crate::codegen::contains_open_generic(parsed, carrier_ty) { + } else if !no_carrier && !node.output.gathers && crate::codegen::contains_open_generic(parsed, carrier_ty) { emit_error!( carrier.pat_ident.span(), "record element reads are monomorphic for now; use a concrete element type or an unbounded passthrough generic" ); - } else if crate::codegen::contains_open_generic(parsed, element) { + } else if !node.output.gathers && crate::codegen::contains_open_generic(parsed, element) { emit_error!(parsed.output_type.span(), "a written element must be a concrete type"); } } diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index 20a94e0171..d838b10292 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -10,6 +10,7 @@ use glam::DAffine2; use core_types::context::{DeriveCtx, ExtractIndex, IndexLink, InjectIndex}; use core_types::extent::{ExtentIn, LevelIn, ListIn, ValueIn}; use core_types::gpoll::{ErrorKind, Extent, GPoll, GraphError, Interrupt, Level}; +use core_types::node::Lane; use core_types::uuid::NodeId; use core_types::Ctx; @@ -304,6 +305,25 @@ fn mirror_extent(content: ListIn<'_, f64>, keep_original: ValueIn<'_, bool>, lev } } +/// Gather-carrier kernel: the level's lanes in reverse. Returning a `Lane` +/// copies that lane's whole record, so undeclared attributes ride along and +/// only the declared opacity is rewritten. +#[node_macro::node(category("Test"), extent(reverse_lanes_extent))] +fn reverse_lanes(ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, content: IList, opacity: f64) -> Result, Attr)>, Interrupt> { + let lane = ctx.innermost_index() as usize; + if lane >= content.len() { + return Err(GraphError::past_end().into()); + } + Ok((content.lane(content.len() - 1 - lane), Attr(opacity))) +} + +fn reverse_lanes_extent(content: ListIn<'_, f64>, _opacity: ValueIn<'_, f64>, level: LevelIn) -> GPoll { + match level.top() { + true => content.total(), + false => GPoll::Final(Extent::Exactly(1)), + } +} + #[node_macro::node(category("Test"))] fn source_opacity(_: impl Ctx, _: (), element: f64, opacity: f64) -> (f64, Attr) { (element, Attr(opacity)) @@ -1355,6 +1375,49 @@ mod tests { assert_eq!(transform.translation.x, 30., "without originals every lane reflects"); } + #[test] + fn a_gathered_lane_carries_its_whole_record() { + let arena = Arena::new(1 << 16).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + // The subject carries Transform, which the kernel never declares. + let layout = Layout::default().with_writes(1, core_types::record::element_write::(), &[core_types::record::FieldWrite::of::(0)]); + reserve_for(&[&layout]); + let rows = [(1., 10.), (2., 30.), (3., 20.)]; + let content = LeveledTransformSource { + layout: layout.clone(), + rows: rows.iter().map(|&(element, x)| (element, DAffine2::from_translation(glam::DVec2::new(x, 0.)))).collect(), + }; + let node = install( + ReverseLanesNode::new(RecordSource::new(content, &layout, &layout), ValueNode(0.25)), + reverse_lanes_layout_meta(), + &[Some(&layout)], + ); + + let out = Node::::layout(&node).clone(); + assert_eq!(out.depth, 1, "gathering preserves the subject's depth"); + assert!(out.offset_of(::NAME, 0).is_some(), "the undeclared attribute survives"); + assert_eq!(node.extent_at(&ctx, 0), GPoll::Final(Extent::Exactly(3))); + + let head = ctx.index_head(); + for (lane, &(element, x)) in rows.iter().rev().enumerate() { + let mark = stack::sp(); + let GPoll::Final(value) = node.eval(&ctx.promoted(&head, lane as u64)) else { + panic!("expected a final record"); + }; + let rec = out.rec(&value); + assert_eq!(unsafe { rec.element::() }, element, "lane {lane} takes the gathered element"); + let transform: DAffine2 = unsafe { rec.read(out.offset_of(::NAME, 0).unwrap()) }; + assert_eq!(transform.translation.x, x, "lane {lane} carries the gathered transform"); + let opacity: f64 = unsafe { rec.read(out.offset_of(::NAME, 0).unwrap()) }; + assert_eq!(opacity, 0.25, "lane {lane} takes the declared write"); + // SAFETY: every field was read out above, so no borrow into this lane's frames remains. + unsafe { stack::rewind(mark) }; + } + } + #[test] fn batch_lanes_match_per_lane_eval() { let arena = Arena::new(1 << 16).unwrap(); diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 6e186fd09d..3d8eb0d9ed 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -11,6 +11,7 @@ use core_types::gpoll::GraphError; use core_types::gpoll::Interrupt; use core_types::gpoll::{Extent, GPoll}; use core_types::list::{Item, ItemAttributeValues, List}; +use core_types::node::Lane; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::transform::Transform; use core_types::uuid::NodeId; @@ -108,7 +109,7 @@ fn assign_colors<'e>( let park_existing = |paint: Option<&List>| -> Result>, Interrupt> { paint.map(|paint| park_paint(ctx.arena(), paint.clone())).transpose() }; let existing_fill = park_existing(content.lane(lane).attr::())?; let existing_stroke = park_existing(content.lane(lane).attr::())?; - let carried = carried_lane_attrs(ctx.arena(), content.lane(lane))?; + let carried = carried_lane_attrs(ctx.arena(), *content.lane(lane))?; let (transform, layer_path) = carried; if gradient.len() == 0 { @@ -176,7 +177,7 @@ fn assign_colors_graphic<'e>( return Err(GraphError::past_end().into()); } let mut element = graphic_types::graphic::map_groups_to_legacy(content.element_ref(lane)); - let (transform, layer_path) = carried_lane_attrs(ctx.arena(), content.lane(lane))?; + let (transform, layer_path) = carried_lane_attrs(ctx.arena(), *content.lane(lane))?; if gradient.len() == 0 { return Ok((element, transform, layer_path)); @@ -970,16 +971,10 @@ fn bilinear_interpolate(t: DVec2, quad: &[DVec2; 4]) -> DVec2 { tl * (1. - t.x) * (1. - t.y) + tr * t.x * (1. - t.y) + br * t.x * t.y + bl * (1. - t.x) * t.y } -#[node_macro::node(category("Vector"), path(graphene_core::vector))] -fn pack_strips( - _: impl Ctx, - #[implementations( - List, - List, - List>, - List>, - )] - elements: List, +#[node_macro::node(category("Vector"), path(graphene_core::vector), extent(pack_strips_extent))] +fn pack_strips<'e, T: BoundingBox + Clone + Send + Sync + CacheHash + 'static>( + ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy, + #[implementations(Graphic, Vector, Raster, Raster)] elements: IList, #[default(0.)] #[unit(" px")] separation: f64, @@ -987,60 +982,56 @@ fn pack_strips( #[unit(" px")] strip_max_length: f64, strip_direction: RowsOrColumns, -) -> List -where - Graphic: From>, - List: BoundingBox, -{ - // Packs shapes using bounds with Best-Fit Decreasing Height (BFDH) algorithm: - // - Sort shapes by cross-axis size (tallest first for rows, widest first for columns) - // - For each shape, find the existing strip with minimum remaining space that fits - // - Create new strip only if no existing strip can accommodate the shape - +) -> Result, Attr<'e, TransformAttr>)>, Interrupt> { + // Best-Fit Decreasing Height: sort by cross-axis size, then place each item on + // the strip with the least remaining space that still fits it. struct Strip { along_position: f64, cross_position: f64, cross_extent: f64, } - // Prepare the items to be sorted - let mut items: Vec<(f64, f64, DVec2, Item)> = elements - .into_iter() + let lane = ctx.innermost_index() as usize; + if lane >= elements.len() { + return Err(GraphError::past_end().into()); + } + + let mut items: Vec<(f64, f64, DVec2, usize)> = (0..elements.len()) .map(|row| { - // Single-item `List` to query its bounding box - let single = List::new_from_item(row.clone()); - let (w, h, top_left) = match single.bounding_box(DAffine2::IDENTITY, false) { + // The pre-flip single-item `List` wrap composed the item's own + // transform into its bounds. + let lane_transform: DAffine2 = elements.lane(row).attr::(); + let (width, height, top_left) = match elements.element_ref(row).bounding_box(lane_transform, false) { RenderBoundingBox::Rectangle([min, max]) => { let size = max - min; (size.x.max(0.), size.y.max(0.), min) } _ => (0., 0., DVec2::ZERO), }; - let (along, cross) = match strip_direction { - RowsOrColumns::Rows => (w, h), - RowsOrColumns::Columns => (h, w), - }; - (along, cross, top_left, row) + match strip_direction { + RowsOrColumns::Rows => (width, height, top_left, row), + RowsOrColumns::Columns => (height, width, top_left, row), + } }) .collect(); - - // Sort by cross-axis size, largest first items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal)); - let mut result = List::new(); let mut strips: Vec = Vec::new(); + let mut gathered = (lane, DAffine2::IDENTITY); - // This looks n^2 but it is just n*k where k is the number of strips, which is generally much smaller than n - for (along, cross, top_left, mut row) in items { + for (position, &(along, cross, top_left, source)) in items.iter().enumerate() { + let lane_transform: DAffine2 = elements.lane(source).attr::(); if along <= 0. { - result.push(row); + if position == lane { + gathered = (source, lane_transform); + break; + } continue; } - // Find a good strip, minimum remaining space that can fit this item ideally + // n*k where k is the strip count, generally much smaller than n let mut best_strip_index = None; let mut min_remaining_space = f64::INFINITY; - for (index, strip) in strips.iter().enumerate() { let remaining_space = strip_max_length - strip.along_position; if remaining_space >= along && remaining_space < min_remaining_space { @@ -1049,45 +1040,49 @@ where } } - if let Some(strip_index) = best_strip_index { - // Place on existing strip - let strip = &mut strips[strip_index]; - - // Update strip cross extent if needed - if cross > strip.cross_extent { - strip.cross_extent = cross; + let target_position = match best_strip_index { + Some(strip_index) => { + let strip = &mut strips[strip_index]; + if cross > strip.cross_extent { + strip.cross_extent = cross; + } + let target = match strip_direction { + RowsOrColumns::Rows => DVec2::new(strip.along_position, strip.cross_position), + RowsOrColumns::Columns => DVec2::new(strip.cross_position, strip.along_position), + }; + strip.along_position += along + separation; + target } + None => { + let new_cross = strips.last().map_or(0., |last| last.cross_position + last.cross_extent + separation); + let target = match strip_direction { + RowsOrColumns::Rows => DVec2::new(0., new_cross), + RowsOrColumns::Columns => DVec2::new(new_cross, 0.), + }; + strips.push(Strip { + along_position: along + separation, + cross_position: new_cross, + cross_extent: cross, + }); + target + } + }; - let target_position = match strip_direction { - RowsOrColumns::Rows => DVec2::new(strip.along_position, strip.cross_position), - RowsOrColumns::Columns => DVec2::new(strip.cross_position, strip.along_position), - }; - let row_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - row.set_attribute(ATTR_TRANSFORM, DAffine2::from_translation(target_position - top_left) * row_transform); - - strip.along_position += along + separation; - } else { - // Create new strip - let new_cross = strips.last().map_or(0., |last| last.cross_position + last.cross_extent + separation); - - let target_position = match strip_direction { - RowsOrColumns::Rows => DVec2::new(0., new_cross), - RowsOrColumns::Columns => DVec2::new(new_cross, 0.), - }; - let row_transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); - row.set_attribute(ATTR_TRANSFORM, DAffine2::from_translation(target_position - top_left) * row_transform); - - strips.push(Strip { - along_position: along + separation, - cross_position: new_cross, - cross_extent: cross, - }); + if position == lane { + gathered = (source, DAffine2::from_translation(target_position - top_left) * lane_transform); + break; } - - result.push(row); } - result + let (source, placement) = gathered; + Ok((elements.lane(source), Attr(placement))) +} + +fn pack_strips_extent(elements: ListIn<'_, T>, _separation: ValueIn<'_, f64>, _strip_max_length: ValueIn<'_, f64>, _strip_direction: ValueIn<'_, RowsOrColumns>, level: LevelIn) -> GPoll { + match level.top() { + true => elements.total(), + false => GPoll::Final(Extent::Exactly(1)), + } } /// Automatically constructs tangents (Bézier handles) for anchor points in a vector path.