From 81d72b159bb79f590591b11d7d7eff683d3f9d98 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Wed, 9 Sep 2026 09:26:17 +0000 Subject: [PATCH] Write an attribute under a name the graph supplies `Named` in parameter position declares where a placeholder's name is wired, and the macro gives that input constant text; `Attr>` in the return writes under it. The kernel is handed the bare placeholder, since the name is spent resolving the layout and a folded offset is all the write needs, so the hot path matches a marker node's exactly. A name-generic write names its value type through the wired generic, which only an implementations row resolves, so those nodes emit their layout meta per row rather than sharing one across rows. `write_attribute` is the catalog's set half, restoring the identifier master's documents carry with its input positions. Its name input is a constant, so those documents resolve without migration, and the reset marker that stood in for the missing node retires with it. Co-Authored-By: Claude Fable 5 --- .../messages/portfolio/document_migration.rs | 23 ++--- .../src/dynamic_executor.rs | 95 +++++++++++++++++++ .../libraries/core-types/src/attribute.rs | 79 ++++++++++++++- .../libraries/core-types/src/record/mod.rs | 2 +- node-graph/node-macro/src/codegen.rs | 38 ++++++-- node-graph/node-macro/src/codegen/entries.rs | 24 ++++- node-graph/node-macro/src/codegen/ir.rs | 95 ++++++++++++++++++- node-graph/node-macro/src/parsing.rs | 20 ++++ .../src/shader_nodes/per_pixel_adjust.rs | 1 + node-graph/nodes/graphic/src/graphic.rs | 21 +++- 10 files changed, 364 insertions(+), 34 deletions(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 7603e7858a..44f9760391 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1029,12 +1029,6 @@ fn replace_optional_f64_null(input: &str) -> String { result } -/// Serialized proto identifiers of the pre-flip Merge and Artboard layer internals. -/// A document containing any of them predates the leveled-records flip and rebuilds its -/// layer definitions through the same reset mechanism as the `SourceNodeIdNode` entry in -/// `document_migration_reset_node_definition`. -pub const FLIP_RESET_NODE_MARKERS: &[&str] = &["graphic_nodes::graphic::WriteAttributeNode"]; - pub fn document_migration_reset_node_definition(document_serialized_content: &str) -> bool { // Upgrade a document being opened to use fresh copies of all nodes if document_serialized_content.contains("node_output_index") { @@ -1056,18 +1050,13 @@ pub fn document_migration_reset_node_definition(document_serialized_content: &st return true; } - // The leveled-records flip replaced the layer internals; documents from before it rebuild - // their layer definitions. - if FLIP_RESET_NODE_MARKERS.iter().any(|marker| document_serialized_content.contains(marker)) { - return true; - } - // Every Merge layer network is built from the two nodes that became "As Graphic" and "Into Group", so their definitions // are reset to pick up the current plumbing instead of the alias migration meant for standalone copies of those nodes. if into_group_aliases().any(|alias| document_serialized_content.contains(alias)) { return true; } + false } @@ -3040,10 +3029,12 @@ mod tests { } #[test] - fn the_flip_reset_markers_keep_the_historical_merge_internals_spelling() { - // The node itself is removed; the marker matches its spelling in - // documents saved before the flip, which must stay stable. - assert_eq!(FLIP_RESET_NODE_MARKERS, &["graphic_nodes::graphic::WriteAttributeNode"]); + fn a_written_attribute_no_longer_resets_the_layer_definitions() { + // The node resolves natively again, so a document carrying it keeps + // its own layer internals instead of rebuilding them. + assert!(!document_migration_reset_node_definition( + r#"{"implementation":{"ProtoNode":{"name":"graphic_nodes::graphic::WriteAttributeNode"}}}"# + )); } #[test] diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 6c721465be..61189d8dfa 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -737,6 +737,101 @@ mod test { ProtoNode::value(ConstructionArgs::Value(TaggedValue::String(value.to_string()).into()), vec![]) } + /// A record source, a constant name, and a value wired into the write node. + fn write_attribute_network(name: &str, value: TaggedValue) -> ProtoNetwork { + ProtoNetwork { + stack_need: 0, + inputs: vec![], + output: NodeId(3), + nodes: vec![ + (NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])), + (NodeId(1), string_value(name)), + (NodeId(2), ProtoNode::value(ConstructionArgs::Value(value.into()), vec![])), + (NodeId(3), proto_node("graphic_nodes::graphic::WriteAttributeNode", vec![NodeId(0), NodeId(1), NodeId(2)])), + ], + } + } + + #[test] + fn a_constant_name_folds_into_the_written_layout() { + let executor = build_executor(write_attribute_network("novel:count", TaggedValue::F64(2.5))); + let arena = Arena::new(1 << 12).unwrap(); + let generations = []; + let scope = EvalScope::new(None, None, None, &generations, &arena); + let ctx = ContextImpl::root(&scope); + let handle = executor.tree().get(NodeId(3)).unwrap(); + let layout = handle.layout().clone(); + let offset = layout.offset_of("novel:count", 0).expect("the folded name names a field of the output layout"); + let edge = handle.duplicate().downcast_record::().unwrap(); + let frames = core_types::record::test_frames(executor.tree().stack_need()); + let GPoll::Final(value) = core_types::record::serve_input(&edge, &ctx, &frames) else { + panic!("expected a final record"); + }; + let rec = layout.rec(&value); + assert_eq!(unsafe { rec.element::() }, 7., "the element passes through the write"); + assert_eq!(unsafe { rec.read::(offset) }, 2.5, "the value lands under the folded name"); + } + + #[test] + fn a_census_name_writes_at_its_declared_value_type() { + let path = vec![NodeId(7), NodeId(8)]; + let executor = build_executor(write_attribute_network("editor:layer_path", TaggedValue::NodeIdPath(path.clone()))); + let arena = Arena::new(1 << 12).unwrap(); + let generations = []; + let scope = EvalScope::new(None, None, None, &generations, &arena); + let ctx = ContextImpl::root(&scope); + let handle = executor.tree().get(NodeId(3)).unwrap(); + let layout = handle.layout().clone(); + let offset = layout.offset_of("editor:layer_path", 0).expect("a census name folds onto its census field"); + let edge = handle.duplicate().downcast_record::().unwrap(); + let frames = core_types::record::test_frames(executor.tree().stack_need()); + let GPoll::Final(value) = core_types::record::serve_input(&edge, &ctx, &frames) else { + panic!("expected a final record"); + }; + assert_eq!(unsafe { layout.rec(&value).read::<&[NodeId]>(offset) }, path.as_slice()); + } + + #[test] + fn a_runtime_attribute_name_is_refused_when_the_graph_compiles() { + // The outer node's name comes off another node rather than sitting on + // the wire as text. It still types as a string, so only the fold can + // reject it, which is the case the design rules out entirely. + let mut network = ProtoNetwork { + stack_need: 0, + inputs: vec![], + output: NodeId(6), + nodes: vec![ + (NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])), + (NodeId(1), string_value("novel:count")), + (NodeId(2), string_value("inner:name")), + (NodeId(3), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(1.).into()), vec![])), + // Carries a string element, so its output types as a name. + (NodeId(4), proto_node("graphic_nodes::graphic::WriteAttributeNode", vec![NodeId(1), NodeId(2), NodeId(3)])), + (NodeId(5), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(2.5).into()), vec![])), + (NodeId(6), proto_node("graphic_nodes::graphic::WriteAttributeNode", vec![NodeId(0), NodeId(4), NodeId(5)])), + ], + }; + network.resolve_types(&node_registry::NODE_REGISTRY).unwrap(); + let errors = network.compute_layouts().expect_err("a non-constant name must be refused"); + assert!( + errors.iter().any(|error| format!("{:?}", error.error).contains("must be a constant")), + "the refusal names the constant requirement, got {errors:?}" + ); + } + + #[test] + fn one_name_carries_one_value_type() { + // `opacity` is declared `f64` in the census, so writing a path there is + // a graph error rather than a second field of the same name. + let mut network = write_attribute_network("opacity", TaggedValue::NodeIdPath(vec![NodeId(1)])); + network.resolve_types(&node_registry::NODE_REGISTRY).unwrap(); + let errors = network.compute_layouts().expect_err("a name at two value types must be refused"); + assert!( + errors.iter().any(|error| format!("{:?}", error.error).contains("one name carries one value type")), + "the refusal names the one-name-one-type rule, got {errors:?}" + ); + } + #[test] fn the_clone_node_clones_the_element_out_of_its_record_wire() { let network = ProtoNetwork { diff --git a/node-graph/libraries/core-types/src/attribute.rs b/node-graph/libraries/core-types/src/attribute.rs index c2cbbd7c62..45bc397fcb 100644 --- a/node-graph/libraries/core-types/src/attribute.rs +++ b/node-graph/libraries/core-types/src/attribute.rs @@ -193,7 +193,27 @@ pub const NAMED_PLACEHOLDER: &str = ""; /// them takes two names; `V` fixes the value type. The name is absent by /// construction: it comes from the instance's constant text input, folded /// into the layout at graph compile time, so a computed name cannot exist. -pub struct Named(PhantomData (X, V)>); +/// +/// Written `Named` in parameter position, it declares where `X`'s name is +/// wired: the macro gives that input constant text, and the kernel receives +/// only the placeholder, since a folded name is a layout fact rather than a +/// value the kernel needs. +pub struct Named(PhantomData (X, V)>); + +impl Default for Named { + fn default() -> Self { + Named(PhantomData) + } +} + +/// The placeholders a signature distinguishes its name-generic attributes by. +/// A node writing one name uses [`Name0`]; a second name on the same node +/// takes [`Name1`], and so on, which is all the placeholder has to do. +pub struct Name0; +/// The second name-generic attribute in one signature. See [`Name0`]. +pub struct Name1; +/// The third name-generic attribute in one signature. See [`Name0`]. +pub struct Name2; // SAFETY: every obligation is discharged by `V`, which carries the same // contract at the same value type; only the name differs, and the compiler @@ -214,6 +234,63 @@ unsafe impl Attribute for Named { const REPARK: Option = V::REPARK; } +/// The value a name-generic write takes off the wire, and the row it lands +/// in. A plain row is its own wire form; a reference row's wire form is the +/// owned payload the kernel parks in the arena, so the field can carry a +/// borrow of it for the evaluation. +pub trait WireValue: 'static { + /// The row this value is written at, fixing the field's value type. + type Row: AttrValue; + + /// Moves the value into `arena` where the row borrows it, or hands it back + /// unchanged where the row stores it plainly. `None` reports exhaustion. + fn park<'e>(self, arena: &'e crate::arena::Arena) -> Option<::Value<'e>>; +} + +/// Declares [`WireValue`] rows. `for T` is a plain value, carried and stored +/// as itself; `Wire => Row` parks `Wire`'s payload in the arena and stores the +/// borrow `Row` names. +#[macro_export] +macro_rules! wire_value { + () => {}; + (for $value:ty; $($rest:tt)*) => { + impl $crate::attribute::WireValue for $value { + type Row = $value; + + fn park<'e>(self, _: &'e $crate::arena::Arena) -> ::core::option::Option<$value> { + ::core::option::Option::Some(self) + } + } + + $crate::wire_value!($($rest)*); + }; + ($wire:ty => $row:ty; $($rest:tt)*) => { + impl $crate::attribute::WireValue for $wire { + type Row = $row; + + fn park<'e>(self, arena: &'e $crate::arena::Arena) -> ::core::option::Option<<$row as $crate::attribute::AttrValue>::Value<'e>> { + let (parked, _) = arena.alloc(self)?; + ::core::option::Option::Some(::std::borrow::Borrow::borrow(parked)) + } + } + + $crate::wire_value!($($rest)*); + }; +} + +wire_value! { + for f64; + for u32; + for u64; + for bool; + for DVec2; + for DAffine2; + for crate::Color; + for crate::blending::BlendMode; + ::std::vec::Vec => NodeIdPath; + ::std::string::String => Text; +} + /// Interns a folded attribute name for the `&'static str` a layout field /// holds. Census names never reach here; a document's novel names are finite /// and repeat across instances, so the leak is one allocation per name. diff --git a/node-graph/libraries/core-types/src/record/mod.rs b/node-graph/libraries/core-types/src/record/mod.rs index 6ee3fe0a14..8c6a505c67 100644 --- a/node-graph/libraries/core-types/src/record/mod.rs +++ b/node-graph/libraries/core-types/src/record/mod.rs @@ -25,7 +25,7 @@ pub use access::{Rec, RecordValue, apply_plan, borrow_element, erase_static, rea pub use frames::{FrameArena, FrameScope, Frames}; pub use input::{DerivedLazyInput, DerivedRecordInput, ElementInput, ElementLazyInput, LevelStatus, RecordExtract, RecordInput, RecordLazyInput, fill_frames, materialize_batch, materialize_level}; pub use layout::{ - ElToken, ElementSpec, ElementWrite, ElementWritePick, ElementWritePickHashed, ElementWritePickPlain, FieldDesc, FieldOffset, FieldWrite, InputReads, Layout, LayoutMeta, RecordLayout, copy_plan, + ElToken, ElementSpec, ElementWrite, ElementWritePick, ElementWritePickHashed, ElementWritePickPlain, FieldDesc, FieldOffset, FieldWrite, InputReads, Layout, LayoutMeta, NamedWrite, RecordLayout, copy_plan, element_dims, element_parked, element_write, element_write_hashed, empty_layout, }; pub use owned::{OwnedRecord, deepen_field_value, has_deep_element_glue, register_deep_element_clone, register_deep_field_value, replay_field_value}; diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index f12e8920f6..8bbdb6ca77 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -2019,6 +2019,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let value_args = regular_fields.iter().skip(if skips_carrier { 0 } else { 1 }).map(|field| { let name = &field.pat_ident.ident; match &field.ty { + // A name input's text is spent when the graph compiles, so the + // kernel takes the bare placeholder, not the wired string. + ParsedFieldType::Regular(RegularParsedField { name_source: Some(_), .. }) => quote!(::core::default::Default::default()), // A lend param binds an owned input; the kernel borrows the // evaluated value. ParsedFieldType::Regular(RegularParsedField { lend: Some(_), .. }) => quote!(&#name), @@ -2503,12 +2506,28 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn self.__layout = __resolved.layout; }) } else if record_io { - let write_installs = write_markers.iter().enumerate().map(|(index, marker)| { - let slot = format_ident!("__write_{index}"); - quote! { - 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"); - } - }); + // A name-generic write has no marker name to look up: the compiler + // folded its name out of the graph, so the offset resolves through the + // name the resolved layout carries. + let mut folded = 0usize; + let write_installs: Vec = write_markers + .iter() + .enumerate() + .map(|(index, marker)| { + let slot = format_ident!("__write_{index}"); + let name = match crate::parsing::named_marker(marker).is_some() { + true => { + let position = folded; + folded += 1; + quote!(__resolved.named_writes[#position]) + } + false => quote!(<#marker as #core_types::attribute::Attribute>::NAME), + }; + quote! { + self.#slot = __resolved.layout.offset_of(#name, 0).expect("a written attribute is always part of the wired layout"); + } + }) + .collect(); let plan = (!skips_carrier || gather_carrier).then(|| quote!(self.__plan = __resolved.plan;)); Some(quote! { #(#write_installs)* @@ -2551,7 +2570,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } None => quote!(#core_types::record::ElementSpec::Carried), }; - let layout_meta = crate::codegen::ir::layout_meta_tokens(&node, element_spec, core_types); + let layout_meta = crate::codegen::ir::layout_meta_tokens(&node, element_spec, core_types, &[]); // A flipped shader node's struct and impl are std-gated; its layout meta must be too. let cfg = crate::shader_nodes::modify_cfg(&parsed.attributes); quote! { @@ -2567,8 +2586,11 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let record_wiring = record_io.then(|| { let layout_fn = format_ident!("{}_layout", fn_name); + // A name-generic write is absent here: this free layout fn derives a + // layout without a graph, and only the graph carries the name. let write_descs: Vec = write_markers .iter() + .filter(|marker| crate::parsing::named_marker(marker).is_none()) .map(|marker| quote!(#core_types::record::FieldWrite::of::<#marker>(0))) .collect(); let remove_pairs: Vec = removes @@ -2605,7 +2627,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } None => quote!(#core_types::record::ElementSpec::Carried), }; - let layout_meta = crate::codegen::ir::layout_meta_tokens(&node, element_spec, core_types); + let layout_meta = crate::codegen::ir::layout_meta_tokens(&node, element_spec, core_types, &[]); let layout_meta_def = quote! { #vis fn #layout_meta_fn() -> #core_types::record::LayoutMeta { #layout_meta diff --git a/node-graph/node-macro/src/codegen/entries.rs b/node-graph/node-macro/src/codegen/entries.rs index 9b94a2b20d..71a780df8e 100644 --- a/node-graph/node-macro/src/codegen/entries.rs +++ b/node-graph/node-macro/src/codegen/entries.rs @@ -149,7 +149,7 @@ fn flip_entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, regular_field quote!(&#layout,) }); let element_spec = quote!(gcore::record::ElementSpec::Concrete({ use gcore::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&gcore::record::ElementWritePick::<#row_output>(::core::marker::PhantomData)).element_write() })); - let layout_meta = crate::codegen::ir::layout_meta_tokens(&node, element_spec, &core_types); + let layout_meta = crate::codegen::ir::layout_meta_tokens(&node, element_spec, &core_types, &assignments); Some(quote! { gcore::registry::RegistryEntry { layout_meta: Some(#layout_meta), @@ -382,7 +382,7 @@ fn single_row_entries(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields .collect(); let carried_meta = || { - let meta = ir::layout_meta_tokens(&node, quote!(gcore::record::ElementSpec::Carried), &core_types); + let meta = ir::layout_meta_tokens(&node, quote!(gcore::record::ElementSpec::Carried), &core_types, &[]); quote!(Some(#meta)) }; @@ -417,7 +417,25 @@ fn single_row_entries(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields ir::NodeKind::RecordIo => { let carrier_arg = (node.inputs.first().is_some_and(|input| input.subject) && ir::materialized_levels(&node, 0) == 0).then(|| quote!(&__layout_0,)); let layout_meta_fn = format_ident!("{}_layout_meta", fn_name); - (quote!(), quote!(#carrier_arg #(#value_layout_args)*), quote!(Some(self::#layout_meta_fn()))) + // A name-generic write names its value type through a wired + // generic, which only the row resolves, so such a node's + // meta is emitted per row instead of shared across them. + let named = node.output.shape.attrs.iter().any(|attr| crate::parsing::named_marker(&attr.marker).is_some()); + let meta = match named { + false => quote!(Some(self::#layout_meta_fn())), + true => { + let element_spec = match &node.output.shape.element { + ir::Element::Concrete(element) => { + let ty = substitute_ident_types(element, assignments); + quote!(gcore::record::ElementSpec::Concrete({ use gcore::record::{ElementWritePickHashed as _, ElementWritePickPlain as _}; (&gcore::record::ElementWritePick::<#ty>(::core::marker::PhantomData)).element_write() })) + } + _ => quote!(gcore::record::ElementSpec::Carried), + }; + let meta = ir::layout_meta_tokens(&node, element_spec, &core_types, assignments); + quote!(Some(#meta)) + } + }; + (quote!(), quote!(#carrier_arg #(#value_layout_args)*), meta) } ir::NodeKind::Routing => { let source_layouts = base_indices.iter().map(|index| format_ident!("__layout_{index}")); diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index 74edf6bfde..3a2dab824d 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -67,7 +67,10 @@ fn inputs(parsed: &ParsedNodeFn, fields: &[&ParsedField], generics: &[Ident]) -> shape: item_shape(&element, depth, &field.attribute_reads, generics), subject: subject(index, field, carrier_subject, routing.as_ref()), lend: matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: Some(_), .. })), - name_source: crate::parsing::named_source(&element), + name_source: match &field.ty { + ParsedFieldType::Regular(RegularParsedField { name_source, .. }) => name_source.clone(), + ParsedFieldType::Node(_) => None, + }, } }) .collect() @@ -262,7 +265,10 @@ 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 { +/// `assignments` binds the row's concrete types to the signature's generics, +/// which a name-generic write needs: its value type is written as a projection +/// through the wired generic, so only the row resolves it. +pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_types: &TokenStream2, assignments: &[(Ident, Type)]) -> TokenStream2 { let sources = layout_sources(node).into_iter().map(|index| index as u8); let reads = node .inputs @@ -275,7 +281,7 @@ pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_t quote!(#core_types::record::InputReads { input: #index, reads: ::std::vec![#(#descs),*] }) }); let writes = field_writes(&node.output.shape.attrs, core_types); - let named_writes = named_field_writes(node, core_types); + let named_writes = named_field_writes(node, core_types, assignments); let removes = node.output.removes.iter().map(|attr| { let marker = &attr.marker; let level = attr.level; @@ -369,13 +375,20 @@ fn field_writes(attrs: &[LevelAttr], core_types: &TokenStream2) -> Vec Vec { +fn named_field_writes(node: &Node, core_types: &TokenStream2, assignments: &[(Ident, Type)]) -> Vec { node.output .shape .attrs .iter() .filter_map(|attr| { let (placeholder, value) = crate::parsing::named_marker(&attr.marker)?; + let value = qualify_projection(node, &crate::codegen::classify::substitute_ident_types(&value, assignments), assignments); + // Only a row resolves a value type written through a generic, so + // the row-free meta omits the write rather than naming a type that + // is not in scope there. + if node.generics.iter().any(|generic| mentions_ident(&value, &generic.ident)) { + return None; + } let input = name_input(node, &placeholder)? as u8; let level = attr.level; Some(quote!(#core_types::record::NamedWrite::of::<#placeholder, #value>(#input, #level))) @@ -383,6 +396,51 @@ fn named_field_writes(node: &Node, core_types: &TokenStream2) -> Vec::Assoc` once the row assigns `V`. +/// A value type reached through an associated type needs the generic's own +/// bound to name the projection, which only the signature carries. +fn qualify_projection(node: &Node, ty: &Type, assignments: &[(Ident, Type)]) -> Type { + let Type::Path(path) = ty else { return ty.clone() }; + if path.qself.is_some() || path.path.segments.len() < 2 { + return ty.clone(); + } + let base = &path.path.segments[0].ident; + let Some(generic) = node.generics.iter().find(|generic| &generic.ident == base) else { + return ty.clone(); + }; + let Some((_, row)) = assignments.iter().find(|(ident, _)| ident == base) else { + return ty.clone(); + }; + let mut bounds = generic.bounds.iter().filter_map(|bound| match bound { + TypeParamBound::Trait(bound) => Some(&bound.path), + _ => None, + }); + let (Some(bound), None) = (bounds.next(), bounds.next()) else { + return ty.clone(); + }; + let rest = path.path.segments.iter().skip(1); + syn::parse_quote!(<#row as #bound>::#(#rest)::*) +} + +/// Whether `ty` names `ident` anywhere, so a type written through a generic +/// can be told from one already concrete. +fn mentions_ident(ty: &Type, ident: &Ident) -> bool { + struct Search<'a> { + ident: &'a Ident, + found: bool, + } + + impl syn::visit::Visit<'_> for Search<'_> { + fn visit_ident(&mut self, found: &Ident) { + self.found |= found == self.ident; + } + } + + let mut search = Search { ident, found: false }; + syn::visit::Visit::visit_type(&mut search, ty); + search.found +} + /// The input position carrying `placeholder`'s name, which is the parameter /// declared at that placeholder. pub(crate) fn name_input(node: &Node, placeholder: &Type) -> Option { @@ -772,6 +830,35 @@ mod tests { ); } + #[test] + fn a_named_write_takes_its_name_from_the_declared_input() { + let mut parsed = crate::parsing::parse_node_fn( + quote!(category("")), + quote!( + fn tag<'e, V: WireValue>(ctx: impl Ctx + ExtractArena<'e>, content: f64, name: Named, value: V) -> (f64, Attr<'e, Named>) { + (content, Attr(value)) + } + ), + ) + .unwrap(); + parsed.replace_impl_trait_in_input(); + let node = build(&parsed); + let attrs = &node.output.shape.attrs; + assert_eq!(attrs.len(), 1, "the write is recorded on the output"); + let (placeholder, _) = crate::parsing::named_marker(&attrs[0].marker).expect("the marker is name-generic"); + assert_eq!(name_input(&node, &placeholder), Some(1), "the name comes from the `Named` parameter"); + + // The row resolves the value type written through the wired generic. + let assignments = vec![(syn::parse_quote!(V), syn::parse_quote!(f64))]; + let emitted = named_field_writes(&node, "e!(gcore), &assignments); + assert_eq!(emitted.len(), 1, "the row carries the named write, got {emitted:?}"); + assert!( + emitted[0].to_string().contains("WireValue"), + "the projection is qualified by the generic's bound, got {}", + emitted[0] + ); + } + #[test] fn bridge_record_remove() { assert_bridge( diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index 7f0899d286..eac29563e7 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -369,6 +369,10 @@ impl Parse for NumberRange { #[derive(Clone, Debug)] pub struct RegularParsedField { pub ty: Type, + /// The placeholder this parameter names, written `Named`. Its `ty` is + /// rewritten to `String`, since the wire carries the name as constant text + /// while the kernel takes only the placeholder. + pub name_source: Option, /// `IList` nesting stripped from `ty` at parse; `ty` holds the element row. pub list_levels: u8, /// The original reference tokens when the parameter was written `&T`; `ty` holds the peeled inner type. @@ -1267,9 +1271,18 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul } } + // A `Named` parameter declares where `X`'s name is wired: the input + // carries constant text, the kernel takes only the placeholder. + let name_source = named_source(&ty); + let ty = match name_source { + Some(_) => parse_quote!(String), + None => ty, + }; + Ok(ParsedField { pat_ident, ty: ParsedFieldType::Regular(RegularParsedField { + name_source, exposed, number_soft_min, number_soft_max, @@ -1392,6 +1405,7 @@ impl ParsedNodeFn { widget_override: ParsedWidgetOverride::Hidden, ty: ParsedFieldType::Regular(RegularParsedField { ty, + name_source: None, list_levels: 0, lend: None, exposed: false, @@ -1569,6 +1583,7 @@ mod tests { description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { + name_source: None, lend: None, list_levels: 0, ty: parse_quote!(f64), @@ -1667,6 +1682,7 @@ mod tests { description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { + name_source: None, lend: None, list_levels: 0, ty: parse_quote!(DVec2), @@ -1746,6 +1762,7 @@ mod tests { description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { + name_source: None, lend: None, list_levels: 0, ty: parse_quote!(f64), @@ -1823,6 +1840,7 @@ mod tests { description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { + name_source: None, lend: None, list_levels: 0, ty: parse_quote!(f64), @@ -1912,6 +1930,7 @@ mod tests { description: String::from("b"), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { + name_source: None, lend: None, list_levels: 0, ty: parse_quote!(f64), @@ -2004,6 +2023,7 @@ mod tests { description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { + name_source: None, lend: None, list_levels: 0, ty: parse_quote!(String), diff --git a/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs b/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs index 50a8904467..360d204865 100644 --- a/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs +++ b/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs @@ -232,6 +232,7 @@ impl PerPixelAdjustCodegen<'_> { widget_override: Default::default(), ty: ParsedFieldType::Regular(RegularParsedField { ty: parse_quote!(#wgpu_executor::WgpuExecutorHandle), + name_source: None, list_levels: 0, lend: None, exposed: true, diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index bc929dbb86..30bf2c50ae 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -1,5 +1,5 @@ use brush_types::Stroke; -use core_types::attribute::{Attr, EditorLayerPath, Transform as TransformAttr}; +use core_types::attribute::{Attr, EditorLayerPath, Name0, Named, Transform as TransformAttr, WireValue}; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::extent::{ExtentIn, LevelIn, ListIn, ValueIn}; use core_types::gpoll::{Extent, GPoll, GraphError, Interrupt, Level}; @@ -371,6 +371,25 @@ pub fn stamp_layer_path<'e, T>(ctx: impl Ctx + ExtractArena<'e>, element: T, pat Ok((element, Attr(parked.as_slice()))) } +/// Writes `value` onto each lane under the attribute `name` names. The name is +/// constant text the compiler folds into the layout when the graph compiles, so +/// the write costs exactly what a marker node's does; a name that is not +/// constant is refused there rather than resolved here. +#[node_macro::node(category("Attributes: Write"))] +pub fn write_attribute<'e, T, V: WireValue>( + ctx: impl Ctx + ExtractArena<'e>, + content: T, + /// The attribute name, which the compiler folds and the kernel never reads. + _name: Named, + #[implementations(f64, u32, u64, bool, DVec2, DAffine2, Color, Vec, String)] value: V, +) -> Result<(T, Attr<'e, Named>), Interrupt> { + let parked = value.park(ctx.arena()).ok_or(GraphError { + kind: core_types::gpoll::ErrorKind::ArenaExhausted, + trace: Vec::new(), + })?; + Ok((content, Attr(parked))) +} + /// Joins two levels of the same type, the base's lanes followed by the new's. #[node_macro::node(category("General"), extent(extend_extent))] pub fn extend(